feat(Product.php): Ajoute les entités ProductPhotos et ProductVideo.
 feat(Product): Ajoute les collections photos et vidéos au produit.
🆕 feat(ProductPhotosType): Crée le formulaire d'upload des photos.
🆕 feat(ProductVideoType): Crée le formulaire d'upload des vidéos.
🎨 refactor(add.twig): Ajoute les formulaires et affichage des photos/vidéos.
🎨 refactor(produit.twig): Affiche les photos et vidéos sur la page produit.
♻️ refactor(vich_uploader.yaml): Ajoute les mappings pour photos et vidéos.
🐛 fix(ProductController): Gère l'ajout/suppression des photos et vidéos.
```
This commit is contained in:
Serreau Jovann
2026-01-30 11:29:29 +01:00
parent 3cc493eba6
commit e1227c5d14
12 changed files with 817 additions and 99 deletions

View File

@@ -9,6 +9,14 @@ vich_uploader:
uri_prefix: /images/image_product
upload_destination: '%kernel.project_dir%/public/images/image_product'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
image_product_pic:
uri_prefix: /images/image_product_pic
upload_destination: '%kernel.project_dir%/public/images/image_product_pic'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
image_product_video:
uri_prefix: /images/image_product_video
upload_destination: '%kernel.project_dir%/public/images/image_product_video'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
doc_product:
uri_prefix: /images/doc_product
upload_destination: '%kernel.project_dir%/public/images/doc_product'

View File

@@ -0,0 +1,42 @@
<?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 Version20260130095656 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 product_photos (id SERIAL NOT NULL, product_id INT DEFAULT NULL, image_name VARCHAR(255) DEFAULT NULL, image_size INT DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6A0AA17D4584665A ON product_photos (product_id)');
$this->addSql('COMMENT ON COLUMN product_photos.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE product_video (id SERIAL NOT NULL, product_id INT DEFAULT NULL, image_name VARCHAR(255) DEFAULT NULL, image_size INT DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_DD9BA1704584665A ON product_video (product_id)');
$this->addSql('COMMENT ON COLUMN product_video.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_photos ADD CONSTRAINT FK_6A0AA17D4584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE product_video ADD CONSTRAINT FK_DD9BA1704584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
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 product_photos DROP CONSTRAINT FK_6A0AA17D4584665A');
$this->addSql('ALTER TABLE product_video DROP CONSTRAINT FK_DD9BA1704584665A');
$this->addSql('DROP TABLE product_photos');
$this->addSql('DROP TABLE product_video');
}
}

View File

@@ -5,9 +5,13 @@ namespace App\Controller\Dashboard;
use App\Entity\Options;
use App\Entity\Product;
use App\Entity\ProductDoc;
use App\Entity\ProductPhotos;
use App\Entity\ProductVideo;
use App\Form\OptionsType;
use App\Form\ProductDocType;
use App\Form\ProductPhotosType;
use App\Form\ProductType;
use App\Form\ProductVideoType;
use App\Logger\AppLogger;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
@@ -126,7 +130,7 @@ class ProductController extends AbstractController
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', methods: ['GET', 'POST'])]
public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe): Response
{
// 1. Gestion de la suppression de document
// 1. Suppression de Document
if ($idDoc = $request->query->get('idDoc')) {
$doc = $em->getRepository(ProductDoc::class)->find($idDoc);
if ($doc && $doc->getProduct() === $product) {
@@ -139,7 +143,59 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 2. Formulaire Document
// 2. Suppression de Vidéo
if ($idVideo = $request->query->get('idVideo')) {
$video = $em->getRepository(ProductVideo::class)->find($idVideo);
if ($video && $video->getProduct() === $product) {
$em->remove($video);
$em->flush();
$logger->record('DELETE', "Vidéo supprimée sur {$product->getName()}");
$this->addFlash('success', 'Vidéo supprimée.');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 3. Suppression de Photo
if ($idPhoto = $request->query->get('idPhoto')) {
$photo = $em->getRepository(ProductPhotos::class)->find($idPhoto);
if ($photo && $photo->getProduct() === $product) {
$em->remove($photo);
$em->flush();
$logger->record('DELETE', "Photo supprimée sur {$product->getName()}");
$this->addFlash('success', 'Photo supprimée.');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 4. Formulaire Ajout Photo
$photo = new ProductPhotos();
$photo->setProduct($product);
$formPhotos = $this->createForm(ProductPhotosType::class, $photo);
$formPhotos->handleRequest($request);
if ($formPhotos->isSubmitted() && $formPhotos->isValid()) {
$em->persist($photo);
$em->flush();
$logger->record('UPDATE', "Ajout photo sur : {$product->getName()}");
$this->addFlash('success', 'Photo ajoutée.');
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 5. Formulaire Ajout Vidéo
$video = new ProductVideo();
$video->setProduct($product);
$formVideo = $this->createForm(ProductVideoType::class, $video);
$formVideo->handleRequest($request);
if ($formVideo->isSubmitted() && $formVideo->isValid()) {
$em->persist($video);
$em->flush();
$logger->record('UPDATE', "Ajout vidéo sur : {$product->getName()}");
$this->addFlash('success', 'Vidéo ajoutée.');
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 6. Formulaire Ajout Document
$doc = new ProductDoc();
$doc->setProduct($product);
$formDoc = $this->createForm(ProductDocType::class, $doc);
@@ -154,7 +210,7 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 3. Formulaire Produit
// 7. Formulaire Principal Produit
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
@@ -171,11 +227,12 @@ class ProductController extends AbstractController
return $this->render('dashboard/products/add.twig', [
'form' => $form->createView(),
'formDoc' => $formDoc->createView(),
'formPhoto' => $formPhotos->createView(),
'formVideo' => $formVideo->createView(),
'product' => $product,
'is_edit' => true
]);
}
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', methods: ['POST'])]
public function productDelete(Product $product, EntityManagerInterface $em, Request $request, AppLogger $logger, Client $stripe): Response
{

View File

@@ -87,11 +87,25 @@ class Product
#[ORM\OneToMany(targetEntity: FormulesProductInclus::class, mappedBy: 'PRODUCT')]
private Collection $formulesProductIncluses;
/**
* @var Collection<int, ProductPhotos>
*/
#[ORM\OneToMany(targetEntity: ProductPhotos::class, mappedBy: 'product')]
private Collection $productPhotos;
/**
* @var Collection<int, ProductVideo>
*/
#[ORM\OneToMany(targetEntity: ProductVideo::class, mappedBy: 'product')]
private Collection $productVideos;
public function __construct()
{
$this->productReserves = new ArrayCollection();
$this->productDocs = new ArrayCollection();
$this->formulesProductIncluses = new ArrayCollection();
$this->productPhotos = new ArrayCollection();
$this->productVideos = new ArrayCollection();
}
public function slug()
{
@@ -400,4 +414,64 @@ class Product
return $this;
}
/**
* @return Collection<int, ProductPhotos>
*/
public function getProductPhotos(): Collection
{
return $this->productPhotos;
}
public function addProductPhoto(ProductPhotos $productPhoto): static
{
if (!$this->productPhotos->contains($productPhoto)) {
$this->productPhotos->add($productPhoto);
$productPhoto->setProduct($this);
}
return $this;
}
public function removeProductPhoto(ProductPhotos $productPhoto): static
{
if ($this->productPhotos->removeElement($productPhoto)) {
// set the owning side to null (unless already changed)
if ($productPhoto->getProduct() === $this) {
$productPhoto->setProduct(null);
}
}
return $this;
}
/**
* @return Collection<int, ProductVideo>
*/
public function getProductVideos(): Collection
{
return $this->productVideos;
}
public function addProductVideo(ProductVideo $productVideo): static
{
if (!$this->productVideos->contains($productVideo)) {
$this->productVideos->add($productVideo);
$productVideo->setProduct($this);
}
return $this;
}
public function removeProductVideo(ProductVideo $productVideo): static
{
if ($this->productVideos->removeElement($productVideo)) {
// set the owning side to null (unless already changed)
if ($productVideo->getProduct() === $this) {
$productVideo->setProduct(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Entity;
use App\Repository\ProductPhotosRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: ProductPhotosRepository::class)]
#[Uploadable]
class ProductPhotos
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'productPhotos')]
private ?Product $product = null;
#[UploadableField(mapping: 'image_product_pic', fileNameProperty: 'imageName', size: 'imageSize')]
private ?File $imageFile = null;
#[ORM\Column(nullable: true)]
private ?string $imageName = null;
#[ORM\Column(nullable: true)]
private ?int $imageSize = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
/**
* @return int|null
*/
public function getImageSize(): ?int
{
return $this->imageSize;
}
/**
* @return string|null
*/
public function getImageName(): ?string
{
return $this->imageName;
}
/**
* @return File|null
*/
public function getImageFile(): ?File
{
return $this->imageFile;
}
/**
* @return \DateTimeImmutable|null
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
/**
* @param int|null $imageSize
*/
public function setImageSize(?int $imageSize): void
{
$this->imageSize = $imageSize;
}
/**
* @param string|null $imageName
*/
public function setImageName(?string $imageName): void
{
$this->imageName = $imageName;
}
/**
* @param File|null $imageFile
*/
public function setImageFile(?File $imageFile): void
{
$this->imageFile = $imageFile;
}
/**
* @param \DateTimeImmutable|null $updatedAt
*/
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}

115
src/Entity/ProductVideo.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
namespace App\Entity;
use App\Repository\ProductVideoRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: ProductVideoRepository::class)]
#[Uploadable]
class ProductVideo
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'productVideos')]
private ?Product $product = null;
#[UploadableField(mapping: 'image_product_video', fileNameProperty: 'imageName', size: 'imageSize')]
private ?File $imageFile = null;
#[ORM\Column(nullable: true)]
private ?string $imageName = null;
#[ORM\Column(nullable: true)]
private ?int $imageSize = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
/**
* @return \DateTimeImmutable|null
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
/**
* @return File|null
*/
public function getImageFile(): ?File
{
return $this->imageFile;
}
/**
* @return string|null
*/
public function getImageName(): ?string
{
return $this->imageName;
}
/**
* @return int|null
*/
public function getImageSize(): ?int
{
return $this->imageSize;
}
/**
* @param \DateTimeImmutable|null $updatedAt
*/
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
/**
* @param File|null $imageFile
*/
public function setImageFile(?File $imageFile): void
{
$this->imageFile = $imageFile;
}
/**
* @param string|null $imageName
*/
public function setImageName(?string $imageName): void
{
$this->imageName = $imageName;
}
/**
* @param int|null $imageSize
*/
public function setImageSize(?int $imageSize): void
{
$this->imageSize = $imageSize;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use App\Entity\Product;
use App\Entity\ProductDoc;
use App\Entity\ProductPhotos;
use App\Entity\ProductVideo;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
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 ProductPhotosType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('imageFile',FileType::class,[
'label' => 'Videos',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ProductPhotos::class,
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use App\Entity\Product;
use App\Entity\ProductDoc;
use App\Entity\ProductVideo;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
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 ProductVideoType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('imageFile',FileType::class,[
'label' => 'Videos',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ProductVideo::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\ProductPhotos;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProductPhotos>
*/
class ProductPhotosRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProductPhotos::class);
}
// /**
// * @return ProductPhotos[] Returns an array of ProductPhotos 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): ?ProductPhotos
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\ProductVideo;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProductVideo>
*/
class ProductVideoRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProductVideo::class);
}
// /**
// * @return ProductVideo[] Returns an array of ProductVideo 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): ?ProductVideo
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -9,14 +9,11 @@
<a target="_blank" rel="nofollow"
href="https://reservation.ludikevent.fr{{ path('reservation_product_show', {id: product.slug}) }}"
class="flex items-center px-6 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-blue-500/50 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-2xl transition-all group shadow-xl backdrop-blur-md">
<svg class="w-4 h-4 mr-2.5 text-blue-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
Voir sur le site
<svg class="w-3 h-3 ml-2 text-slate-300 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
@@ -24,17 +21,17 @@
{% endif %}
</div>
{% endblock %}
{% block body %}
<div class="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-700">
{{ form_start(form) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# COLONNE GAUCHE : VISUEL & IDENTITÉ #}
{# --- COLONNE GAUCHE --- #}
<div class="lg:col-span-2 space-y-8">
{# 00. IMAGE DU PRODUIT #}
{# SECTION IMAGE #}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-purple-600/20 text-purple-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">00</span>
@@ -42,14 +39,13 @@
</h3>
<div class="flex flex-col md:flex-row items-center gap-8">
{# Preview Image #}
<div class="relative flex-shrink-0">
<div class="w-48 h-48 rounded-[2rem] overflow-hidden border-2 border-white/10 bg-slate-900/50 flex items-center justify-center">
{# L'image avec un ID spécifique pour le JS #}
<img id="product-image-preview"
src="{{ product.imageName ? vich_uploader_asset(product, 'imageFile') | imagine_filter('webp') : '#' }}"
class="w-full h-full object-cover {{ product.imageName ? '' : 'hidden' }}">
{# L'icône de remplacement #}
<svg id="product-image-placeholder"
class="w-12 h-12 text-slate-700 {{ product.imageName ? 'hidden' : '' }}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -58,10 +54,9 @@
</div>
</div>
{# Input File #}
<div class="flex-1 w-full">
{{ form_label(form.imageFile, 'Sélectionner un fichier', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest mb-2 block'}}) }}
{# On enlève le "onchange" inline, on cible via l'ID généré par Symfony ou un ID fixe #}
{{ form_widget(form.imageFile, {
'id': 'product_image_input',
'attr': {
@@ -71,6 +66,7 @@
</div>
</div>
</div>
{# 01. INFORMATIONS GÉNÉRALES #}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
@@ -93,9 +89,8 @@
{{ form_label(form.ref, 'Référence Interne (SKU)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(form.ref, {'attr': {'placeholder': 'REF-000', 'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white font-mono focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3.5 px-5'}}) }}
</div>
</div>
{# À placer juste après le bloc Référence Interne #}
<div class="md:col-span-2 mt-6">
<div class="md:col-span-2 mt-2">
{{ form_label(form.description, 'Description détaillée', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(form.description, {
'attr': {
@@ -111,8 +106,10 @@
{% endif %}
</div>
</div>
</div>
{# 02. DIMENSIONS DU PRODUIT #}
<div class="mt-8 backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-blue-600/20 text-blue-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">02</span>
Dimensions de la Structure
@@ -155,18 +152,19 @@
</div>
</div>
{# COLONNE DROITE : TARIFICATION #}
{# --- COLONNE DROITE --- #}
<div class="lg:col-span-1 space-y-8">
{# 03. FINANCES & TARIFS #}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl h-full border-l-emerald-500/10 border-l-2">
<h3 class="text-lg font-bold text-white mb-8 flex items-center">
<span class="w-8 h-8 bg-emerald-600/20 text-emerald-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">02</span>
<span class="w-8 h-8 bg-emerald-600/20 text-emerald-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">03</span>
Finances & Tarifs
</h3>
{# PRIX LOCATION #}
<div class="space-y-6">
<div>
{{ form_label(form.priceDay, 'Tarif 1er Jour (€) Ou Tarif weekend (si Barnums)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest ml-1 mb-2 block'}}) }}
{{ form_label(form.priceDay, 'Tarif 1er Jour (€)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest ml-1 mb-2 block'}}) }}
<p class="text-[9px] text-slate-500 italic mb-2 -mt-1">Ou tarif weekend pour les barnums</p>
<div class="relative">
{{ form_widget(form.priceDay, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-emerald-400 font-black text-xl focus:ring-emerald-500/20 focus:border-emerald-500 transition-all py-4 px-5'}}) }}
<span class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-600 font-bold">€HT</span>
@@ -182,9 +180,9 @@
</div>
</div>
<hr class="mt-2"/>
{# CAUTION #}
<div class="pt-8 border-t border-white/5">
<hr class="mt-8 border-white/5"/>
<div class="pt-8">
{{ form_label(form.caution, 'Montant de la Caution (€)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest ml-1 mb-2 block'}}) }}
<div class="relative">
{{ form_widget(form.caution, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white font-mono focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3 px-5 text-center'}}) }}
@@ -198,7 +196,7 @@
</div>
<p class="text-[11px] leading-relaxed text-slate-300">
<strong class="text-white block mb-1 uppercase tracking-wider">Information importante</strong>
Cette somme sera prélevée sur le compte bancaire du client. Conformément aux politiques de <span class="text-blue-400">Stripe</span>, les fonds peuvent être bloqués pour un maximum de <span class="font-bold text-white">4 jours</span>. Passé ce délai, aucun prélèvement ultérieur ne sera possible.
Cette somme sera prélevée sur le compte bancaire du client. Conformément aux politiques de <span class="text-blue-400">Stripe</span>, les fonds peuvent être bloqués pour un maximum de <span class="font-bold text-white">4 jours</span>.
</p>
</div>
</div>
@@ -206,7 +204,6 @@
</div>
</div>
</div>
</div>
{# FOOTER ACTIONS #}
<div class="mt-12 mb-20 flex items-center justify-between backdrop-blur-xl bg-slate-900/40 p-6 rounded-[2rem] border border-white/5 shadow-xl">
@@ -221,20 +218,20 @@
{{ form_end(form) }}
{# 03. DOCUMENTS TECHNIQUES (PDF) #}
{# 04. DOCUMENTS TECHNIQUES (PDF) #}
{% if formDoc is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-amber-600/20 text-amber-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">03</span>
<span class="w-8 h-8 bg-amber-600/20 text-amber-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">04</span>
Documents & Notices
</h3>
{# LISTE DES DOCUMENTS EXISTANTS #}
{# LISTE DES DOCUMENTS #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-10">
{% for doc in product.productDocs %}
<div class="flex items-center justify-between p-4 bg-white/5 border border-white/5 rounded-2xl group hover:bg-white/10 transition-all">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-500/20 text-red-500 rounded-xl flex items-center justify-center mr-4">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-red-500/20 text-red-500 rounded-xl flex items-center justify-center">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
</div>
<div>
@@ -248,8 +245,8 @@
<a href="{{ vich_uploader_asset(doc, 'docProduct') }}" target="_blank" class="p-2 text-slate-400 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id,act:'deleted',idDoc:doc.id}) }}"
onsubmit="return confirm('Confirmer la suppression de ce document ?');"
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id, act:'deleted', idDoc: doc.id}) }}"
onsubmit="return confirm('Confirmer la suppression ?');"
class="inline-block">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ doc.id) }}">
<button type="submit" class="p-2 text-slate-400 hover:text-rose-500 transition-colors" title="Supprimer">
@@ -258,7 +255,6 @@
</svg>
</button>
</form>
{# Ici tu pourrais ajouter un lien de suppression #}
</div>
</div>
{% else %}
@@ -270,7 +266,7 @@
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT #}
{# FORMULAIRE D'AJOUT DOC #}
{{ form_start(formDoc) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div>
@@ -302,5 +298,120 @@
{{ form_end(formDoc) }}
</div>
{% endif %}
{# 05. VIDEOS #}
{% if formVideo is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">05</span>
Vidéos
</h3>
{# LISTE DES VIDÉOS #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
{% for video in product.productVideos %}
<div class="relative group rounded-2xl overflow-hidden border border-white/5 bg-black/50 aspect-video shadow-lg">
<video controls class="w-full h-full object-cover">
<source src="{{ vich_uploader_asset(video, 'imageFile') }}" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
{# DELETE BUTTON VIDEO #}
<div class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all z-20">
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id, act:'deleteVideo', idVideo: video.id}) }}"
onsubmit="return confirm('Supprimer cette vidéo ?');" class="inline-block">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ video.id) }}">
<button type="submit" class="p-2.5 bg-black/60 hover:bg-rose-600 text-white rounded-xl backdrop-blur-md transition-all shadow-lg hover:scale-105">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</form>
</div>
</div>
{% else %}
<div class="md:col-span-2 py-8 text-center border-2 border-dashed border-white/5 rounded-3xl">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em]">Aucune vidéo</p>
</div>
{% endfor %}
</div>
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT VIDEO #}
{{ form_start(formVideo) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="md:col-span-2 bg-slate-950/40 p-6 rounded-[2rem] border border-dashed border-white/10 group hover:border-rose-500/30 transition-all">
{{ form_label(formVideo.imageFile, 'Fichier Vidéo (MP4, WebM...)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest mb-4 block text-center'}}) }}
{{ form_widget(formVideo.imageFile, {
'attr': {
'class': 'block w-full text-xs text-slate-400 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-[10px] file:font-black file:uppercase file:tracking-widest file:bg-rose-600 file:text-white hover:file:bg-rose-500 transition-all cursor-pointer'
}
}) }}
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="submit" class="group px-8 py-4 bg-rose-600/10 hover:bg-rose-600 text-rose-500 hover:text-white text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all border border-rose-500/20 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"/></svg>
Ajouter la vidéo
</button>
</div>
{{ form_end(formVideo) }}
</div>
{% endif %}
{# 06. PHOTOS #}
{% if formPhoto is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-cyan-600/20 text-cyan-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">06</span>
Photos Supplémentaires
</h3>
{# LISTE DES PHOTOS #}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 mb-10">
{% for photo in product.productPhotos %}
<div class="relative group rounded-2xl overflow-hidden border border-white/5 aspect-square shadow-lg">
<img src="{{ vich_uploader_asset(photo, 'imageFile') }}" class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-700">
{# DELETE BUTTON PHOTO #}
<div class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all z-20">
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id, act:'deletePhoto', idPhoto: photo.id}) }}"
onsubmit="return confirm('Supprimer cette photo ?');" class="inline-block">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ photo.id) }}">
<button type="submit" class="p-2.5 bg-black/60 hover:bg-rose-600 text-white rounded-xl backdrop-blur-md transition-all shadow-lg hover:scale-105">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</form>
</div>
</div>
{% else %}
<div class="col-span-full py-8 text-center border-2 border-dashed border-white/5 rounded-3xl">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em]">Aucune photo</p>
</div>
{% endfor %}
</div>
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT PHOTO #}
{{ form_start(formPhoto) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="md:col-span-2 bg-slate-950/40 p-6 rounded-[2rem] border border-dashed border-white/10 group hover:border-cyan-500/30 transition-all">
{{ form_label(formPhoto.imageFile, 'Fichier Image (JPG, PNG...)', {'label_attr': {'class': 'text-[10px] font-black text-slate-300 uppercase tracking-widest mb-4 block text-center'}}) }}
{{ form_widget(formPhoto.imageFile, {
'attr': {
'class': 'block w-full text-xs text-slate-400 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-[10px] file:font-black file:uppercase file:tracking-widest file:bg-cyan-600 file:text-white hover:file:bg-cyan-500 transition-all cursor-pointer'
}
}) }}
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="submit" class="group px-8 py-4 bg-cyan-600/10 hover:bg-cyan-600 text-cyan-500 hover:text-white text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all border border-cyan-500/20 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"/></svg>
Ajouter la photo
</button>
</div>
{{ form_end(formPhoto) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -242,6 +242,40 @@
</div>
</div>
{# --- GALERIE PHOTOS --- #}
{% if product.productPhotos|length > 0 %}
<div class="mt-20">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 block italic text-center md:text-left">Galerie Photos</span>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
{% for photo in product.productPhotos %}
<div class="relative overflow-hidden rounded-[2rem] bg-slate-50 aspect-square shadow-sm hover:shadow-xl transition-all duration-500 group">
<img src="{{ vich_uploader_asset(photo, 'imageFile') }}"
alt="Photo {{ product.name }}"
class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-700"
loading="lazy">
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# --- VIDEOS --- #}
{% if product.productVideos|length > 0 %}
<div class="mt-20">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8 block italic text-center md:text-left">Vidéos de présentation</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{% for video in product.productVideos %}
<div class="relative overflow-hidden rounded-[2.5rem] bg-black shadow-lg aspect-video group">
<video controls class="w-full h-full object-cover" preload="metadata">
<source src="{{ vich_uploader_asset(video, 'imageFile') }}" type="video/mp4">
Votre navigateur ne supporte pas la lecture de vidéos.
</video>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# --- DOCUMENTS PUBLICS --- #}
{% set publicDocs = product.productDocs|filter(doc => doc.isPublic) %}
{% if publicDocs|length > 0 %}