feat(all): Ajoute l'inscription, Turnstile, Sentry et améliore l'EPAGE en français.
```
This commit is contained in:
Serreau Jovann
2025-12-24 23:59:23 +01:00
parent e41602de49
commit 9b399800eb
32 changed files with 1538 additions and 16 deletions

9
.env
View File

@@ -69,3 +69,12 @@ GOOGLE_APPLICATION_CREDENTIALS=%kernel.project_dir%/google.json
###> sentry/sentry-symfony ###
SENTRY_DSN="https://4f43769e7c483f14da26e05824a482d0@o4509563601092608.ingest.de.sentry.io/4510392636473424"
###< sentry/sentry-symfony ###
###> pixelopen/cloudflare-turnstile-bundle ###
TURNSTILE_KEY=3x00000000000000000000FF
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
###< pixelopen/cloudflare-turnstile-bundle ###
###> stripe/stripe-php ###
STRIPE_SECRET_KEY=sk_test_***
###< stripe/stripe-php ###

View File

@@ -74,7 +74,9 @@
STRIPE_SK=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
STRIPE_WEBHOOKS_SIGN=whsec_wNHtgjypqbfP7erAqifCOzZvW8kW9oB7
MAILER_DSN=ses+smtp://AKIAWTT2T22CWBRBBDYN:BBdgb6KxRQ8mNcpWFJsZCJxbSGNdgLhKFiITMErfBlQP@default?region=eu-west-3
SENTRY_DSN="https://4f43769e7c483f14da26e05824a482d0@o4509563601092608.ingest.de.sentry.io/4510392636473424"
SENTRY_DSN="https://375cf73e411fb1aa515202b7922cbaeb@sentry.esy-web.dev/6"
TURNSTILE_KEY=0x4AAAAAACI84gZ0CLCEZY5i
TURNSTILE_SECRET=0x4AAAAAACI84k8G11ODrOwCNAaWyWQ_Vzk
dest: "{{ path }}/.env.local"
when: ansible_os_family == "Debian"

View File

@@ -8,6 +8,9 @@ www.e-cosplay.fr {
request_body {
max_size 100MB
}
handle_path /sc.js {
redir https://sentry.esy-web.dev/js-sdk-loader/49feadbadd9b443832205c4eeac1c4f5.min.js
}
handle_path /ts.js {
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js
}

View File

@@ -1,7 +1,8 @@
import './app.scss'
import * as Turbo from "@hotwired/turbo"
import '@grafikart/drop-files-element'
import {PaymentForm} from './PaymentForm'
import * as Sentry from "@sentry/browser";
// --- CLÉS DE STOCKAGE ET VAPID ---
const VAPID_PUBLIC_KEY = "BKz0kdcsG6kk9KxciPpkfP8kEDAd408inZecij5kBDbQ1ZGZSNwS4KZ8FerC28LFXvgSqpDXtor3ePo0zBCdNqo";
@@ -482,6 +483,7 @@ document.addEventListener('turbo:load', () => {
initializeUI();
handleNotificationBanner();
handleCookieBanner(); // Doit être appelé à chaque chargement Turbo
});

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap');
.bg-op {
background: rgba(0,0,0,0.5);
backdrop-filter: blur(5px);

View File

@@ -40,6 +40,7 @@
"phpdocumentor/reflection-docblock": "^5.6.4",
"phpoffice/phpspreadsheet": ">=5.3",
"phpstan/phpdoc-parser": "^2.3",
"pixelopen/cloudflare-turnstile-bundle": "^0.4.1",
"presta/sitemap-bundle": "^4.2",
"sentry/sentry-symfony": "^5.6",
"setasign/fpdi": "^2.6.4",

61
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f27062872645f8d1a8553efdd75c2aef",
"content-hash": "ed26fccb39be0438a7def45a9b987dc9",
"packages": [
{
"name": "async-aws/core",
@@ -6899,6 +6899,65 @@
},
"time": "2025-08-30T15:50:23+00:00"
},
{
"name": "pixelopen/cloudflare-turnstile-bundle",
"version": "0.4.1",
"source": {
"type": "git",
"url": "https://github.com/Pixel-Open/cloudflare-turnstile-bundle.git",
"reference": "d000eb4b37c48f9421fb603742e7e23611204182"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Pixel-Open/cloudflare-turnstile-bundle/zipball/d000eb4b37c48f9421fb603742e7e23611204182",
"reference": "d000eb4b37c48f9421fb603742e7e23611204182",
"shasum": ""
},
"require": {
"php": ">=7.4",
"symfony/form": "^5.0|^6.0|^7.0",
"symfony/framework-bundle": "^5.0|^6.0|^7.0",
"symfony/http-client": "^5.0|^6.0|^7.0",
"symfony/twig-bundle": "^5.0|^6.0|^7.0",
"symfony/validator": "^5.0|^6.0|^7.0",
"symfony/yaml": "^5.0|^6.0|^7.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-symfony": "^1.2",
"phpunit/phpunit": "^9.6",
"rector/rector": "^0.14.5",
"symplify/easy-coding-standard": "^11.1"
},
"type": "library",
"autoload": {
"psr-4": {
"PixelOpen\\CloudflareTurnstileBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Pixel Open Team",
"homepage": "https://pixel-open.org/projects/symfony-bundle-cloudflare-turnstile/"
}
],
"description": "A simple package to help integrate Cloudflare Turnstile on Symfony.",
"keywords": [
"Symfony Bundle",
"captcha",
"cloudflare-turnstile",
"form security"
],
"support": {
"issues": "https://github.com/Pixel-Open/cloudflare-turnstile-bundle/issues",
"source": "https://github.com/Pixel-Open/cloudflare-turnstile-bundle/tree/0.4.1"
},
"time": "2024-09-30T15:42:13+00:00"
},
{
"name": "presta/sitemap-bundle",
"version": "v4.2.0",

View File

@@ -18,4 +18,5 @@ return [
Presta\SitemapBundle\PrestaSitemapBundle::class => ['all' => true],
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
PixelOpen\CloudflareTurnstileBundle\PixelOpenCloudflareTurnstileBundle::class => ['all' => true],
];

View File

@@ -74,6 +74,14 @@ vich_uploader:
inject_on_load: true
delete_on_update: true
delete_on_remove: true
epage_avatar:
uri_prefix: /epage_avatar/events
upload_destination: '%kernel.project_dir%/public/storage/epage_avatar'
namer: App\VichUploader\Namer\Epage\AvatarNamer # Replaced namer
directory_namer: App\VichUploader\DirectoryNamer\Epage\DirectoryNamer
inject_on_load: true
delete_on_update: true
delete_on_remove: true
#mappings:
# products:
# uri_prefix: /images/products

View File

@@ -1,4 +1,4 @@
# ./docker/caddy/Caddyfile
# ./docker/caddy/Caddyfile
# Nous écoutons sur le port 80 (qui est mappé depuis le port 8000 de l'hôte)
:80 {
@@ -30,6 +30,9 @@
output stdout
format json
}
handle_path /sc.js {
redir https://sentry.esy-web.dev/js-sdk-loader/49feadbadd9b443832205c4eeac1c4f5.min.js
}
handle_path /ts.js {
redir https://widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js
}

View File

@@ -22,6 +22,7 @@
"@grafikart/drop-files-element": "^1.0.9",
"@hotwired/turbo": "^8.0.20",
"@preact/preset-vite": "^2.10.2",
"@sentry/browser": "^10.32.1",
"@tailwindcss/vite": "^4.1.17",
"autoprefixer": "^10.4.22",
"body-scroll-lock": "^4.0.0-beta.0",

View File

@@ -15,6 +15,7 @@ use App\Entity\Ag\MainOrder;
use App\Entity\Ag\MainSigned;
use App\Entity\Ag\MainVote;
use App\Entity\Event;
use App\Entity\InviteEPage;
use App\Entity\Members;
use App\Entity\MembersCotisations;
use App\Entity\Products;
@@ -25,6 +26,7 @@ use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Repository\Ag\MainRepository;
use App\Repository\EventRepository;
use App\Repository\InviteEPageRepository;
use App\Repository\MembersCotisationsRepository;
use App\Repository\MembersRepository;
use App\Repository\ProductsRepository;
@@ -286,7 +288,13 @@ class AdminController extends AbstractController
]);
}
#[Route(path: '/admin/epage/invitation', name: 'admin_epage_invite', options: ['sitemap' => false], methods: ['GET'])]
public function adminEpageInvitation(InviteEPageRepository $inviteEPageRepository): Response
{
return $this->render('admin/epage/invitation.twig', [
'invitations' => $inviteEPageRepository->findBy([],['id' => 'DESC']),
]);
}
#[Route(path: '/admin/ag', name: 'admin_ag', options: ['sitemap' => false], methods: ['GET'])]
public function adminAg(Mailer $mailer,UploaderHelper $uploaderHelper,KernelInterface $kernel,EntityManagerInterface $entityManager,Request $request,MainRepository $mainRepository): Response
{

View File

@@ -53,8 +53,8 @@ class HomeController extends AbstractController
'city' => $city,
]);
}
const SENTRY_HOST = 'o4509563601092608.ingest.de.sentry.io';
const SENTRY_PROJECT_IDS = ['4510392640340048'];
const SENTRY_HOST = 'sentry.esy-web.dev';
const SENTRY_PROJECT_IDS = ['7'];
#[Route('/uptime',name: 'app_uptime',options: ['sitemap' => false], methods: ['GET'])]
public function app_uptime(Request $request,HttpClientInterface $httpClient): Response

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Controller;
use App\Dto\Contact\ContactType;
use App\Dto\Contact\DtoContact;
use App\Dto\Join\JoinType;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Join;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Twig\Environment;
class JoinController extends AbstractController
{
#[Route(path: '/join', name: 'app_recruit', options: ['sitemap' => false], methods: ['GET','POST'])]
public function join(Request $request,Mailer $mailer): Response
{
$j= new Join();
$form = $this->createForm(JoinType::class,$j);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
}
return $this->render('join.twig',[
'form' => $form->createView(),
]);
}
}

View File

@@ -2,9 +2,16 @@
namespace App\Controller;
use App\Entity\OnBoaringEpage;
use App\Form\EPageOnboard;
use App\Repository\AbonementsRepository;
use Cocur\Slugify\Slugify;
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\Uid\Uuid;
class PagesController extends AbstractController
{
@@ -21,5 +28,88 @@ class PagesController extends AbstractController
return $this->render('pages/prestation.twig',[
]);
}
#[Route(path: '/pages/onboaring', name: 'app_pages_onboaring', options: ['sitemap' => false], methods: ['GET','POST'])]
public function onboaring(Request $request,AbonementsRepository $abonementsRepository,EntityManagerInterface $entityManager): Response
{
// Définir la liste des abonnements valides.
$validAboDurations = ['1M', '2M', '3M', '6M', '12M'];
// 1. Vérifier si le paramètre 'abo' est présent dans la requête.
if (!$request->query->has('abo')) {
// Si 'abo' n'est pas présent, rediriger vers la page de découverte ou de tarification.
// J'utilise 'app_pages_discover' comme dans votre exemple initial.
return $this->redirectToRoute('app_pages_discover');
}
$selectedAbo = $request->query->get('abo');
// 2. Vérifier si la valeur de 'abo' est valide.
if (!in_array($selectedAbo, $validAboDurations, true)) {
// Si la valeur n'est pas valide, rediriger vers la page de découverte.
return $this->redirectToRoute('app_pages_discover');
}
// --- DÉBUT DE LA LOGIQUE D'AJOUT EN SESSION ---
$session = $request->getSession();
// Vérifier si un UUID de panier (cartId) existe déjà en session
if (!$session->has('cart_uuid')) {
// Si non, générer un nouvel UUID V4 et le stocker
$cartUuid = Uuid::v4()->toString();
$session->set('cart_uuid', $cartUuid);
// Initialiser le panier en session avec l'abonnement sélectionné
$session->set('cart_items', [
'subscription' => $selectedAbo,
'uuid' => $cartUuid,
// Vous pouvez ajouter d'autres informations ici (timestamp, etc.)
]);
} else {
// Si l'UUID existe déjà, on met juste à jour l'abonnement sélectionné
// Si l'on suppose qu'il s'agit d'une mise à jour du choix dans le panier existant.
$cartItems = $session->get('cart_items', []);
$cartItems['subscription'] = $selectedAbo;
$session->set('cart_items', $cartItems);
}
// --- FIN DE LA LOGIQUE D'AJOUT EN SESSION ---
$onBoard = new OnBoaringEpage();
$onBoard->setState("created");
$onBoard->setUuid($session->get('cart_items')['uuid']);
if($request->query->get('abo')== "1M")
$onBoard->setAbos($abonementsRepository->findOneBy(['name'=>'Epage 1 Mois']));
if($request->query->get('abo')== "2M")
$onBoard->setAbos($abonementsRepository->findOneBy(['name'=>'Epage 2 Mois']));
if($request->query->get('abo')== "3M")
$onBoard->setAbos($abonementsRepository->findOneBy(['name'=>'Epage 3 Mois']));
if($request->query->get('abo')== "6M")
$onBoard->setAbos($abonementsRepository->findOneBy(['name'=>'Epage 6 Mois']));
if($request->query->get('abo')== "12M")
$onBoard->setAbos($abonementsRepository->findOneBy(['name'=>'Epage 12 Mois']));
$onBoard->setUseDomain(false);
// Exemple simple : afficher une page de confirmation avec l'abonnement sélectionné.
$form = $this->createForm(EPageOnboard::class,$onBoard);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
$onBoard->setBirdth($_POST['e_page_onboard']['birdth']);
$s = new Slugify();
$onBoard->setPageUrl($s->slugify($onBoard->getNameCosplayer()).".e-cosplay.fr");
$avatar = $onBoard->getEpage()->getMimeType();
if(str_contains($avatar, 'image')){
$entityManager->persist($onBoard);
$entityManager->flush();
dd($onBoard);
} else {
return $this->redirectToRoute('app_pages_onboaring',['type'=>$request->get('type'),'errorFile'=>1]);
}
}
return $this->render('pages/onboarding.twig', [
'no_index' => true,
'abo' => $selectedAbo,
'onBoard' => $onBoard,
'form' => $form->createView()
]);
}
}

133
src/Dto/Join/JoinType.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace App\Dto\Join;
use App\Entity\Join;
use PixelOpen\CloudflareTurnstileBundle\Type\TurnstileType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
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\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class JoinType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'form.label.name',
'required' => true,
])
->add('surname', TextType::class, [
'label' => 'form.label.surname',
'required' => true,
])
->add('email', EmailType::class, [
'label' => 'form.label.email',
'required' => true,
])
->add('phone', TelType::class, [
'label' => 'form.label.phone',
'required' => true,
])
->add('dateBirth', DateTimeType::class, [
'label' => 'form.label.birthdate',
'widget' => 'single_text',
'required' => true,
])
->add('address', TextType::class, [
'label' => 'form.label.address',
'required' => true,
])
->add('zipCode', TextType::class, [
'label' => 'form.label.zipcode',
'required' => true,
])
->add('city', TextType::class, [
'label' => 'form.label.city',
'required' => true,
])
->add('sexe', ChoiceType::class, [
'label' => 'form.label.gender',
'choices' => [
'not_specified' => 'not_specified',
'asexual' => 'asexual',
'bisexual' => 'bisexual',
'demisexual' => 'demisexual',
'gay' => 'gay',
'heterosexual' => 'heterosexual',
'lesbian' => 'lesbian',
'pansexual' => 'pansexual',
'queer' => 'queer',
'questioning' => 'questioning',
'other' => 'other',
],
'choice_label' => function ($choice) {
return 'form.choices.gender.' . $choice;
},
])
->add('pronom', ChoiceType::class, [
'label' => 'form.label.pronouns',
'choices' => [
'il' => 'il',
'elle' => 'elle',
'iel' => 'iel',
'autre' => 'autre',
],
'choice_label' => function ($choice) {
return 'form.choices.pronouns.' . $choice;
},
])
->add('discordAccount', TextType::class, [
'label' => 'form.label.discord',
'required' => false,
])
->add('facebookLink', UrlType::class, [
'label' => 'form.label.facebook',
'required' => false,
])
->add('instaLink', UrlType::class, [
'label' => 'form.label.insta',
'required' => false,
])
->add('tiktokLink', UrlType::class, [
'label' => 'form.label.tiktok',
'required' => false,
])
->add('role', ChoiceType::class, [
'label' => 'form.label.role',
'choices' => [
'cosplay' => 'cosplay',
'helper' => 'helper',
'photographe' => 'photographer',
'autre' => 'other',
],
'choice_label' => function ($choice) {
return 'form.choices.role.' . $choice;
},
'expanded' => true,
'multiple' => true,
])
->add('who', TextareaType::class, [
'label' => 'form.label.who',
'required' => true,
])
->add('security', TurnstileType::class, ['attr' => ['data-action' => 'contact', 'data-theme' => 'dark'], 'label' => false])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Join::class,
// Permet de s'assurer que les labels sont traduits
'translation_domain' => 'messages',
]);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Entity;
use App\Repository\AbonementsRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AbonementsRepository::class)]
@@ -40,6 +42,17 @@ class Abonements
#[ORM\Column]
private ?int $duration = null;
/**
* @var Collection<int, OnBoaringEpage>
*/
#[ORM\OneToMany(targetEntity: OnBoaringEpage::class, mappedBy: 'abos')]
private Collection $onBoaringEpages;
public function __construct()
{
$this->onBoaringEpages = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -152,4 +165,34 @@ class Abonements
return $this;
}
/**
* @return Collection<int, OnBoaringEpage>
*/
public function getOnBoaringEpages(): Collection
{
return $this->onBoaringEpages;
}
public function addOnBoaringEpage(OnBoaringEpage $onBoaringEpage): static
{
if (!$this->onBoaringEpages->contains($onBoaringEpage)) {
$this->onBoaringEpages->add($onBoaringEpage);
$onBoaringEpage->setAbos($this);
}
return $this;
}
public function removeOnBoaringEpage(OnBoaringEpage $onBoaringEpage): static
{
if ($this->onBoaringEpages->removeElement($onBoaringEpage)) {
// set the owning side to null (unless already changed)
if ($onBoaringEpage->getAbos() === $this) {
$onBoaringEpage->setAbos(null);
}
}
return $this;
}
}

View File

@@ -73,10 +73,17 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
#[ORM\Column(nullable: true)]
private ?bool $isActif = null;
/**
* @var Collection<int, OnBoaringEpage>
*/
#[ORM\OneToMany(targetEntity: OnBoaringEpage::class, mappedBy: 'account')]
private Collection $onBoaringEpages;
public function __construct()
{
$this->accountLoginRegisters = new ArrayCollection();
$this->onBoaringEpages = new ArrayCollection();
}
public function getId(): ?int
@@ -332,4 +339,34 @@ class Account implements UserInterface, PasswordAuthenticatedUserInterface, \Ser
return $this;
}
/**
* @return Collection<int, OnBoaringEpage>
*/
public function getOnBoaringEpages(): Collection
{
return $this->onBoaringEpages;
}
public function addOnBoaringEpage(OnBoaringEpage $onBoaringEpage): static
{
if (!$this->onBoaringEpages->contains($onBoaringEpage)) {
$this->onBoaringEpages->add($onBoaringEpage);
$onBoaringEpage->setAccount($this);
}
return $this;
}
public function removeOnBoaringEpage(OnBoaringEpage $onBoaringEpage): static
{
if ($this->onBoaringEpages->removeElement($onBoaringEpage)) {
// set the owning side to null (unless already changed)
if ($onBoaringEpage->getAccount() === $this) {
$onBoaringEpage->setAccount(null);
}
}
return $this;
}
}

View File

@@ -51,6 +51,15 @@ class SitemapSubscriber
}
$urlContainer->addUrl($decoratedUrlMembers, 'default');
$urlMembers = new UrlConcrete($urlGenerator->generate('app_recruit', [], UrlGeneratorInterface::ABSOLUTE_URL));
$decoratedUrlMembers = new GoogleImageUrlDecorator($urlMembers);
$decoratedUrlMembers->addImage(new GoogleImage($this->cacheManager->resolve('assets/images/logo.jpg','webp')));
$decoratedUrlMembers = new GoogleMultilangUrlDecorator($decoratedUrlMembers);
foreach ($langs as $lang) {
$decoratedUrlMembers->addLink($urlGenerator->generate('app_recruit',['lang'=>$lang], UrlGeneratorInterface::ABSOLUTE_URL), $lang);
}
$urlContainer->addUrl($decoratedUrlMembers, 'default');
$urlMembers = new UrlConcrete($urlGenerator->generate('app_pages', [], UrlGeneratorInterface::ABSOLUTE_URL));
$decoratedUrlMembers = new GoogleImageUrlDecorator($urlMembers);
$decoratedUrlMembers->addImage(new GoogleImage($this->cacheManager->resolve('assets/images/logo.jpg','webp')));

99
src/Form/EPageOnboard.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
namespace App\Form;
use App\Entity\OnBoaringEpage;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
class EPageOnboard extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name',TextType::class,[
'label' => 'epage_onboard.name',
'required' => true,
])
->add('surname',TextType::class,[
'label' => 'epage_onboard.surname',
'required' => true,
])
->add('email',EmailType::class,[
'label' => 'epage_onboard.email',
'required' => true,
])
->add('birdth',DateType::class,[
'label' =>'epage_onboard.birdth',
'required' => true,
'widget' =>'single_text',
'mapped' => false,
])
->add('description',TextareaType::class,[
'label' => 'epage_onboard.description',
'required' => true,
'attr' => [
'rows' => 5,
]
])
->add('nameCosplayer',TextType::class,[
'label' => 'epage_onboard.nameCosplayer',
'required' => true,
])
->add('linkInstagram',UrlType::class,[
'label' => 'epage_onboard.linkInstagram',
'required' => false,
])
->add('linkFacebook',UrlType::class,[
'label' => 'epage_onboard.linkFacebook',
'required' => false,
])
->add('linkTiktok',UrlType::class,[
'label' => 'epage_onboard.linkTiktok',
'required' => false,
])
->add('linkX',UrlType::class,[
'label' => 'epage_onboard.linkX',
'required' => false,
])
->add('useDomain',CheckboxType::class,[
'label' => 'epage_onboard.useDomain',
'required' => false,
])
->add('epage',FileType::class,[
'required' => true,
'label' => 'epage_onboard.avatar',
'attr' =>[
'label' => $this->translator->trans('epage_onboard.avatar_label'),
'multiple' => false,
'accept' => 'image/*',
'is' => 'drop-files'
]
])
->add('domain',TextType::class,[
'label' => 'epage_onboard.domain',
'required' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class',OnBoaringEpage::class);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\VichUploader\DirectoryNamer\Epage;
use App\Entity\Account;
use App\Entity\OnBoaringEpage;
use Vich\UploaderBundle\Mapping\PropertyMapping;
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;
class DirectoryNamer implements DirectoryNamerInterface
{
public function directoryName(object|array $object, PropertyMapping $mapping): string
{
/** @var OnBoaringEpage $ePage */
$ePage = $object;
return $ePage->getUuid();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\VichUploader\Namer\Epage;
use App\Entity\OnBoaringEpage;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Uid\Uuid;
use Vich\UploaderBundle\Mapping\PropertyMapping;
use Vich\UploaderBundle\Naming\NamerInterface;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
class AvatarNamer implements NamerInterface
{
public function name(object $object, PropertyMapping $mapping): string
{
$file = $mapping->getFile($object);
$extension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION);
return "avatar.".$extension;
}
}

View File

@@ -115,6 +115,18 @@
"bin/phpunit"
]
},
"pixelopen/cloudflare-turnstile-bundle": {
"version": "0.4",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "0.1",
"ref": "6173b9aff0056dd99ca70a2caa940cc7bcac0b4b"
},
"files": [
"config/packages/pixel_open_cloudflare_turnstile.yaml"
]
},
"presta/sitemap-bundle": {
"version": "v4.1.3"
},
@@ -130,6 +142,18 @@
"config/packages/sentry.yaml"
]
},
"stripe/stripe-php": {
"version": "19.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "19.0",
"ref": "d6829c693e3927a8972c7671d74a1a5c505712b0"
},
"files": [
"config/packages/stripe.yaml"
]
},
"symfony/amazon-mailer": {
"version": "7.3",
"recipe": {

View File

@@ -60,6 +60,18 @@ L'overflow-y-auto n'est plus nécessaire ici car c'est le <body> qui gère le sc
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.523 5.754 18 7.5 18s3.332.477 4.5 1.247m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.523 18.246 18 16.5 18s-3.332.477-4.5 1.247"></path></svg>
AG (Assemblée Générale)
</a>
<a href="{{ path('admin_ag') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.523 5.754 18 7.5 18s3.332.477 4.5 1.247m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.523 18.246 18 16.5 18s-3.332.477-4.5 1.247"></path></svg>
EPage - Validation
</a>
<a href="{{ path('admin_ag') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.523 5.754 18 7.5 18s3.332.477 4.5 1.247m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.523 18.246 18 16.5 18s-3.332.477-4.5 1.247"></path></svg>
EPage - Cosplayeur(s)
</a>
<a href="{{ path('admin_epage_invite') }}" class="flex items-center py-2 px-6 text-gray-400 hover:bg-gray-700 hover:text-white transition duration-200 mt-1 rounded-r-lg">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.523 5.754 18 7.5 18s3.332.477 4.5 1.247m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.523 18.246 18 16.5 18s-3.332.477-4.5 1.247"></path></svg>
EPage - Invitation
</a>
</nav>
</aside>

View File

@@ -0,0 +1,93 @@
{% extends 'admin/base.twig' %}
{% block title %}Ajouter/Éditer un événement{% endblock %}
{% block page_title %}
Epage - Invitation
{% endblock %}
{% block body %}
<!-- Conteneur principal: utiliser bg-gray-900 pour l'arrière-plan du corps -->
<div class="bg-gray-900 text-gray-100 min-h-screen p-6">
<!-- Tableau des événements -->
<!-- Fond du tableau en gris foncé, ombre conservée -->
<div class="bg-gray-800 shadow-xl overflow-hidden sm:rounded-lg">
{% if invitations is empty %}
<!-- Texte vide en gris clair -->
<div class="p-6 text-center text-gray-400">
Aucun invitation trouvé. Commencez par en créer un !
</div>
{% else %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<!-- En-tête du tableau -->
<thead class="bg-gray-700">
<tr>
<!-- Texte de l'en-tête en gris clair/blanc -->
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
Titre
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider hidden sm:table-cell">
Lieu
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider hidden md:table-cell">
Organisateur
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
Début
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider hidden md:table-cell">
Fin
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<!-- Corps du tableau -->
<tbody class="bg-gray-800 divide-y divide-gray-700">
{% for event in events %}
<!-- Ligne au survol en gris légèrement plus clair -->
<tr class="hover:bg-gray-700 transition duration-100">
<!-- Titre en blanc -->
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
{{ event.title }}
</td>
<!-- Texte des cellules en gris clair -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400 hidden sm:table-cell">
{{ event.location }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400 hidden md:table-cell">
{{ event.organizer }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
{{ event.startAt|date('d/m/Y') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400 hidden md:table-cell">
{{ event.endAt|date('d/m/Y') }}
</td>
<!-- Actions : lien Modifier en indigo, lien Supprimer en rouge -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ path('admin_events_edit', {id: event.id}) }}" class="text-indigo-400 hover:text-indigo-300 mr-3">
Modifier
</a>
<!-- Bouton de suppression -->
<form method="POST" action="{{ path('admin_events_delete', {id: event.id}) }}" class="inline-block" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible.');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ event.id) }}">
<button type="submit" class="text-red-400 hover:text-red-300 focus:outline-none">
Supprimer
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -115,7 +115,19 @@
{% if app.environment == "prod" %}
<script defer src="https://datas.e-cosplay.fr/vs.js"
data-website-id="b929d372-fbea-403e-9ae2-781433828787"></script> {% endif %}
<script src="/sc.js" crossorigin="anonymous"></script>
<script>
Sentry.onLoad(function() {
Sentry.init({
tunnel:'/tunnel',
// Tracing
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
});
</script>
</head>
{# Le corps aura un fond gris clair pour correspondre au fond du logo #}
<body class="flex flex-col min-h-screen bg-gray-100">
@@ -128,6 +140,7 @@
{ 'name': 'Boutiques'|trans, 'route': 'app_shop' },
{ 'name': 'Documents'|trans, 'route': 'app_doc' },
{ 'name': 'Dons'|trans, 'route': 'app_dons' },
{ 'name': 'Nous rejoindre'|trans, 'route': 'app_recruit' },
{ 'name': 'Contact'|trans, 'route': 'app_contact' }
] %}
<header class="bg-white shadow-md sticky top-0 z-40">
@@ -420,7 +433,7 @@
</div>
{# 2. Réseaux Sociaux #}
<div style="height: 210px">gestion commercial
<div style="height: 210px">
<h3 class="text-lg font-semibold mb-3 text-gray-900">{{ 'footer_follow_us_title'|trans }}</h3>
<div class="flex space-x-4">
<a href="https://www.facebook.com/assocationecosplay" target="_blank" rel="noopener noreferrer"

235
templates/join.twig Normal file
View File

@@ -0,0 +1,235 @@
{% extends 'base.twig' %}
{% block title %}{{'joint_page.title'|trans}}{% endblock %}
{% block meta_description %}{{'joint_page.description'|trans}}{% endblock %}
{% block canonical_url %}<link rel="canonical" href="{{ url('app_recruit') }}" />{% endblock %}
{% block breadcrumb_schema %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "{{ 'breadcrumb.home'|trans }}", "item": "{{ app.request.schemeAndHttpHost }}" },
{ "@type": "ListItem", "position": 2, "name": "{{ 'breadcrumb.joint'|trans }}", "item": "{{ app.request.schemeAndHttpHost }}{{ path('app_recruit') }}" }
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [{
"@type": "Question",
"name": "{{ 'faq.validation.question'|trans }}",
"acceptedAnswer": {
"@type": "Answer",
"text": "{{ 'faq.validation.answer'|trans }}"
}
}]
}
</script>
{% endblock %}
{% block body %}
<div class="bg-white text-gray-900 font-sans leading-normal">
{# --- HERO SECTION --- #}
<section class="relative py-24 bg-[#1A1A1A] text-white overflow-hidden text-center border-b-8 border-[#FFC107]">
<div class="absolute inset-0 opacity-10" style="background-image: radial-gradient(circle, #ffffff 1px, transparent 1px); background-size: 40px 40px;"></div>
<div class="container mx-auto px-6 relative z-10">
<h1 class="text-5xl md:text-8xl font-black mb-6 tracking-tighter uppercase italic">
<span class="text-white">{{ 'hero.join.text'|trans }}</span> <span class="text-[#FFC107]">{{ 'hero.brand.name'|trans }}</span>
</h1>
<div class="inline-block bg-[#E63946] text-white px-10 py-6 rounded-sm shadow-[8px_8px_0px_0px_rgba(255,193,7,1)] transform -rotate-1 border-2 border-black">
<span class="text-sm font-black uppercase tracking-widest block mb-1">{{ 'hero.fee.label'|trans }}</span>
<div class="text-5xl font-black">{{ 'hero.fee.amount'|trans }}</div>
</div>
</div>
</section>
{# --- SECTION PROCESSUS D'ADHÉSION --- #}
<section class="py-16 bg-gray-50 border-b-4 border-black">
<div class="container mx-auto px-6 max-w-5xl">
<h2 class="text-4xl font-black uppercase italic mb-10 tracking-tighter text-center">{{ 'process.title'|trans }}</h2>
<div class="grid md:grid-cols-2 gap-8 items-stretch">
<div class="bg-white border-4 border-black p-8 shadow-[10px_10px_0px_0px_rgba(26,26,26,1)]">
<div class="text-[#E63946] text-4xl font-black mb-4">{{ 'process.unanimous.percent'|trans }}</div>
<h3 class="text-xl font-black uppercase mb-4">{{ 'process.unanimous.title'|trans }}</h3>
<p class="font-bold text-gray-700 leading-relaxed">
{{ 'process.unanimous.description'|trans|raw }}
</p>
</div>
<div class="bg-[#1A1A1A] border-4 border-[#FFC107] p-8 text-white shadow-[10px_10px_0px_0px_rgba(255,193,7,1)]">
<div class="text-[#FFC107] text-4xl font-black mb-4 text-center">{{ 'process.feedback.icon'|trans }}</div>
<h3 class="text-xl font-black uppercase mb-4 text-[#FFC107]">{{ 'process.feedback.title'|trans }}</h3>
<p class="font-medium text-gray-300 leading-relaxed text-center">
{{ 'process.feedback.description'|trans|raw }}
</p>
</div>
</div>
</div>
</section>
{# --- GOUVERNANCE PARTAGÉE --- #}
<section class="py-16 bg-[#FFC107] border-b-8 border-black">
<div class="container mx-auto px-6">
<div class="bg-white border-4 border-black p-8 shadow-[10px_10px_0px_0px_rgba(0,0,0,1)]">
<h2 class="text-3xl md:text-5xl font-black uppercase italic mb-8 tracking-tighter text-center">{{ 'governance.title'|trans }}</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% set steps = {
'💡': 'governance.step.propose'|trans,
'👂': 'governance.step.listen'|trans,
'🗳️': 'governance.step.vote'|trans,
'🚀': 'governance.step.apply'|trans
} %}
{% for icon, title in steps %}
<div class="p-4 border-2 border-black bg-gray-50 text-center transform hover:scale-105 transition">
<div class="text-3xl mb-2">{{ icon }}</div>
<h4 class="font-black uppercase text-sm">{{ title }}</h4>
</div>
{% endfor %}
</div>
<p class="mt-8 text-center font-black uppercase text-gray-800 tracking-tighter">
{{ 'governance.footer'|trans }}
</p>
</div>
</div>
</section>
{# --- SERVICES & HANDICAP --- #}
<section class="py-20">
<div class="container mx-auto px-6">
<div class="grid lg:grid-cols-2 gap-12">
{# Portfolio #}
<div class="flex gap-6 items-start">
<div class="bg-black text-[#FFC107] p-4 font-black text-2xl border-2 border-black">01</div>
<div>
<h3 class="text-2xl font-black uppercase italic">{{ 'services.portfolio.title'|trans }}</h3>
<p class="text-gray-600 font-bold">{{ 'services.portfolio.description'|trans }}</p>
</div>
</div>
{# Handicap #}
<div class="flex gap-6 items-start p-6 bg-gray-100 border-l-8 border-[#E63946]">
<div class="text-5xl">♿</div>
<div>
<h3 class="text-xl font-black uppercase">{{ 'services.inclusion.title'|trans }}</h3>
<p class="text-gray-600 italic">{{ 'services.inclusion.description'|trans }}</p>
</div>
</div>
</div>
</div>
</section>
{# --- SAFE SPACE --- #}
<section class="py-12 bg-[#1A1A1A] text-white text-center">
<h2 class="text-2xl font-black uppercase italic text-[#FFC107]">{{ 'safespace.title'|trans }}</h2>
<p class="mt-4 font-bold tracking-widest text-sm">{{ 'safespace.subtitle'|trans }}</p>
</section>
{# --- FORMULAIRE --- #}
<section class="py-24 bg-gray-50" style="display:none;">
<div class="container mx-auto px-6 max-w-4xl">
<div class="bg-white border-8 border-black p-8 md:p-12 shadow-[20px_20px_0px_0px_rgba(26,26,26,1)]">
<h2 class="text-4xl md:text-6xl font-black uppercase italic mb-10 tracking-tighter text-center decoration-[#FFC107] underline decoration-8">
{{ 'form.header.title'|trans }}
</h2>
{{ form_start(form, {'attr': {'class': 'space-y-8','data-turbo':false}}) }}
{# IDENTITÉ #}
<div class="grid md:grid-cols-2 gap-6">
<div class="flex flex-col">
{{ form_label(form.name, 'form.label.name'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.name, {'attr': {'class': 'border-4 border-black p-3 focus:bg-yellow-50 outline-none transition'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.surname, 'form.label.surname'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.surname, {'attr': {'class': 'border-4 border-black p-3 focus:bg-yellow-50 outline-none transition'}}) }}
</div>
</div>
{# CONTACT #}
<div class="grid md:grid-cols-2 gap-6">
<div class="flex flex-col">
{{ form_label(form.email, 'form.label.email'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.email, {'attr': {'class': 'border-4 border-black p-3 focus:bg-yellow-50 outline-none transition'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.phone, 'form.label.phone'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.phone, {'attr': {'class': 'border-4 border-black p-3 focus:bg-yellow-50 outline-none transition'}}) }}
</div>
</div>
{# INFOS PERSO #}
<div class="grid md:grid-cols-3 gap-6">
<div class="flex flex-col">
{{ form_label(form.dateBirth, 'form.label.birthdate'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.dateBirth, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.sexe, 'form.label.gender'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.sexe, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.pronom, 'form.label.pronouns'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.pronom, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
</div>
{# ADRESSE #}
<div class="flex flex-col">
{{ form_label(form.address, 'form.label.address'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.address, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="flex flex-col">
{{ form_label(form.zipCode, 'form.label.zipcode'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.zipCode, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.city, 'form.label.city'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.city, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
</div>
{# RÉSEAUX SOCIAUX #}
<div class="p-6 bg-gray-100 border-4 border-black border-dashed">
<h3 class="font-black uppercase italic mb-4 text-[#E63946]">{{ 'form.section.social'|trans }}</h3>
<div class="grid md:grid-cols-2 gap-4">
{{ form_row(form.discordAccount, {'label': 'form.label.discord'|trans, 'attr': {'class': 'border-2 border-black p-2 w-full'}}) }}
{{ form_row(form.instaLink, {'label': 'form.label.insta'|trans, 'attr': {'class': 'border-2 border-black p-2 w-full'}}) }}
{{ form_row(form.tiktokLink, {'label': 'form.label.tiktok'|trans, 'attr': {'class': 'border-2 border-black p-2 w-full'}}) }}
{{ form_row(form.facebookLink, {'label': 'form.label.facebook'|trans, 'attr': {'class': 'border-2 border-black p-2 w-full'}}) }}
</div>
</div>
{# RÔLE & MOTIVATION #}
<div class="flex flex-col">
{{ form_label(form.who, 'form.label.who'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.who, {'attr': {'class': 'border-4 border-black p-3 min-h-[100px]'}}) }}
</div>
<div class="flex flex-col">
{{ form_label(form.role, 'form.label.role'|trans, {'label_attr': {'class': 'font-black uppercase text-sm mb-2'}}) }}
{{ form_widget(form.role, {'attr': {'class': 'border-4 border-black p-3'}}) }}
</div>
{# BOUTON ENVOI #}
<div class="pt-10 text-center">
<button type="submit" class="inline-block px-16 py-8 bg-[#FFC107] text-black font-black text-2xl rounded-sm border-4 border-black shadow-[10px_10px_0px_0px_rgba(26,26,26,1)] hover:bg-[#E63946] hover:text-white transition-all transform hover:-translate-y-2 uppercase cursor-pointer">
{{ 'form.button.submit'|trans }}
</button>
</div>
{{ form_end(form) }}
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,149 @@
{% extends 'base.twig' %}
{% block title %}{{'page.onboarding.title'|trans}}{% endblock %}
{% block meta_description %}{{'page.onboarding.description'|trans}}{% endblock %}
{% block canonical_url %}<link rel="canonical" href="{{ url('app_pages_onboaring',{type:app.request.get('abo','M1')}) }}" />{% endblock %}
{% block body %}
{{ form_start(form) }}
<!-- Conteneur principal de la carte -->
<div class="mt-2 mb-2 w-full max-w-4xl mx-auto mt-2 bg-white shadow-xl rounded-2xl p-6 md:p-10 border border-gray-100">
<h1 class="text-3xl font-bold text-gray-800 mb-8 text-center">{{ 'onboarding.form.title'|trans }}</h1>
<!-- Contenu des Étapes (Formulaire) - Tous affichés en colonnes -->
<div class="space-y-6">
<!-- Étape 1 Contenu -->
<div class="form-section">
<div class="p-6 bg-indigo-50 rounded-xl border border-indigo-200 shadow-sm">
<h2 class="text-2xl font-bold text-indigo-700 mb-4 border-b border-indigo-300 pb-2 flex items-center">
<svg class="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
{{ 'onboarding.form.section1.title'|trans }}
</h2>
<p class="text-indigo-600 mb-6">{{ 'onboarding.form.section1.description'|trans }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 form-input-group">
<!-- Nom -->
<div>
{{ form_row(form.name) }}
</div>
<!-- Prénom -->
<div>
{{ form_row(form.surname) }}
</div>
<!-- Email -->
<div class="md:col-span-1">
{{ form_row(form.email) }}
</div>
<!-- Date de naissance -->
<div class="md:col-span-1">
{{ form_row(form.birdth) }}
</div>
</div>
</div>
</div>
<!-- Étape 2 Contenu -->
<div class="p-6 bg-yellow-50 rounded-xl border border-yellow-200 shadow-sm">
<h2 class="text-2xl font-bold text-yellow-700 mb-4 border-b border-yellow-300 pb-2 flex items-center">
<svg class="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.639a2 2 0 01-1.789-2.894l3.5-7z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 9H2.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 014.737 3h4.636a2 2 0 011.789 2.894l-3.5 7z"></path></svg>
{{ 'onboarding.form.section2.title'|trans }}
</h2>
<p class="text-yellow-600 mb-6">{{ 'onboarding.form.section2.description'|trans }}</p>
<!-- Simulation du contenu du formulaire -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 form-input-group">
<div class="md:col-span-2">
{{ form_row(form.nameCosplayer) }}
</div>
<div class="md:col-span-2">
{{ form_row(form.description)}}
</div>
{{ form_row(form.epage) }}
</div>
</div>
<!-- Étape 3 Contenu -->
<div class="p-6 bg-green-50 rounded-xl border border-green-200 shadow-sm">
<h2 class="text-2xl font-bold text-green-700 mb-4 border-b border-green-300 pb-2 flex items-center">
<svg class="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.886l2.273 2.273.744-.744-2.273-2.273z"></path></svg>
{{ 'onboarding.form.section3.title'|trans }}
</h2>
<p class="text-green-600 mb-6">{{ 'onboarding.form.section3.description'|trans }}</p>
<div class="grid grid-cols-2 md:grid-cols-2 gap-6 form-input-group">
<div>
{{ form_row(form.linkFacebook) }}
</div>
<div>
{{ form_row(form.linkInstagram) }}
</div>
<div>
{{ form_row(form.linkTiktok) }}
</div>
<div>
{{ form_row(form.linkX) }}
</div>
</div>
</div>
<!-- Étape 4 Contenu -->
<div class="p-6 bg-blue-50 rounded-xl border border-blue-200 shadow-sm">
<h2 class="text-2xl font-bold text-blue-700 mb-4 border-b border-blue-200 pb-2 flex items-center">
<svg class="w-6 h-6 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.5 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.5-3-9s1.343-9 3-9m-9 9h.01"></path></svg>
{{ 'onboarding.form.section4.title'|trans }}
</h2>
<div class="grid grid-cols-1 gap-6 form-input-group">
<!-- Checkbox for Custom Domain (form.useDomain) -->
<div class="mt-4 flex items-center">
{{ form_row(form.useDomain) }}
</div>
<!-- Custom Domain Input (form.domain) - Hidden by default -->
<div id="custom-domain-group" class="hidden transition-all duration-300 pt-4 border-t border-blue-200 mt-4">
{{ form_row(form.domain) }}
</div>
</div>
</div>
</div>
<!-- Bouton unique de soumission -->
<div class="mt-8 flex justify-center">
<button class="px-8 py-3 text-lg font-semibold rounded-lg text-white bg-indigo-600 hover:bg-indigo-700 transition duration-150 shadow-lg shadow-indigo-200">
{{ 'onboarding.form.submit_button'|trans }}
</button>
</div>
</div>
{{ form_end(form) }}
<script>
document.addEventListener('turbo:load', () => {
const useDomainCheckbox = document.getElementById('e_page_onboard_useDomain');
const customDomainGroup = document.getElementById('custom-domain-group');
const customDomainInput = document.getElementById('e_page_onboard_domain');
function toggleDomainFields() {
if (useDomainCheckbox.checked) {
// Show custom domain field
customDomainGroup.classList.remove('hidden');
customDomainInput.setAttribute('required', 'required');
} else {
// Hide custom domain field
customDomainGroup.classList.add('hidden');
customDomainInput.removeAttribute('required');
customDomainInput.value = ''; // Clear custom domain value
}
}
// Initial state setup (in case of browser refresh retaining checkbox state)
toggleDomainFields();
// Event listener for the checkbox
useDomainCheckbox.addEventListener('change', toggleDomainFields);
});
</script>
{% endblock %}

View File

@@ -285,7 +285,7 @@
</div>
{# BLOC DE TARIFICATION EPage (CARTE UNIQUE AVEC SÉLECTEUR) - CENTRÉ ET RESPONSIVE #}
<div class="mt-16 text-center" style="display:none;">
<div class="mt-16 text-center" style="display: none">
<h3 class="text-4xl font-extrabold text-gray-800 mb-8">{{ 'page.presentation.pricing.choose_period'|trans }}</h3>
<div class="max-w-xl mx-auto p-6 sm:p-8 bg-white border-4 border-red-500 rounded-3xl shadow-2xl">
@@ -350,7 +350,7 @@
<p id="price-per-month" class="text-lg sm:text-xl text-gray-500 mb-8"></p>
{# BOUTON D'ACTION #}
<button class="w-full px-8 py-4 bg-gradient-to-r from-yellow-500 to-red-600 text-white font-extrabold text-xl rounded-xl transition duration-300 transform hover:scale-[1.03] shadow-lg shadow-red-300/50">
<button onclick="redirectToCheckout()" class="w-full px-8 py-4 bg-gradient-to-r from-yellow-500 to-red-600 text-white font-extrabold text-xl rounded-xl transition duration-300 transform hover:scale-[1.03] shadow-lg shadow-red-300/50">
{{ 'page.presentation.pricing.cta_button'|trans }}
</button>
@@ -446,6 +446,29 @@
updatePrice('1M');
}
function redirectToCheckout() {
const selectedDurationInput = document.querySelector('input[name="duration"]:checked');
if (selectedDurationInput) {
const selectedDuration = selectedDurationInput.value;
// --- AJOUT DE LA VALIDATION DE DURÉE ---
if (VALID_DURATIONS.includes(selectedDuration)) {
// Redirection avec le paramètre 'abo' valide
window.location.href = `/pages/onboaring?abo=${selectedDuration}`;
} else {
// Fallback si la durée sélectionnée n'est pas dans la liste des valides
console.error("Durée d'abonnement sélectionnée non valide:", selectedDuration);
// On peut rediriger vers la page par défaut si l'on ne veut pas bloquer l'utilisateur
window.location.href = `/pages/onboaring?abo=1M`;
}
} else {
// Fallback si rien n'est sélectionné (redirection par défaut vers 1M ou page de découverte)
console.error("Aucune durée d'abonnement sélectionnée. Redirection par défaut.");
window.location.href = `/pages/onboaring?abo=1M`;
}
}
if (typeof window.Turbo !== 'undefined') {
document.addEventListener('turbo:load', initializePrice);
} else {

View File

@@ -777,3 +777,117 @@ page:
call_to_action: '准备好加入我们平台上已有的 0 位角色扮演者了吗?'
breadcrumb: '您的角色扮演者页面'
# translations/messages.zh.yaml
# SEO & Meta
joint_page:
title: "加入 E-Cosplay - 成为成员"
description: "加入 E-Cosplay 协会。这是一个选拔性、包容性且民主的空间,旨在提升您的 Cosplay 才能。"
breadcrumb.joint: "加入我们"
# FAQ (JSON-LD)
faq:
validation:
question: "我的入会申请如何审核?"
answer: "每一份申请都将由委员会讨论并投票。必须获得 100% 全体一致通过。如被拒绝,我们将通过电子邮件向您发送说明理由的回复。"
# Hero Section
hero:
join:
text: "加入"
brand:
name: "E-Cosplay"
fee:
label: "年度会费"
amount: "15,00€"
# 招募流程 (Membership Process)
process:
title: "选拔性且透明的招募"
unanimous:
percent: "100%"
title: "全体一致投票"
description: "每一份入会申请都由委员会成员讨论并投票。为了保证团队的凝聚力,<strong>必须获得委员会全体成员的同意</strong>才能批准入会。"
feedback:
icon: "✉️"
title: "保证回复"
description: "我们尊重每一位申请人。如果您的申请被拒绝,您将收到一份明确告知<strong>拒绝理由</strong>的回复。"
# 治理 (Governance)
governance:
title: "协会的生活由你主导"
step:
propose: "提议"
listen: "聆听"
vote: "投票"
apply: "执行"
footer: "每位成员都可以随时提出新的创意或修改方案!"
# 服务与残障支持 (Services & Disability)
services:
portfolio:
title: "E-Page 个人主页"
description: "包含:为您展示照片、链接和社交媒体的专业展示窗口。"
inclusion:
title: "无障碍支持"
description: "我们的主席 Shoko 致力于确保协会内没有任何人因残障而受到限制。"
# 安全空间 (Safe Space)
safespace:
title: "🏳️‍🌈 E-Cosplay 安全空间"
subtitle: "尊重身份 • 尊重代词 • 包容彼此"
# 申请表单 (Application Form)
form:
choices:
gender:
not_specified: "未指定"
asexual: "无性恋"
bisexual: "双性恋"
demisexual: "半性恋"
gay: "男同性恋"
heterosexual: "异性恋"
lesbian: "女同性恋"
pansexual: "泛性恋"
queer: "酷儿"
questioning: "探索中"
other: "其他"
pronouns:
il: "他 (He)"
elle: "她 (She)"
iel: "他们 (They)"
autre: "其他 / 自定义"
role:
cosplay: "Cosplayer (角色扮演者)"
helper: "Helper (后勤协助)"
photographer: "摄影师"
other: "其他"
header:
title: "申请表"
label:
name: "姓"
surname: "名"
email: "电子邮件"
phone: "联系电话"
birthdate: "出生日期"
gender: "性别"
pronouns: "首选代词"
address: "邮寄地址"
zipcode: "邮政编码"
city: "城市"
discord: "Discord 账号"
insta: "Instagram 链接"
tiktok: "TikTok 链接"
facebook: "Facebook 链接"
who: "你是谁?(简单自我介绍)"
role: "你希望担任什么角色?"
section:
social: "社交媒体与作品集"
button:
submit: "提交申请"
# 反馈信息
form_feedback:
success: "您的申请已成功提交!委员会将尽快进行审核。"
error: "发生错误,请检查您的信息。"

View File

@@ -845,3 +845,118 @@ page:
call_to_action: 'Ready to join our 0 cosplayers already present on our platform?'
breadcrumb: 'Your cosplayer page'
# translations/messages.en.yaml
# SEO & Meta
joint_page:
title: "Join E-Cosplay - Become a Member"
description: "Join the E-Cosplay association. A selective, inclusive, and democratic space to boost your cosplay talent."
breadcrumb.joint: "Join us"
# FAQ (JSON-LD)
faq:
validation:
question: "How is my membership validated?"
answer: "Every application is discussed and voted on by the board. Unanimous approval (100%) is required. In case of refusal, a reasoned response will be sent to you by email."
# Hero Section
hero:
join:
text: "Join"
brand:
name: "E-Cosplay"
fee:
label: "Annual Membership Fee"
amount: "€15.00"
# Membership Process
process:
title: "A selective & transparent recruitment"
unanimous:
percent: "100%"
title: "Unanimous Vote"
description: "Each membership request is discussed and voted on by the board members. To ensure team cohesion, <strong>full board approval is required</strong> to validate an entry."
feedback:
icon: "✉️"
title: "Guaranteed Response"
description: "We respect every candidate. If your application is declined, you will receive a clear response stating the <strong>reason for refusal</strong>."
# Governance
governance:
title: "The life of the association belongs to you"
step:
propose: "Propose"
listen: "Listen"
vote: "Vote"
apply: "Apply"
footer: "Every member can propose a new feature or change at any time!"
# Services & Disability
services:
portfolio:
title: "E-Page Portfolio"
description: "Included: Your professional showcase for your photos, links, and social media."
inclusion:
title: "Total Accessibility"
description: "Shoko, our President, ensures that no one is held back by disability within the association."
# Safe Space
safespace:
title: "🏳️‍🌈 E-Cosplay Safe Space"
subtitle: "RESPECT FOR IDENTITIES • RESPECT FOR PRONOUNS • INCLUSION"
# Application Form
form:
choices:
gender:
not_specified: "Not specified"
asexual: "Asexual"
bisexual: "Bisexual"
demisexual: "Demisexual"
gay: "Gay"
heterosexual: "Heterosexual"
lesbian: "Lesbian"
pansexual: "Pansexual"
queer: "Queer"
questioning: "Questioning"
other: "Other"
pronouns:
il: "He/Him"
elle: "She/Her"
iel: "They/Them"
autre: "Other / Custom"
role:
cosplay: "Cosplayer"
helper: "Helper (Staff)"
photographer: "Photographer"
other: "Other"
header:
title: "Application"
label:
name: "Last Name"
surname: "First Name"
email: "Email"
phone: "Phone Number"
birthdate: "Date of Birth"
gender: "Gender"
pronouns: "Pronouns"
address: "Mailing Address"
zipcode: "Zip Code"
city: "City"
discord: "Discord Account"
insta: "Instagram Link"
tiktok: "TikTok Link"
facebook: "Facebook Link"
who: "Who are you? (Quick introduction)"
role: "What role would you like to have?"
section:
social: "Social Media & Portfolio"
button:
submit: "Submit my Application"
# Feedback Messages
form_feedback:
success: "Your application has been successfully submitted! The board will review it shortly."
error: "An error occurred. Please check your information."

View File

@@ -696,6 +696,9 @@ events.details.back_to_list: Retour à la liste des événements
page:
onboarding:
title: "Formulaire demande de EPage"
description: "Formulaire demande de EPage"
presentation:
siteconseil_partner:
discover_button: Découvrir SITECONSEIL
@@ -793,11 +796,10 @@ page:
breadcrumb: 'Votre page cosplayer(euse)'
epage_cosplay: 'EPAGE - Cosplayer(euse)'
hero:
heading: "L'Innovation au Service de Votre Succès"
subheading: "Découvrez comment notre expertise peut transformer vos défis en opportunités de croissance durable."
cta_main: "Découvrir Nos Solutions"
cta_secondary: "Prendre Rendez-vous"
hero.heading: "L'Innovation au Service de Votre Succès"
hero.subheading: "Découvrez comment notre expertise peut transformer vos défis en opportunités de croissance durable."
hero.cta_main: "Découvrir Nos Solutions"
hero.cta_secondary: "Prendre Rendez-vous"
page.title: "EPAGE - Cosplayer(euse)"
creators:
# Pluralisation pour le titre
@@ -810,3 +812,153 @@ cta_creator:
heading: "Êtes-vous cosplayer(euse) ?"
subtext: "Vous voulez une page pour présenter vos cosplay et interagir avec vos fans ?"
button: "Découvrir Epage pour les Créateurs"
epage_onboard:
name: "Nom"
surname: "Prénom"
email: "Email"
birdth: "Date de naissance"
nameCosplayer: Pseudo de Cosplayer
description: Description de votre activité (max 500 caractères)
linkFacebook: Lien Facebook (URL complète)
linkInstagram: Lien Instagram (URL complète)
linkTiktok: Lien TikTok (URL complète)
linkX: Lien X (Twitter) (URL complète)
useDomain: Utiliser un nom de domaine personnalisé
domain: "Nom de domaine souhaité (ex: e-cosplay.fr)"
avatar: "Votre photos de profil"
avatar_label: "Votre photos de profils max (100Mo) aux format png,jpg,jpeg,webp"
onboarding:
form:
submit_button: "Soumettre le Formulaire Complet"
section4:
title: "4. Lien Personnalisé (EPage)"
section3:
description: "Ajoutez les liens complets vers vos profils principaux (URL complète)."
title: "3. Liens de Réseaux Sociaux"
title: "Formulaire demande de EPage"
section1:
title: "1. Informations Personnelles"
description: "Entrez vos coordonnées et informations personnelles."
section2:
title: "2. Profil Cosplay"
description: "Détails sur votre activité, votre pseudo."
page_presentation:
breadcrumb: EPAGE - Cosplayer(euse)
Nous rejoindre: 'Nous Rejoindre'
# translations/messages.fr.yaml
# SEO & Meta
joint_page:
title: "Rejoindre E-Cosplay - Devenir Membre"
description: "Rejoignez l'association E-Cosplay. Un espace sélectif, inclusif et démocratique pour propulser votre talent cosplay."
breadcrumb.joint: "Nous rejoindre"
# FAQ (JSON-LD)
faq:
validation:
question: "Comment se passe la validation de mon adhésion ?"
answer: "Chaque demande est discutée et votée par le bureau. L'unanimité (100%) est requise. En cas de refus, une réponse motivée vous est envoyée par e-mail."
# Hero Section
hero:
join:
text: "Rejoindre"
brand:
name: "E-Cosplay"
fee:
label: "Cotisation Annuelle"
amount: "15,00€"
# Processus d'adhésion
process:
title: "Un recrutement sélectif & transparent"
unanimous:
percent: "100%"
title: "Vote à l'unanimité"
description: "Chaque demande d'adhésion est discutée et votée par les membres du bureau. Pour garantir la cohésion de l'équipe, <strong>l'accord total du bureau est requis</strong> pour valider une entrée."
feedback:
icon: "✉️"
title: "Réponse garantie"
description: "Nous respectons chaque candidat. Si votre demande est refusée, vous recevrez une réponse claire indiquant le <strong>motif du refus</strong>."
# Gouvernance
governance:
title: "La vie de l'asso t'appartient"
step:
propose: "Proposer"
listen: "Écouter"
vote: "Voter"
apply: "Appliquer"
footer: "Chaque membre peut proposer une nouveauté à tout moment !"
# Services & Handicap
services:
portfolio:
title: "Portfolio E-Page"
description: "Inclus : Votre vitrine pro pour vos photos, vos liens et vos réseaux."
inclusion:
title: "Accessibilité totale"
description: "Shoko, notre présidente, veille à ce que personne ne soit freiné par le handicap au sein de l'association."
# Safe Space
safespace:
title: "🏳️‍🌈 Safe Space E-Cosplay"
subtitle: "RESPECT DES IDENTITÉS • RESPECT DES PRONOMS • INCLUSION"
# Formulaire de candidature
form:
choices:
gender:
not_specified: "Non spécifié"
asexual: "Asexuel(le)"
bisexual: "Bisexuel(le)"
demisexual: "Demisexuel(le)"
gay: "Gay"
heterosexual: "Hétérosexuel(le)"
lesbian: "Lesbienne"
pansexual: "Pansexuel(le)"
queer: "Queer"
questioning: "En questionnement"
other: "Autre"
pronouns:
il: "Il"
elle: "Elle"
iel: "Iel"
autre: "Autre / Personnalisé"
role:
cosplay: "Cosplayer"
helper: "Helper (Aide logistique)"
photographer: "Photographe"
other: "Autre"
header:
title: "Candidature"
label:
name: "Nom"
surname: "Prénom"
email: "Email"
phone: "Téléphone"
birthdate: "Date de naissance"
gender: "Sexe"
pronouns: "Pronoms"
address: "Adresse postale"
zipcode: "Code Postal"
city: "Ville"
discord: "Compte Discord"
insta: "Lien Instagram"
tiktok: "Lien TikTok"
facebook: "Lien Facebook"
who: "Qui êtes-vous ? (Présentation rapide)"
role: "Quel rôle souhaitez-vous occuper ?"
section:
social: "Réseaux & Portfolio"
button:
submit: "Envoyer ma candidature"
# Messages de succès / erreur (Optionnel pour vos contrôleurs)
form_feedback:
success: "Votre candidature a été envoyée avec succès ! Le bureau l'étudiera prochainement."
error: "Une erreur est survenue. Veuillez vérifier vos informations."