✨ feat(Product): Ajoute la génération de slug pour les produits.
🐛 fix(ReserverController): Corrige la route de la sitemap. ♻️ refactor(SiteMapListener): Génère les URLs des produits dans la sitemap. 🔧 chore(ansible): Ajoute le dossier seo aux dossiers à sauvegarder.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,3 +54,4 @@ backup/*.sql
|
||||
/public/images/**/*.webp
|
||||
/public/images/*/*.png
|
||||
/public/pdf/**/*.pdf
|
||||
/public/seo/*.xml
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
- "{{ path }}/public/media" # For uploads
|
||||
- "{{ path }}/public/images" # For uploads
|
||||
- "{{ path }}/public/pdf" # For uploads
|
||||
- "{{ path }}/public/seo" # For uploads
|
||||
- "{{ path }}/public/tmp-sign" # For upload
|
||||
- "{{ path }}/sauvegarde"
|
||||
|
||||
@@ -248,5 +249,6 @@
|
||||
- "{{ path }}/sauvegarde"
|
||||
- "{{ path }}/public/images" # For uploads
|
||||
- "{{ path }}/public/pdf"
|
||||
- "{{ path }}/public/seo"
|
||||
- "{{ path }}/public/tmp-sign" # For uploads
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ framework:
|
||||
async: "%env(MESSENGER_TRANSPORT_DSN)%"
|
||||
|
||||
routing:
|
||||
|
||||
Presta\SitemapBundle\Messenger\DumpSitemapMessage: async
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
presta_sitemap:
|
||||
sitemap_file_prefix: 'sitemap'
|
||||
dump_directory: '%kernel.project_dir%/public/seo'
|
||||
|
||||
@@ -6,17 +6,7 @@ controllers:
|
||||
type: attribute
|
||||
|
||||
|
||||
PrestaSitemapBundle_index:
|
||||
path: "/sa2/%presta_sitemap.sitemap_file_prefix%.xml"
|
||||
defaults: { _controller: Presta\SitemapBundle\Controller\SitemapController::indexAction }
|
||||
requirements:
|
||||
_format: xml
|
||||
|
||||
PrestaSitemapBundle_section:
|
||||
path: "/sa2/%presta_sitemap.sitemap_file_prefix%.{name}.{_format}"
|
||||
defaults: { _controller: Presta\SitemapBundle\Controller\SitemapController::sectionAction }
|
||||
requirements:
|
||||
_format: xml
|
||||
|
||||
2fa_login:
|
||||
path: /2fa
|
||||
|
||||
0
public/seo/.gitignore
vendored
Normal file
0
public/seo/.gitignore
vendored
Normal file
@@ -41,6 +41,7 @@ class BackupCommand extends Command
|
||||
$sqlDumpPath = $projectDir . '/var/temp_db.sql';
|
||||
$imagesDir = $projectDir . '/public/images';
|
||||
$pdfDir = $projectDir . '/public/pdf';
|
||||
$seoDif = $projectDir . '/public/seo';
|
||||
|
||||
try {
|
||||
if (!is_dir($backupDir)) {
|
||||
@@ -85,7 +86,10 @@ class BackupCommand extends Command
|
||||
$io->text('Compression du dossier pdf...');
|
||||
$this->addFolderToZip($pdfDir, $zip, 'pdf');
|
||||
}
|
||||
|
||||
if (is_dir($seoDif)) {
|
||||
$io->text('Compression du dossier seo...');
|
||||
$this->addFolderToZip($seoDif, $zip, 'seo');
|
||||
}
|
||||
$zip->close();
|
||||
} else {
|
||||
throw new \Exception("Impossible d'initialiser le fichier ZIP.");
|
||||
|
||||
@@ -18,10 +18,12 @@ use App\Service\Mailer\Mailer;
|
||||
use App\Service\Stripe\Client;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Presta\SitemapBundle\Messenger\DumpSitemapMessage;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
@@ -61,7 +63,8 @@ class ProductController extends AbstractController
|
||||
EntityManagerInterface $entityManager,
|
||||
AppLogger $appLogger,
|
||||
Client $client,
|
||||
Request $request
|
||||
Request $request,
|
||||
MessageBusInterface $messageBus,
|
||||
): Response {
|
||||
$appLogger->record('VIEW', 'Consultation page création d\'un produit');
|
||||
|
||||
@@ -85,6 +88,7 @@ class ProductController extends AbstractController
|
||||
// Message flash de succès
|
||||
$this->addFlash('success', sprintf('Le produit "%s" a été ajouté au catalogue avec succès.', $productName));
|
||||
|
||||
$messageBus->dispatch(new DumpSitemapMessage());
|
||||
// Redirection vers le listing des produits
|
||||
return $this->redirectToRoute('app_crm_product');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Entity\AccountResetPasswordRequest;
|
||||
use App\Entity\Product;
|
||||
use App\Form\RequestPasswordConfirmType;
|
||||
use App\Form\RequestPasswordRequestType;
|
||||
use App\Logger\AppLogger;
|
||||
@@ -40,7 +41,7 @@ class ReserverController extends AbstractController
|
||||
$robots->disallow('/payment');
|
||||
$robots->crawlDelay(60);
|
||||
$robots->allow('/reservation');
|
||||
$robots->sitemap($request->getSchemeAndHttpHost().$this->generateUrl('PrestaSitemapBundle_index',['_format' => 'xml']));
|
||||
$robots->sitemap($request->getSchemeAndHttpHost().'/seo/sitemap.xml');
|
||||
|
||||
return new Response($robots->toString(),Response::HTTP_OK,[
|
||||
'Content-Type' => 'text/plain'
|
||||
@@ -63,10 +64,30 @@ class ReserverController extends AbstractController
|
||||
]);
|
||||
}
|
||||
#[Route('/reservation/produit/{id}', name: 'reservation_product_show')]
|
||||
public function revervationShowProduct(ProductRepository $productRepository): Response
|
||||
public function revervationShowProduct(string $id, ProductRepository $productRepository): Response
|
||||
{
|
||||
// 1. Extraction de l'ID (ex: "15-chateau-fort" -> 15)
|
||||
$parts = explode('-', $id);
|
||||
$realId = $parts[0]; // Récupère le tout premier élément (l'index 0)
|
||||
|
||||
// 2. Récupération du produit par son ID numérique
|
||||
$product = $productRepository->find($realId);
|
||||
|
||||
if (!$product) {
|
||||
throw $this->createNotFoundException('Produit introuvable');
|
||||
}
|
||||
|
||||
// 3. Logique des suggestions (inchangée)
|
||||
$allInCat = $productRepository->findBy(['category' => $product->getCategory()], [], 5);
|
||||
|
||||
$otherProducts = array_filter($allInCat, function($p) use ($product) {
|
||||
return $p->getId() !== $product->getId();
|
||||
});
|
||||
|
||||
return $this->render('revervation/produit.twig', [
|
||||
'product' => $product,
|
||||
'otherProducts' => array_slice($otherProducts, 0, 4)
|
||||
]);
|
||||
}
|
||||
#[Route('/reservation/contact', name: 'reservation_contact')]
|
||||
public function revervationContact(Request $request, Mailer $mailer): Response
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ProductRepository;
|
||||
use Cocur\Slugify\Slugify;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -72,6 +73,12 @@ class Product
|
||||
$this->devisLines = new ArrayCollection();
|
||||
$this->productReserves = new ArrayCollection();
|
||||
}
|
||||
public function slug()
|
||||
{
|
||||
$s = new Slugify();
|
||||
|
||||
return$s->slugify($this->id."-".$this->name);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Repository\ProductRepository;
|
||||
use Presta\SitemapBundle\Event\SitemapPopulateEvent;
|
||||
use Presta\SitemapBundle\Sitemap\Url\UrlConcrete;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
@@ -20,6 +21,10 @@ use Twig\Environment;
|
||||
class SiteMapListener implements EventSubscriberInterface
|
||||
{
|
||||
|
||||
public function __construct(private readonly ProductRepository $productRepository)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public static function getSubscribedEvents()
|
||||
{
|
||||
@@ -35,20 +40,24 @@ class SiteMapListener implements EventSubscriberInterface
|
||||
|
||||
$t = new \DateTime();
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_DAILY,1);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_contact',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_DAILY,1);
|
||||
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_cookies',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_mentions-legal',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_rgpd',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_hosting',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_cgv',[], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'revervation');
|
||||
$urlContainer->addUrl($rv,'reservation');
|
||||
|
||||
foreach ($this->productRepository->findAll() as $product) {
|
||||
$rv = new UrlConcrete($urlGenerator->generate('reservation_product_show',['id'=>$product->slug()], UrlGeneratorInterface::ABSOLUTE_URL),$t,UrlConcrete::CHANGEFREQ_WEEKLY,0.5);
|
||||
$urlContainer->addUrl($rv,'reservation_product');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="min-h-screen bg-gray-50/50 font-sans antialiased pb-20">
|
||||
|
||||
{# --- HEADER --- #}
|
||||
<div class="max-w-[1600px] mx-auto pt-16 pb-8 px-4 text-center">
|
||||
<div class="max-w-7xl mx-auto pt-16 pb-8 px-4 text-center">
|
||||
<nav class="flex justify-center space-x-4 text-[10px] mb-8 uppercase tracking-[0.3em] font-black italic">
|
||||
<a href="{{ url('reservation') }}" class="text-slate-400 hover:text-blue-600 transition">ACCUEIL</a>
|
||||
<span class="text-slate-300">/</span>
|
||||
@@ -25,17 +25,14 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# --- BARRE DE RECHERCHE / FILTRES (STICKY) --- #}
|
||||
{# --- FILTRES (STICKY) --- #}
|
||||
<div class="sticky top-0 z-40 bg-gray-50/90 backdrop-blur-md border-b border-slate-200 mb-12">
|
||||
<div class="max-w-[1600px] mx-auto px-4 py-4">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4">
|
||||
<div class="flex flex-wrap justify-center gap-2 md:gap-3">
|
||||
|
||||
{# Bouton Tout voir #}
|
||||
<button data-filter="all" class="filter-btn px-5 py-2.5 rounded-xl font-black italic text-[9px] tracking-widest transition-all uppercase shadow-sm bg-slate-900 text-white border border-slate-900">
|
||||
Tout voir
|
||||
</button>
|
||||
|
||||
{# Liste des catégories en dur #}
|
||||
{% set categories_list = [
|
||||
{'id': '3-15 ans', 'label': '3-15 ANS', 'hover': 'hover:border-blue-600 hover:text-blue-600'},
|
||||
{'id': '2-7 ans', 'label': '2-7 ANS', 'hover': 'hover:border-amber-500 hover:text-amber-500'},
|
||||
@@ -55,52 +52,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- GRILLE DE PRODUITS (5 COLONNES) --- #}
|
||||
<div class="max-w-[1600px] mx-auto px-4">
|
||||
<div id="product-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-12">
|
||||
{# --- GRILLE DE PRODUITS (4 COLONNES) --- #}
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
{# Mobile: 2 col | Tablette: 3 col | Desktop: 4 col #}
|
||||
<div id="product-grid" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-12">
|
||||
|
||||
{% for product in products %}
|
||||
<div class="product-item group transition-all duration-500" data-category="{{ product.category|lower }}">
|
||||
<a href="{{ path('reservation_product_show', {id: product.id}) }}" class="block">
|
||||
<a href="{{ path('reservation_product_show', {id: product.slug}) }}" class="block">
|
||||
|
||||
{# Image avec arrondi et badge prix #}
|
||||
<div class="relative overflow-hidden rounded-[2.5rem] bg-slate-100 aspect-square mb-4 shadow-sm group-hover:shadow-2xl transition-all duration-700">
|
||||
{# IMAGE #}
|
||||
<div class="relative overflow-hidden rounded-[1rem] bg-slate-100 aspect-square mb-6 shadow-sm group-hover:shadow-2xl transition-all duration-700">
|
||||
{% if product.imageName %}
|
||||
<img src="{{ vich_uploader_asset(product,'imageFile') | imagine_filter('webp') }}"
|
||||
alt="{{ product.name }}"
|
||||
class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-1000">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<img src="{{ asset('provider/images/favicon.png') }}" class="w-10 h-10 opacity-10">
|
||||
</div>
|
||||
<img src="{{ asset('provider/images/favicon.png') | imagine_filter('webp') }}"
|
||||
alt="{{ product.name }}"
|
||||
class="w-full h-full object-cover opacity-50 transform group-hover:scale-110 transition-transform duration-1000">
|
||||
{% endif %}
|
||||
|
||||
{# Badge Prix #}
|
||||
<div class="absolute top-3 right-3 bg-white/95 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-sm border border-slate-100">
|
||||
<p class="text-slate-900 font-black text-[12px] italic leading-none">
|
||||
{{ product.priceDay }}€
|
||||
{# PRIX : Plus gros et plus gras #}
|
||||
<div class="absolute top-5 right-5 bg-white/95 backdrop-blur-md px-4 py-2 rounded-2xl shadow-md border border-slate-100">
|
||||
<p class="text-slate-900 font-black text-lg italic leading-none">
|
||||
A partir de {{ product.priceDay }}€ / Jour
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Informations produit #}
|
||||
{# INFOS : Texte augmenté #}
|
||||
<div class="px-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-[7px] font-black text-blue-600 uppercase tracking-widest italic">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
<span class="text-[7px] font-bold text-slate-300 uppercase tracking-tighter">
|
||||
REF. {{ product.ref }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
{# Catégorie : Plus lisible #}
|
||||
<span class="text-[10px] font-black text-blue-600 uppercase tracking-[0.2em] italic">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
<span class="w-4 h-[1px] bg-slate-200"></span>
|
||||
<span class="text-[9px] font-bold text-slate-300 uppercase tracking-widest">
|
||||
REF. {{ product.ref }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-black text-slate-900 uppercase italic tracking-tighter leading-tight group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{# TITRE : Passage en text-2xl pour un impact maximum #}
|
||||
<h3 class="text-2xl font-black text-slate-900 uppercase tracking-tighter leading-[0.95] group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 flex items-center gap-1 text-[8px] font-black uppercase text-slate-400 group-hover:text-blue-600 transition-colors">
|
||||
<span>Voir le produit</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{# LIEN : Plus visible #}
|
||||
<div class="mt-5 flex items-center gap-2 text-[11px] font-black uppercase tracking-[0.15em] text-slate-400 group-hover:text-blue-600 transition-colors">
|
||||
<span>Découvrir le produit</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transform group-hover:translate-x-2 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -109,32 +111,12 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Message si catégorie vide #}
|
||||
{# Message vide #}
|
||||
<div id="empty-msg" class="hidden col-span-full py-24 text-center bg-white rounded-[3.5rem] border-2 border-dashed border-slate-100">
|
||||
<div class="text-4xl mb-4">🎈</div>
|
||||
<p class="text-slate-400 font-black italic uppercase tracking-widest text-xs">
|
||||
Arrive bientôt dans cette catégorie...
|
||||
</p>
|
||||
<p class="text-slate-400 font-black italic uppercase tracking-widest text-xs">Arrive bientôt...</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- CTA FINAL --- #}
|
||||
<div class="max-w-[1600px] mx-auto px-4 mt-32">
|
||||
<div class="bg-slate-900 rounded-[3.5rem] p-12 text-center relative overflow-hidden shadow-2xl">
|
||||
<div class="relative z-10">
|
||||
<h2 class="text-3xl md:text-5xl font-black text-white uppercase italic tracking-tighter">Vous ne trouvez pas <span class="text-blue-500">votre bonheur ?</span></h2>
|
||||
<p class="text-slate-400 mt-4 max-w-xl mx-auto font-medium italic text-sm">Contactez Lilian directement pour une demande sur mesure ou des conseils personnalisés.</p>
|
||||
<div class="mt-8">
|
||||
<a href="{{ path('reservation_contact') }}" class="inline-block px-10 py-5 bg-blue-600 text-white rounded-2xl font-black uppercase tracking-widest hover:bg-white hover:text-slate-900 transition-all shadow-xl">
|
||||
Nous appeler ⚡
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -right-20 -bottom-20 w-80 h-80 bg-blue-600/10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="flex flex-col items-center justify-center p-12 text-center">
|
||||
<img src="{{ asset('provider/images/favicon.png') }}"
|
||||
alt="Ludik Event"
|
||||
class="w-20 h-20 object-contain opacity-20 group-hover:opacity-100 group-hover:scale-110 transition-all duration-500 grayscale group-hover:grayscale-0">
|
||||
class="w-50 h-50 object-contain opacity-20 group-hover:opacity-100 group-hover:scale-110 transition-all duration-500 grayscale group-hover:grayscale-0">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
171
templates/revervation/produit.twig
Normal file
171
templates/revervation/produit.twig
Normal file
@@ -0,0 +1,171 @@
|
||||
{% extends 'revervation/base.twig' %}
|
||||
|
||||
{% block title %}{{ product.name }} - Location Ludikevent{% endblock %}
|
||||
{% block breadcrumb_json %}
|
||||
,{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Catalogue",
|
||||
"item": "{{ path('reservation_catalogue') }}"
|
||||
},{
|
||||
"@type": "ListItem",
|
||||
"position": 2
|
||||
"name": "Catalogue",
|
||||
"item": "{{ path('reservation_product_show',{id:product.id}) }}"
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen bg-white font-sans antialiased">
|
||||
{# --- HEADER --- #}
|
||||
<div class="max-w-7xl mx-auto pt-16 pb-8 px-4 text-center">
|
||||
<nav class="flex justify-center space-x-4 text-[10px] mb-8 uppercase tracking-[0.3em] font-black italic">
|
||||
<a href="{{ url('reservation') }}" class="text-slate-400 hover:text-blue-600 transition">ACCUEIL</a>
|
||||
<span class="text-slate-300">/</span>
|
||||
<a href="{{ url('reservation_catalogue') }}" class="text-slate-400 hover:text-blue-600 transition">Catalogue</a>
|
||||
<span class="text-slate-300">/</span>
|
||||
<span class="text-amber-500 underline decoration-2 underline-offset-4">{{ product.name }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
{# --- FIL D'ARIANE / RETOUR --- #}
|
||||
<div class="max-w-7xl mx-auto px-4 pt-8">
|
||||
<a href="{{ path('reservation_catalogue') }}" class="group inline-flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 hover:text-blue-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transform group-hover:-translate-x-2 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Retour au catalogue
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-12 md:py-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 md:gap-24 items-start">
|
||||
|
||||
{# --- COLONNE GAUCHE : IMAGE --- #}
|
||||
<div class="sticky top-24">
|
||||
<div class="relative overflow-hidden rounded-[4rem] bg-slate-50 aspect-[4/5] shadow-2xl">
|
||||
{% if product.imageName %}
|
||||
<img src="{{ vich_uploader_asset(product,'imageFile') | imagine_filter('webp') }}"
|
||||
alt="{{ product.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
{# FALLBACK : Image par défaut si vide #}
|
||||
<div class="h-full flex flex-col items-center justify-center p-12 text-center">
|
||||
<img src="{{ asset('provider/images/favicon.png') }}"
|
||||
alt="Ludik Event"
|
||||
class="w-75 h-75 object-contain opacity-50 group-hover:opacity-100 group-hover:scale-110 transition-all duration-500 grayscale group-hover:grayscale-0">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Badge catégorie flottant #}
|
||||
<div class="absolute top-8 left-8">
|
||||
<span class="bg-blue-600 text-white px-6 py-2 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- COLONNE DROITE : INFOS --- #}
|
||||
<div class="flex flex-col h-full py-4">
|
||||
<div class="mb-10">
|
||||
<span class="text-[12px] font-black text-slate-300 uppercase tracking-[0.4em] mb-4 block italic">
|
||||
Référence : {{ product.ref }}
|
||||
</span>
|
||||
<h1 class="text-6xl md:text-8xl font-black text-slate-900 uppercase italic tracking-tighter leading-[0.85] mb-8">
|
||||
{{ product.name }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span class="text-5xl font-black text-blue-600 italic">{{ product.priceDay }}€</span>
|
||||
<span class="text-sm font-bold text-slate-400 uppercase tracking-widest italic">TTC / Journée</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- DESCRIPTION / CARACTERISTIQUES --- #}
|
||||
<div class="border-t border-slate-100 pt-10 mb-12">
|
||||
<p class="text-xl text-slate-600 leading-relaxed italic font-medium">
|
||||
Louez ce produit pour vos événements. Qualité professionnelle, sécurité certifiée et plaisir garanti.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mt-10">
|
||||
<div class="bg-slate-50 p-6 rounded-[2rem] border border-slate-100">
|
||||
<span class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Âge conseillé</span>
|
||||
<span class="text-lg font-black text-slate-900 uppercase italic">{{ product.category }}</span>
|
||||
</div>
|
||||
<div class="bg-slate-50 p-6 rounded-[2rem] border border-slate-100">
|
||||
<span class="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Disponibilité</span>
|
||||
<span class="text-lg font-black text-emerald-500 uppercase italic">En stock ⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- ACTION --- #}
|
||||
<div class="mt-auto">
|
||||
<a href="{{ path('reservation_contact', {id: product.id}) }}"
|
||||
class="flex items-center justify-center w-full py-8 bg-slate-900 text-white rounded-[2.5rem] font-black uppercase text-[12px] tracking-[0.3em] hover:bg-blue-600 transition-all shadow-2xl hover:scale-[1.02] active:scale-95">
|
||||
Réserver maintenant
|
||||
</a>
|
||||
<p class="text-center mt-6 text-[9px] font-black text-slate-300 uppercase tracking-widest italic">
|
||||
Réponse rapide sous 24h • Ludikevent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{# --- SECTION SUGGESTIONS DISCRETE --- #}
|
||||
{# --- SECTION SUGGESTIONS --- #}
|
||||
<section class="max-w-7xl mx-auto px-4 py-20 border-t border-slate-100">
|
||||
<div class="flex items-end justify-between mb-12">
|
||||
<div>
|
||||
<span class="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em] mb-2 block italic">Vous pourriez aussi aimer</span>
|
||||
<h2 class="text-4xl md:text-5xl font-black text-slate-900 uppercase italic tracking-tighter leading-none">D'autres <span class="text-blue-600">idées ?</span></h2>
|
||||
</div>
|
||||
<a href="{{ path('reservation_catalogue') }}" class="hidden md:block text-[10px] font-black uppercase tracking-widest border-b-2 border-blue-600 pb-1 hover:text-blue-600 transition-colors">
|
||||
Voir tout le catalogue
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Grille de suggestions (on réutilise le style épuré) #}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{% for other in otherProducts %}
|
||||
<div class="group transition-all duration-500">
|
||||
<a href="{{ path('reservation_product_show', {id: other.id}) }}" class="block">
|
||||
|
||||
{# Image miniature #}
|
||||
<div class="relative overflow-hidden rounded-[2.5rem] bg-slate-50 aspect-square mb-4 shadow-sm group-hover:shadow-xl transition-all duration-700">
|
||||
{% if other.imageName %}
|
||||
<img src="{{ vich_uploader_asset(other,'imageFile') | imagine_filter('webp') }}"
|
||||
alt="{{ other.name }}"
|
||||
class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-1000">
|
||||
{% else %}
|
||||
<img src="{{ asset('provider/images/favicon.png') | imagine_filter('webp') }}"
|
||||
alt="{{ product.name }}"
|
||||
class="w-full h-full object-cover opacity-50 transform group-hover:scale-110 transition-transform duration-1000">
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-3 py-1 rounded-xl shadow-sm">
|
||||
<p class="text-slate-900 font-black text-[11px] italic leading-none">{{ other.priceDay }}€</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Infos miniatures #}
|
||||
<div class="px-2">
|
||||
<span class="text-[8px] font-black text-slate-300 uppercase tracking-widest mb-1 block italic">{{ other.category }}</span>
|
||||
<h3 class="text-sm font-black text-slate-900 uppercase italic tracking-tighter leading-tight group-hover:text-blue-600 transition-colors line-clamp-1">
|
||||
{{ other.name }}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Cas où il n'y a pas d'autres produits à suggérer #}
|
||||
<p class="col-span-full text-slate-400 italic text-sm">Plus de surprises arrivent bientôt...</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user