From 198d684fb8001fefcff11565415faddc8df32eb8 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 20 Mar 2026 10:44:31 +0100 Subject: [PATCH] Add organizer pages, SEO breadcrumbs, Open Graph, homepage redesign, and infrastructure updates - Add public organizers list page (/organisateurs) with neo-brutalist card grid, social icons, and logo display - Add organizer detail page (/organisateur/{id}-{slug}) with company info, SIRET, email, address, social links, and events placeholder - Add slug-based URLs with 301 redirect on wrong slug, getSlug() method on User entity - Add "Voir les evenements" button on organizer cards linking to detail page - Add JSON-LD BreadcrumbList to all 17 pages that were missing breadcrumbs (login, forgot_password, register_success, email_verified, legal/*, attestation/*, account/*) - Add Open Graph meta tags (og:title, og:description, og:image, og:type, og:locale, og:site_name) in base.html.twig with automatic inheritance from title/description blocks - Add og:image with organizer logo on detail page - Update sitemap: add /organisateurs to sitemap-main, generate organizer detail URLs in sitemap-orgas with logo images - Update navbar to highlight "Organisateurs" on detail pages - Redesign homepage with hero section, marquee, stats counters, how-it-works, and CTA sections - Add Tailwind v4 @source "../templates" directive to app.scss and admin.scss - Migrate Flysystem from S3 to local storage (uploads/events, uploads/logos) - Update Liip Imagine config with FormatExtensionResolver for webp conversion - Add User entity social fields (website, facebook, instagram, twitter, tiktok), logo upload (Vich), __serialize/__unserialize for session safety - Add account page settings tab with profile, logo upload, and social media for organizers - Add Stripe Connect status display and sub-account management in account page - Delete WebpExtensionSubscriber (replaced by FormatExtensionResolver) - Add migration for social fields and logo columns - Add deploy.yml chmod tasks for uploads directories - Add HomeController tests (detail success, wrong slug redirect, 404 cases) Co-Authored-By: Claude Opus 4.6 (1M context) --- ansible/deploy.yml | 19 ++ assets/admin.scss | 1 + assets/app.scss | 1 + config/packages/flysystem.yaml | 6 +- config/packages/liip_imagine.yaml | 10 +- config/packages/vich_uploader.yaml | 2 +- config/reference.php | 39 +++ config/services.yaml | 8 + migrations/Version20260320085455.php | 39 +++ src/Controller/AccountController.php | 22 ++ src/Controller/AttestationController.php | 10 +- src/Controller/ForgotPasswordController.php | 7 + src/Controller/HomeController.php | 53 +++- src/Controller/LegalController.php | 49 +++- src/Controller/RegistrationController.php | 9 + src/Controller/SecurityController.php | 4 + src/Controller/SitemapController.php | 51 +++- src/Entity/User.php | 110 ++++++++ .../WebpExtensionSubscriber.php | 27 -- templates/account/index.html.twig | 54 +++- templates/base.html.twig | 18 +- templates/home/index.html.twig | 249 +++++++++++------- templates/home/organizer_detail.html.twig | 137 ++++++++++ templates/home/organizers.html.twig | 120 +++++++++ tests/Controller/HomeControllerTest.php | 115 ++++++++ .../WebpExtensionSubscriberTest.php | 81 ------ 26 files changed, 1018 insertions(+), 223 deletions(-) create mode 100644 migrations/Version20260320085455.php delete mode 100644 src/EventSubscriber/WebpExtensionSubscriber.php create mode 100644 templates/home/organizer_detail.html.twig create mode 100644 templates/home/organizers.html.twig delete mode 100644 tests/EventSubscriber/WebpExtensionSubscriberTest.php diff --git a/ansible/deploy.yml b/ansible/deploy.yml index 87f9add..26fb6da 100644 --- a/ansible/deploy.yml +++ b/ansible/deploy.yml @@ -220,6 +220,25 @@ args: chdir: /var/www/e-ticket + - name: Ensure uploads directories exist with correct permissions + file: + path: "/var/www/e-ticket/public/uploads/{{ item }}" + state: directory + owner: "1000" + group: "1000" + mode: "0755" + recurse: true + loop: + - logos + + - name: Ensure var/payouts directory exists + file: + path: /var/www/e-ticket/var/payouts + state: directory + owner: "1000" + group: "1000" + mode: "0755" + - name: Ensure Caddy sites directory exists file: path: /etc/caddy/sites diff --git a/assets/admin.scss b/assets/admin.scss index f1d8c73..29da7dd 100644 --- a/assets/admin.scss +++ b/assets/admin.scss @@ -1 +1,2 @@ @import "tailwindcss"; +@source "../templates"; diff --git a/assets/app.scss b/assets/app.scss index e7d9b4c..a35158f 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -1,2 +1,3 @@ @import "tailwindcss"; +@source "../templates"; @import 'https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap'; diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index a689a47..7cfa2b7 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -1,11 +1,9 @@ flysystem: storages: default.storage: - adapter: 'aws' + adapter: 'local' options: - client: 's3_client' - bucket: '%env(S3_BUCKET)%' - prefix: 'uploads' + directory: '%kernel.project_dir%/public/uploads/events' logos.storage: adapter: 'local' diff --git a/config/packages/liip_imagine.yaml b/config/packages/liip_imagine.yaml index fe2527b..c7575e5 100644 --- a/config/packages/liip_imagine.yaml +++ b/config/packages/liip_imagine.yaml @@ -2,6 +2,11 @@ liip_imagine: driver: imagick twig: mode: lazy + cache: format_web_path + + resolvers: + web_path: + web_path: ~ webp: generate: true @@ -13,7 +18,6 @@ liip_imagine: format: webp filters: thumbnail: { size: [200, 72], mode: inset } - format: { format: 'webp' } thumbnail: quality: 80 @@ -21,25 +25,21 @@ liip_imagine: filters: thumbnail: { size: [300, 300], mode: inset } background: { size: [300, 300], position: center, color: '#ffffff' } - format: { format: 'webp' } medium: quality: 85 format: webp filters: thumbnail: { size: [600, 600], mode: inset } - format: { format: 'webp' } large: quality: 90 format: webp filters: thumbnail: { size: [1200, 1200], mode: inset } - format: { format: 'webp' } organizer_logo: quality: 85 format: webp filters: thumbnail: { size: [400, 400], mode: inset } - format: { format: 'webp' } diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 84111fd..a5eda8d 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -6,7 +6,7 @@ vich_uploader: mappings: event_image: - uri_prefix: '%env(S3_ENDPOINT)%/%env(S3_BUCKET)%/uploads/events' + uri_prefix: /uploads/events upload_destination: default.storage namer: Vich\UploaderBundle\Naming\SmartUniqueNamer directory_namer: diff --git a/config/reference.php b/config/reference.php index e3358cc..5c7234e 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1782,6 +1782,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * } + * @psalm-type KnpuOauth2ClientConfig = array{ + * http_client?: scalar|Param|null, // Service id of HTTP client to use (must implement GuzzleHttp\ClientInterface) // Default: null + * http_client_options?: array{ + * timeout?: int|Param, + * proxy?: scalar|Param|null, + * verify?: bool|Param, // Use only with proxy option set + * }, + * clients?: array>, + * } + * @psalm-type KnpPaginatorConfig = array{ + * default_options?: array{ + * sort_field_name?: scalar|Param|null, // Default: "sort" + * sort_direction_name?: scalar|Param|null, // Default: "direction" + * filter_field_name?: scalar|Param|null, // Default: "filterField" + * filter_value_name?: scalar|Param|null, // Default: "filterValue" + * page_name?: scalar|Param|null, // Default: "page" + * distinct?: bool|Param, // Default: true + * page_out_of_range?: scalar|Param|null, // Default: "ignore" + * default_limit?: scalar|Param|null, // Default: 10 + * }, + * template?: array{ + * pagination?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sliding.html.twig" + * rel_links?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/rel_links.html.twig" + * filtration?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/filtration.html.twig" + * sortable?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sortable_link.html.twig" + * }, + * page_range?: scalar|Param|null, // Default: 5 + * page_limit?: scalar|Param|null, // Default: null + * convert_exception?: bool|Param, // Default: false + * remove_first_page_param?: bool|Param, // Default: false + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1798,6 +1829,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vich_uploader?: VichUploaderConfig, * flysystem?: FlysystemConfig, * nelmio_security?: NelmioSecurityConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * knp_paginator?: KnpPaginatorConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1817,6 +1850,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vich_uploader?: VichUploaderConfig, * flysystem?: FlysystemConfig, * nelmio_security?: NelmioSecurityConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1834,6 +1869,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vich_uploader?: VichUploaderConfig, * flysystem?: FlysystemConfig, * nelmio_security?: NelmioSecurityConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1852,6 +1889,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * vich_uploader?: VichUploaderConfig, * flysystem?: FlysystemConfig, * nelmio_security?: NelmioSecurityConfig, + * knpu_oauth2_client?: KnpuOauth2ClientConfig, + * knp_paginator?: KnpPaginatorConfig, * }, * ...addSql('ALTER TABLE "user" ADD website VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD facebook VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD instagram VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD twitter VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD tiktok VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP website'); + $this->addSql('ALTER TABLE "user" DROP facebook'); + $this->addSql('ALTER TABLE "user" DROP instagram'); + $this->addSql('ALTER TABLE "user" DROP twitter'); + $this->addSql('ALTER TABLE "user" DROP tiktok'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index bc24ed6..d0cbb40 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -56,6 +56,10 @@ class AccountController extends AbstractController 'isOrganizer' => $isOrganizer, 'payouts' => $payouts, 'subAccounts' => $subAccounts, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Mon compte', 'url' => '/mon-compte'], + ], ]); } @@ -80,6 +84,19 @@ class AccountController extends AbstractController $user->setCity(trim($request->request->getString('city'))); } + if ($isOrganizer) { + $user->setWebsite(trim($request->request->getString('website'))); + $user->setFacebook(trim($request->request->getString('facebook'))); + $user->setInstagram(trim($request->request->getString('instagram'))); + $user->setTwitter(trim($request->request->getString('twitter'))); + $user->setTiktok(trim($request->request->getString('tiktok'))); + + $logoFile = $request->files->get('logo'); + if ($logoFile) { + $user->setLogoFile($logoFile); + } + } + $em->flush(); $this->addFlash('success', 'Parametres mis a jour.'); @@ -212,6 +229,11 @@ class AccountController extends AbstractController return $this->render('account/edit_subaccount.html.twig', [ 'subAccount' => $subAccount, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Mon compte', 'url' => '/mon-compte'], + ['name' => 'Sous-compte', 'url' => '/mon-compte/sous-compte/' . $subAccount->getId()], + ], ]); } diff --git a/src/Controller/AttestationController.php b/src/Controller/AttestationController.php index c50307c..87a66e2 100644 --- a/src/Controller/AttestationController.php +++ b/src/Controller/AttestationController.php @@ -15,12 +15,20 @@ class AttestationController extends AbstractController { $payout = $em->getRepository(Payout::class)->findOneBy(['stripePayoutId' => $stripePayoutId]); + $breadcrumbs = [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Verification attestation', 'url' => '/attestation/check/' . $stripePayoutId], + ]; + if (!$payout) { - return $this->render('attestation/not_found.html.twig'); + return $this->render('attestation/not_found.html.twig', [ + 'breadcrumbs' => $breadcrumbs, + ]); } return $this->render('attestation/check.html.twig', [ 'payout' => $payout, + 'breadcrumbs' => $breadcrumbs, ]); } } diff --git a/src/Controller/ForgotPasswordController.php b/src/Controller/ForgotPasswordController.php index d62688c..2ae86f6 100644 --- a/src/Controller/ForgotPasswordController.php +++ b/src/Controller/ForgotPasswordController.php @@ -15,6 +15,10 @@ class ForgotPasswordController extends AbstractController { private const TEMPLATE = 'security/forgot_password.html.twig'; private const CODE_EXPIRATION_MINUTES = 15; + private const BREADCRUMBS = [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Mot de passe oublie', 'url' => '/mot-de-passe-oublie'], + ]; #[Route('/mot-de-passe-oublie', name: 'app_forgot_password', methods: ['GET', 'POST'])] public function index( @@ -39,6 +43,7 @@ class ForgotPasswordController extends AbstractController return $this->render(self::TEMPLATE, [ 'step' => 'email', 'email' => '', + 'breadcrumbs' => self::BREADCRUMBS, ]); } @@ -52,6 +57,7 @@ class ForgotPasswordController extends AbstractController return $this->render(self::TEMPLATE, [ 'step' => 'email', 'email' => $email, + 'breadcrumbs' => self::BREADCRUMBS, ]); } @@ -83,6 +89,7 @@ class ForgotPasswordController extends AbstractController return $this->render(self::TEMPLATE, [ 'step' => 'code', 'email' => $email, + 'breadcrumbs' => self::BREADCRUMBS, ]); } diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php index 62dd6a2..f771b3e 100644 --- a/src/Controller/HomeController.php +++ b/src/Controller/HomeController.php @@ -2,6 +2,8 @@ namespace App\Controller; +use App\Entity\User; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -9,12 +11,61 @@ use Symfony\Component\Routing\Attribute\Route; class HomeController extends AbstractController { #[Route('/', name: 'app_home')] - public function index(): Response + public function index(EntityManagerInterface $em): Response { + $allUsers = $em->getRepository(User::class)->findAll(); + $organizers = \count(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved())); + return $this->render('home/index.html.twig', [ 'breadcrumbs' => [ ['name' => 'Accueil', 'url' => '/'], ], + 'stats' => [ + 'events' => 0, + 'organizers' => $organizers, + 'tickets' => 0, + ], + ]); + } + + #[Route('/organisateurs', name: 'app_organizers')] + public function organizers(EntityManagerInterface $em): Response + { + $allUsers = $em->getRepository(User::class)->findAll(); + $organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()); + + return $this->render('home/organizers.html.twig', [ + 'organizers' => $organizers, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Organisateurs', 'url' => '/organisateurs'], + ], + ]); + } + + #[Route('/organisateur/{id}-{slug}', name: 'app_organizer_detail', requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]+'])] + public function organizerDetail(int $id, string $slug, EntityManagerInterface $em): Response + { + $organizer = $em->getRepository(User::class)->find($id); + + if (!$organizer || !\in_array('ROLE_ORGANIZER', $organizer->getRoles(), true) || !$organizer->isApproved()) { + throw $this->createNotFoundException('Organisateur introuvable.'); + } + + if ($slug !== $organizer->getSlug()) { + return $this->redirectToRoute('app_organizer_detail', [ + 'id' => $organizer->getId(), + 'slug' => $organizer->getSlug(), + ], 301); + } + + return $this->render('home/organizer_detail.html.twig', [ + 'organizer' => $organizer, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Organisateurs', 'url' => '/organisateurs'], + ['name' => $organizer->getCompanyName() ?? $organizer->getFirstName() . ' ' . $organizer->getLastName(), 'url' => '/organisateur/' . $organizer->getId() . '-' . $organizer->getSlug()], + ], ]); } diff --git a/src/Controller/LegalController.php b/src/Controller/LegalController.php index d1b56af..5093ebd 100644 --- a/src/Controller/LegalController.php +++ b/src/Controller/LegalController.php @@ -11,42 +11,77 @@ class LegalController extends AbstractController #[Route('/mentions-legales', name: 'app_mentions_legales')] public function mentionsLegales(): Response { - return $this->render('legal/mentions_legales.html.twig'); + return $this->render('legal/mentions_legales.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Mentions legales', 'url' => '/mentions-legales'], + ], + ]); } #[Route('/cgu', name: 'app_cgu')] public function cgu(): Response { - return $this->render('legal/cgu.html.twig'); + return $this->render('legal/cgu.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'CGU', 'url' => '/cgu'], + ], + ]); } #[Route('/cgv', name: 'app_cgv')] public function cgv(): Response { - return $this->render('legal/cgv.html.twig'); + return $this->render('legal/cgv.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'CGV', 'url' => '/cgv'], + ], + ]); } #[Route('/hebergement', name: 'app_hosting')] public function hosting(): Response { - return $this->render('legal/hosting.html.twig'); + return $this->render('legal/hosting.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Hebergement', 'url' => '/hebergement'], + ], + ]); } #[Route('/cookies', name: 'app_cookies')] public function cookies(): Response { - return $this->render('legal/cookies.html.twig'); + return $this->render('legal/cookies.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Politique de cookies', 'url' => '/cookies'], + ], + ]); } #[Route('/rgpd', name: 'app_rgpd')] public function rgpd(): Response { - return $this->render('legal/rgpd.html.twig'); + return $this->render('legal/rgpd.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Politique RGPD', 'url' => '/rgpd'], + ], + ]); } #[Route('/conformite', name: 'app_conformite')] public function conformite(): Response { - return $this->render('legal/conformite.html.twig'); + return $this->render('legal/conformite.html.twig', [ + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Conformite', 'url' => '/conformite'], + ], + ]); } } diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php index f1074fc..1d66e3c 100644 --- a/src/Controller/RegistrationController.php +++ b/src/Controller/RegistrationController.php @@ -74,6 +74,11 @@ class RegistrationController extends AbstractController return $this->render('security/register_success.html.twig', [ 'isOrganizer' => 'organizer' === $type, + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Inscription', 'url' => '/inscription'], + ['name' => 'Compte cree', 'url' => '/inscription'], + ], ]); } @@ -147,6 +152,10 @@ class RegistrationController extends AbstractController return $this->render('security/email_verified.html.twig', [ 'isOrganizer' => \in_array('ROLE_ORGANIZER', $user->getRoles(), true), + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Email verifie', 'url' => '/verification-email/' . $token], + ], ]); } } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index fdb5ab2..6373de2 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -19,6 +19,10 @@ class SecurityController extends AbstractController return $this->render('security/login.html.twig', [ 'last_username' => $authenticationUtils->getLastUsername(), 'error' => $authenticationUtils->getLastAuthenticationError(), + 'breadcrumbs' => [ + ['name' => 'Accueil', 'url' => '/'], + ['name' => 'Connexion', 'url' => '/connexion'], + ], ]); } diff --git a/src/Controller/SitemapController.php b/src/Controller/SitemapController.php index c058b83..748e572 100644 --- a/src/Controller/SitemapController.php +++ b/src/Controller/SitemapController.php @@ -2,7 +2,10 @@ namespace App\Controller; +use App\Entity\User; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -13,6 +16,11 @@ class SitemapController extends AbstractController private const CONTENT_TYPE_XML = 'text/xml'; private const URLSET_TEMPLATE = 'sitemap/urlset.xml.twig'; + public function __construct( + private readonly EntityManagerInterface $em, + ) { + } + #[Route('/sitemap.xml', name: 'app_sitemap', methods: ['GET'])] public function index(): Response { @@ -44,8 +52,9 @@ class SitemapController extends AbstractController } #[Route('/sitemap-main.xml', name: 'app_sitemap_main', methods: ['GET'])] - public function main(): Response + public function main(Request $request): Response { + $baseUrl = $request->getSchemeAndHttpHost(); $urls = [ [ 'loc' => $this->generateUrl('app_home', [], UrlGeneratorInterface::ABSOLUTE_URL), @@ -62,6 +71,15 @@ class SitemapController extends AbstractController 'changefreq' => 'monthly', 'priority' => '0.7', ], + [ + 'loc' => $this->generateUrl('app_organizers', [], UrlGeneratorInterface::ABSOLUTE_URL), + 'changefreq' => 'weekly', + 'priority' => '0.8', + 'images' => [[ + 'loc' => $baseUrl . '/logo.png', + 'title' => 'E-Ticket - Nos organisateurs', + ]], + ], ]; return new Response( @@ -78,9 +96,11 @@ class SitemapController extends AbstractController } #[Route('/sitemap-orgas-{page}.xml', name: 'app_sitemap_orgas', requirements: ['page' => '\d+'], methods: ['GET'])] - public function orgas(int $page = 1): Response + public function orgas(Request $request, int $page = 1): Response { - return $this->renderUrlset($this->getOrgaUrls()); + $baseUrl = $request->getSchemeAndHttpHost(); + + return $this->renderUrlset($this->getOrgaUrls($baseUrl)); } /** @@ -106,8 +126,29 @@ class SitemapController extends AbstractController /** * @return list> */ - private function getOrgaUrls(): array + private function getOrgaUrls(string $baseUrl): array { - return []; + $allUsers = $this->em->getRepository(User::class)->findAll(); + $organizers = array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()); + + $urls = []; + foreach ($organizers as $organizer) { + $url = [ + 'loc' => $this->generateUrl('app_organizer_detail', ['id' => $organizer->getId(), 'slug' => $organizer->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL), + 'changefreq' => 'weekly', + 'priority' => '0.6', + ]; + + if ($organizer->getLogoName()) { + $url['images'] = [[ + 'loc' => $baseUrl . '/uploads/logos/' . $organizer->getLogoName(), + 'title' => $organizer->getCompanyName() ?? $organizer->getFirstName() . ' ' . $organizer->getLastName(), + ]]; + } + + $urls[] = $url; + } + + return $urls; } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 4515bf1..fc8b9f7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -58,6 +58,21 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(length: 20, nullable: true)] private ?string $phone = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $website = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $facebook = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $instagram = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $twitter = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $tiktok = null; + #[Vich\UploadableField(mapping: 'organizer_logo', fileNameProperty: 'logoName')] private ?File $logoFile = null; @@ -271,6 +286,66 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getWebsite(): ?string + { + return $this->website; + } + + public function setWebsite(?string $website): static + { + $this->website = $website; + + return $this; + } + + public function getFacebook(): ?string + { + return $this->facebook; + } + + public function setFacebook(?string $facebook): static + { + $this->facebook = $facebook; + + return $this; + } + + public function getInstagram(): ?string + { + return $this->instagram; + } + + public function setInstagram(?string $instagram): static + { + $this->instagram = $instagram; + + return $this; + } + + public function getTwitter(): ?string + { + return $this->twitter; + } + + public function setTwitter(?string $twitter): static + { + $this->twitter = $twitter; + + return $this; + } + + public function getTiktok(): ?string + { + return $this->tiktok; + } + + public function setTiktok(?string $tiktok): static + { + $this->tiktok = $tiktok; + + return $this; + } + public function getLogoFile(): ?File { return $this->logoFile; @@ -499,4 +574,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { // Required by UserInterface — no temporary credentials to clear } + + public function getSlug(): string + { + $name = $this->companyName ?? $this->firstName . ' ' . $this->lastName; + $slug = mb_strtolower(trim($name)); + $slug = transliterator_transliterate('Any-Latin; Latin-ASCII', $slug) ?: $slug; + $slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug); + $slug = trim($slug, '-'); + + return '' === $slug ? 'organisateur' : $slug; + } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'id' => $this->id, + 'email' => $this->email, + 'password' => $this->password, + 'roles' => $this->roles, + ]; + } + + /** + * @param array $data + */ + public function __unserialize(array $data): void + { + $this->id = $data['id'] ?? null; + $this->email = $data['email'] ?? null; + $this->password = $data['password'] ?? null; + $this->roles = $data['roles'] ?? []; + } } diff --git a/src/EventSubscriber/WebpExtensionSubscriber.php b/src/EventSubscriber/WebpExtensionSubscriber.php deleted file mode 100644 index 5545859..0000000 --- a/src/EventSubscriber/WebpExtensionSubscriber.php +++ /dev/null @@ -1,27 +0,0 @@ - 'onPostResolve', - ]; - } - - public function onPostResolve(CacheResolveEvent $event): void - { - $url = $event->getUrl(); - - if (null === $url) { - return; - } - - $event->setUrl(preg_replace('/\.(png|jpg|jpeg|gif|bmp|tiff)$/i', '.webp', $url)); - } -} diff --git a/templates/account/index.html.twig b/templates/account/index.html.twig index 0e79dbc..5562949 100644 --- a/templates/account/index.html.twig +++ b/templates/account/index.html.twig @@ -279,7 +279,7 @@ {% elseif tab == 'settings' %}

Parametres du compte

-
+
@@ -329,6 +329,58 @@ {% endif %} {% if isOrganizer %} +
+

Logo de l'organisation

+ {% if app.user.logoName %} +
+ Logo +
+ {% endif %} + +

PNG, JPEG ou WebP. Max 2 Mo.

+
+ +
+

Reseaux sociaux & site internet

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+

Les informations de votre organisation (raison sociale, SIRET, adresse) ne peuvent etre modifiees que par l'equipe E-Ticket. Contactez contact@e-cosplay.fr pour toute modification.

diff --git a/templates/base.html.twig b/templates/base.html.twig index af6c578..5a884a4 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -55,14 +55,24 @@ { "@type": "ListItem", "position": {{ loop.index }}, - "name": "{{ breadcrumb.name }}", - "item": "{{ breadcrumb.url }}" + "name": "{{ breadcrumb.name }}"{% if breadcrumb.url is defined and breadcrumb.url is not empty %}, + "item": "{{ breadcrumb.url }}"{% endif %} }{% if not loop.last %},{% endif %} {% endfor %} ] } {% endif %} + {% block og %} + + + + + + {% block og_image %} + + {% endblock %} + {% endblock %} {% block stylesheets %}{% endblock %} {% block javascripts %} {{ vite_asset('app.js') }} @@ -85,7 +95,9 @@ {% set current_route = app.request.attributes.get('_route') %} + +
@@ -113,7 +125,9 @@
Accueil Evenements + Organisateurs Contact + E-Cosplay {% if app.user %} Mon espace {% else %} diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig index 13f1013..a0731f2 100644 --- a/templates/home/index.html.twig +++ b/templates/home/index.html.twig @@ -4,107 +4,180 @@ {% block description %}E-Ticket, plateforme de billetterie en ligne pour associations : vente de tickets, reservation de tables, brocantes et vote en ligne.{% endblock %} {% block body %} +
-
-
-

- La billetterie pensee pour les associations -

-

- Vendez vos billets, gerez vos evenements et simplifiez votre organisation. Simple, securise, transparent. -

- -
-
-
-

Comment ca marche ?

-
+
-
-

Pour les acheteurs

-
-
-

1

-

Choisissez

-

Parcourez les evenements et selectionnez vos billets.

-
-
-

2

-

Payez en ligne

-

Paiement securise par carte bancaire via Stripe.

-
-
-

3

-

Recevez votre billet

-

Billet electronique avec QR Code envoye par email signe.

+

+ La billetterie pensee pour les associations +

+ +

+ Vendez vos billets, gerez vos evenements et simplifiez votre organisation. Simple, securise, transparent. +

+ +
+
+ +
+
+ Billets // Brocantes // Reservations // Associations // Evenements // QR Code // Paiement securise + Billets // Brocantes // Reservations // Associations // Evenements // QR Code // Paiement securise + Billets // Brocantes // Reservations // Associations // Evenements // QR Code // Paiement securise + Billets // Brocantes // Reservations // Associations // Evenements // QR Code // Paiement securise + Billets // Brocantes // Reservations // Associations // Evenements // QR Code // Paiement securise +
-
-

Pour les organisateurs

-
-
-

1

-

Creez votre evenement

-

Configurez vos billets, tarifs et nombre de places en quelques minutes.

-
-
-

2

-

Vendez en ligne

-

Partagez le lien de votre evenement. Les paiements sont automatiques.

-
-
-

3

-

Scannez a l'entree

-

Validez les billets QR Code depuis n'importe quel smartphone.

+
+
+
+
+

{{ stats.events }}

+

Evenements geres

+
+
+

{{ stats.organizers }}

+

Organisateurs

+
+
+

{{ stats.tickets }}

+

Billets vendus

+

Billets, reservations, brocantes, votes

+
-
- + -
-
-
-

Pourquoi E-Ticket ?

-
-
-
-

🔒

-

Securise

-

Paiement Stripe, HTTPS, emails signes S/MIME, protection Cloudflare.

-
-
-

💰

-

Transparent

-

Commission claire a 3%, negociable. Aucun frais cache.

-
-
-

-

Simple

-

Creez un evenement en 5 minutes. Aucune competence technique requise.

-
-
-

🎫

-

Fait pour les assos

-

Billets, brocantes, tables, votes. Tout ce dont votre association a besoin.

+
+
+
+

// Comment ca marche

+

+ Pour les acheteurs +

+
+

Parcourez les evenements, selectionnez vos billets, payez en ligne en toute securite via Stripe et recevez votre billet electronique avec QR Code par email signe.

+

C'est simple, rapide et securise. Votre billet est disponible instantanement apres le paiement.

+
-
-
+ -
-

Pret a lancer votre evenement ?

-

Rejoignez E-Ticket et commencez a vendre vos billets des aujourd'hui.

- -
+
+
+

+ Pour les organisateurs +

+
+
+
+ 1 +
+

Creez votre evenement

+

Configurez vos billets, tarifs et nombre de places en quelques minutes. Evenements, brocantes, reservations de tables.

+
+ +
+
+ 2 +
+

Vendez en ligne

+

Partagez le lien de votre evenement. Les paiements sont automatiques via Stripe Connect. Vous recevez vos virements directement.

+
+ +
+
+ 3 +
+

Scannez a l'entree

+

Validez les billets QR Code depuis n'importe quel smartphone. Gerez les sous-comptes pour votre equipe.

+
+
+
+
+ +
+
+
+
+

// Pourquoi E-Ticket

+

+ Fait pour les associations +

+
+

Une plateforme pensee par une association, pour les associations.

+
+ +
+
+
🔒
+

Securise

+

Paiement Stripe, HTTPS, emails signes S/MIME, protection Cloudflare.

+
+
+
💰
+

Transparent

+

Commission claire a 3%, negociable. Aucun frais cache.

+
+
+
+

Simple

+

Creez un evenement en 5 minutes. Aucune competence technique requise.

+
+
+
🎫
+

Complet

+

Billets, brocantes, tables, votes, sous-comptes, virements automatiques.

+
+
+
+
+ +
+
+ +
+
+

+ Pret a lancer votre evenement ? +

+

+ Rejoignez E-Ticket et commencez a vendre vos billets des aujourd'hui. +

+
+ + + Creer un compte + +
+
+ +
+ + {% endblock %} diff --git a/templates/home/organizer_detail.html.twig b/templates/home/organizer_detail.html.twig new file mode 100644 index 0000000..285c95f --- /dev/null +++ b/templates/home/organizer_detail.html.twig @@ -0,0 +1,137 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ organizer.companyName ?? organizer.firstName ~ ' ' ~ organizer.lastName }} - E-Ticket{% endblock %} +{% block description %}Decouvrez les evenements de {{ organizer.companyName ?? organizer.firstName ~ ' ' ~ organizer.lastName }} sur E-Ticket{% endblock %} +{% block og_image %} + {% if organizer.logoName %} + + {% else %} + + {% endif %} +{% endblock %} + +{% block body %} +
+ +
+
+ ORGA +
+ +
+ + + Retour aux organisateurs + + +
+ {% if organizer.logoName %} +
+ {{ organizer.companyName }} +
+ {% else %} +
+ {{ organizer.firstName|first|upper }}{{ organizer.lastName|first|upper }} +
+ {% endif %} + +
+

+ {{ organizer.companyName ?? organizer.firstName ~ ' ' ~ organizer.lastName }} +

+ {% if organizer.city %} +

+ {{ organizer.postalCode }} {{ organizer.city }} +

+ {% endif %} + + {% if organizer.website or organizer.facebook or organizer.instagram or organizer.twitter or organizer.tiktok %} +
+ {% if organizer.website %} + + + + {% endif %} + {% if organizer.facebook %} + + + + {% endif %} + {% if organizer.instagram %} + + + + {% endif %} + {% if organizer.twitter %} + + + + {% endif %} + {% if organizer.tiktok %} + + + + {% endif %} +
+ {% endif %} +
+
+
+
+ +
+
+
+
+

Informations

+
+
+ + + + + + + {% if organizer.siret %} + + + + + {% endif %} + + + + + {% if organizer.city %} + + + + + {% endif %} + +
Raison sociale{{ organizer.companyName ?? organizer.firstName ~ ' ' ~ organizer.lastName }}
SIRET{{ organizer.siret }}
Email + {{ organizer.email }} +
Adresse + {% if organizer.address %}{{ organizer.address }}, {% endif %}{{ organizer.postalCode }} {{ organizer.city }} +
+
+
+
+
+ +
+
+
+
+

Evenements

+
+
+

Aucun evenement pour le moment

+

Les evenements de cet organisateur apparaitront ici.

+
+
+
+
+ +
+{% endblock %} diff --git a/templates/home/organizers.html.twig b/templates/home/organizers.html.twig new file mode 100644 index 0000000..c0e3f4f --- /dev/null +++ b/templates/home/organizers.html.twig @@ -0,0 +1,120 @@ +{% extends 'base.html.twig' %} + +{% block title %}Organisateurs - E-Ticket{% endblock %} +{% block description %}Decouvrez les organisateurs d'evenements sur E-Ticket{% endblock %} +{% block og_title %}Nos organisateurs - E-Ticket{% endblock %} +{% block og_description %}{{ organizers|length }} organisateur{{ organizers|length > 1 ? 's' : '' }} font confiance a E-Ticket pour gerer leurs evenements.{% endblock %} + +{% block body %} +
+ +
+
+ ORGA +
+ +
+

+ Nos organisateurs +

+

+ {{ organizers|length }} organisateur{{ organizers|length > 1 ? 's' : '' }} font confiance a E-Ticket pour gerer leurs evenements. +

+
+
+ +
+
+ {% if organizers|length > 0 %} +
+ {% for orga in organizers %} +
+ {% if orga.logoName %} +
+ {{ orga.companyName }} +
+ {% else %} +
+ {{ orga.firstName|first|upper }}{{ orga.lastName|first|upper }} +
+ {% endif %} + +

+ {{ orga.companyName ?? orga.firstName ~ ' ' ~ orga.lastName }} +

+ + {% if orga.city %} +

+ {{ orga.postalCode }} {{ orga.city }} +

+ {% endif %} + + {% if orga.website or orga.facebook or orga.instagram or orga.twitter or orga.tiktok %} +
+ {% if orga.website %} + + + + {% endif %} + {% if orga.facebook %} + + + + {% endif %} + {% if orga.instagram %} + + + + {% endif %} + {% if orga.twitter %} + + + + {% endif %} + {% if orga.tiktok %} + + + + {% endif %} +
+ {% endif %} + + + Voir les evenements + +
+ {% endfor %} +
+ {% else %} +
+
+

Aucun organisateur pour le moment

+

Soyez le premier a rejoindre E-Ticket !

+ + Devenir organisateur + +
+
+ {% endif %} +
+
+ +
+
+
+
+

+ Vous etes une association ? +

+

+ Rejoignez E-Ticket et gerez vos evenements simplement. +

+
+ + Creer un compte + +
+
+ +
+{% endblock %} diff --git a/tests/Controller/HomeControllerTest.php b/tests/Controller/HomeControllerTest.php index 81f958a..2163eff 100644 --- a/tests/Controller/HomeControllerTest.php +++ b/tests/Controller/HomeControllerTest.php @@ -2,6 +2,8 @@ namespace App\Tests\Controller; +use App\Entity\User; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class HomeControllerTest extends WebTestCase @@ -21,4 +23,117 @@ class HomeControllerTest extends WebTestCase self::assertResponseIsSuccessful(); } + + public function testOrganizersReturnsSuccess(): void + { + $client = static::createClient(); + $client->request('GET', '/organisateurs'); + + self::assertResponseIsSuccessful(); + } + + public function testOrganizerDetailReturnsSuccess(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $organizer = $em->getRepository(User::class)->findOneBy([]); + $found = false; + if ($organizer) { + foreach ($em->getRepository(User::class)->findAll() as $user) { + if (\in_array('ROLE_ORGANIZER', $user->getRoles(), true) && $user->isApproved()) { + $organizer = $user; + $found = true; + break; + } + } + } + + if (!$found) { + $organizer = new User(); + $organizer->setEmail('test-orga-detail@example.com'); + $organizer->setFirstName('Test'); + $organizer->setLastName('Orga'); + $organizer->setPassword('hashed'); + $organizer->setRoles(['ROLE_ORGANIZER']); + $organizer->setIsApproved(true); + $organizer->setIsVerified(true); + $organizer->setCompanyName('Asso Test'); + $organizer->setSiret('12345678901234'); + $em->persist($organizer); + $em->flush(); + } + + $client->request('GET', '/organisateur/' . $organizer->getId() . '-' . $organizer->getSlug()); + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', $organizer->getCompanyName() ?? $organizer->getFirstName()); + } + + public function testOrganizerDetailRedirectsOnWrongSlug(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $organizer = new User(); + $organizer->setEmail('test-orga-slug-' . uniqid() . '@example.com'); + $organizer->setFirstName('Slug'); + $organizer->setLastName('Test'); + $organizer->setPassword('hashed'); + $organizer->setRoles(['ROLE_ORGANIZER']); + $organizer->setIsApproved(true); + $organizer->setIsVerified(true); + $organizer->setCompanyName('Mon Asso'); + $em->persist($organizer); + $em->flush(); + + $client->request('GET', '/organisateur/' . $organizer->getId() . '-mauvais-slug'); + self::assertResponseRedirects('/organisateur/' . $organizer->getId() . '-mon-asso', 301); + } + + public function testOrganizerDetailNotFoundReturns404(): void + { + $client = static::createClient(); + $client->request('GET', '/organisateur/999999-inexistant'); + + self::assertResponseStatusCodeSame(404); + } + + public function testOrganizerDetailNonApprovedReturns404(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $organizer = new User(); + $organizer->setEmail('test-orga-noapprove-' . uniqid() . '@example.com'); + $organizer->setFirstName('Test'); + $organizer->setLastName('NonApproved'); + $organizer->setPassword('hashed'); + $organizer->setRoles(['ROLE_ORGANIZER']); + $organizer->setIsApproved(false); + $organizer->setIsVerified(true); + $em->persist($organizer); + $em->flush(); + + $client->request('GET', '/organisateur/' . $organizer->getId() . '-' . $organizer->getSlug()); + self::assertResponseStatusCodeSame(404); + } + + public function testOrganizerDetailBuyerReturns404(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + + $buyer = new User(); + $buyer->setEmail('test-buyer-detail-' . uniqid() . '@example.com'); + $buyer->setFirstName('Buyer'); + $buyer->setLastName('Test'); + $buyer->setPassword('hashed'); + $buyer->setRoles(['ROLE_USER']); + $buyer->setIsVerified(true); + $em->persist($buyer); + $em->flush(); + + $client->request('GET', '/organisateur/' . $buyer->getId() . '-' . $buyer->getSlug()); + self::assertResponseStatusCodeSame(404); + } } diff --git a/tests/EventSubscriber/WebpExtensionSubscriberTest.php b/tests/EventSubscriber/WebpExtensionSubscriberTest.php deleted file mode 100644 index 522d37d..0000000 --- a/tests/EventSubscriber/WebpExtensionSubscriberTest.php +++ /dev/null @@ -1,81 +0,0 @@ -subscriber = new WebpExtensionSubscriber(); - } - - public function testGetSubscribedEvents(): void - { - $events = WebpExtensionSubscriber::getSubscribedEvents(); - - self::assertArrayHasKey('liip_imagine.post_resolve', $events); - self::assertSame('onPostResolve', $events['liip_imagine.post_resolve']); - } - - public function testRewritesPngToWebp(): void - { - $event = new CacheResolveEvent('logo.png', 'thumbnail', '/media/cache/thumbnail/logo.png'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl()); - } - - public function testRewritesJpgToWebp(): void - { - $event = new CacheResolveEvent('photo.jpg', 'medium', '/media/cache/medium/photo.jpg'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/medium/photo.webp', $event->getUrl()); - } - - public function testRewritesJpegToWebp(): void - { - $event = new CacheResolveEvent('image.jpeg', 'large', '/media/cache/large/image.jpeg'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/large/image.webp', $event->getUrl()); - } - - public function testRewritesGifToWebp(): void - { - $event = new CacheResolveEvent('anim.gif', 'thumbnail', '/media/cache/thumbnail/anim.gif'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/thumbnail/anim.webp', $event->getUrl()); - } - - public function testDoesNotRewriteWebp(): void - { - $event = new CacheResolveEvent('already.webp', 'thumbnail', '/media/cache/thumbnail/already.webp'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/thumbnail/already.webp', $event->getUrl()); - } - - public function testCaseInsensitive(): void - { - $event = new CacheResolveEvent('logo.PNG', 'thumbnail', '/media/cache/thumbnail/logo.PNG'); - $this->subscriber->onPostResolve($event); - - self::assertSame('/media/cache/thumbnail/logo.webp', $event->getUrl()); - } - - public function testNullUrlIsIgnored(): void - { - $event = new CacheResolveEvent('logo.png', 'thumbnail'); - $this->subscriber->onPostResolve($event); - - self::assertNull($event->getUrl()); - } -}