feat: ✨ cookie notice, the end ?

This commit is contained in:
K Muller 2026-08-04 10:58:11 +02:00
parent f43cb28a7d
commit 3c9a9d3ae4
36 changed files with 1885 additions and 223 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ Homestead.json
Homestead.yaml
Thumbs.db
.npmrc
bootstrap/ssr
public/favicon-back

28
Ssr-Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.ts ./
COPY tsconfig.json ./
RUN npm run build
RUN npm run build:ssr
CMD ["node", "bootstrap/ssr/ssr.js"]
apiVersion: v1
kind: Service
metadata:
name: inertia-ssr
spec:
selector:
app: inertia-ssr
ports:
- port: 13714
targetPort: 13714
type: ClusterIP

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Front;
use App\Enums\Contact\ContactFormType;
use App\Exceptions\AbortContactMailer;
use App\Http\Requests\Front\Contact\ContactRequest;
use App\Jobs\Mails\SendContactMail;
use App\Models\ContactCount;
use App\Models\MailContactData;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\HtmlString;
use LDC\LDCCRM\Enums\ApiError;
use LDC\LDCCRM\Exceptions\ApiErrorException;
use LDC\LDCCRM\Exceptions\CrmException;
use LDC\LDCCRM\Models\SingleOptin;
class ContactController
{
/**
* Contact send.
*
* @param \App\Http\Requests\Front\Contact\ContactRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function send(ContactRequest $request): \Illuminate\Http\RedirectResponse
{
$validatedData = $request->validated();
dd($validatedData);
$messageContact = new MailContactData($validatedData);
// $contactMessage = "";
// $hasContactError = false;
// $newsletterMessage = null;
// $hasNewsletterError = false;
// /** @var boolean $isNewsletter */
// $isNewsletter = isset($validatedData['newsletter']) ? $validatedData['newsletter'] : false;
// try {
// dispatch(new SendContactMail($messageContact))
// ->onConnection('database')
// ->onQueue('database');
// } catch (AbortContactMailer $e) {
// Log::error('Fail to build mail from contact page', [
// 'exception' => $e
// ]);
// $hasContactError = true;
// $contactMessage = trans('Une erreur est survenue lors de l\'envoi du message de contact.');
// }
// if ($isNewsletter) {
// try {
// SingleOptin::setStatus($request->email, true);
// $newsletterMessage = trans("Votre inscription à la newsletter a été prise en compte");
// } catch (ApiErrorException $e) {
// if ($e->getApiError()->equals(ApiError::singleOptinCustomerExists)) {
// $newsletterMessage = trans("Vous êtes déjà inscrit avec cette adresse email");
// }
// report($e);
// $hasNewsletterError = true;
// // phpcs:ignore Generic.Files.LineLength
// $newsletterMessage = trans('Une erreur est survenue lors de l\'inscription à la newsletter, merci de réessayer ultérieurement.');
// } catch (CrmException $e) {
// report($e);
// $hasNewsletterError = true;
// // phpcs:ignore Generic.Files.LineLength
// $newsletterMessage = trans('Une erreur est survenue lors de l\'inscription à la newsletter, merci de réessayer ultérieurement.');
// }
// }
// ContactCount::updateOrInsert(
// ['type' => $validatedData['enumType']],
// ['submissions' => DB::raw('submissions + 1')]
// );
// $contactMessage = trans('Votre message a bien été envoyé.');
// $message = $newsletterMessage ? $contactMessage . ' <br /> ' . $newsletterMessage : $contactMessage;
// return redirect()->back()
// ->with(
// $hasContactError || $hasNewsletterError ? 'info' : 'success',
// new HtmlString($message)
// );
}
}

View file

@ -6,6 +6,7 @@ use App\Enums\Contact\ActivitySectorEnum;
use App\Enums\Contact\ObjectEnum;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
use Uwil\LaravelCommon\Helpers\InertiaHelper;
use Uwil\LaravelCommon\Support\InertiaSharedRegistry;
use Uwil\LaravelDynamicPages\Enums\PageStatusEnum;
@ -41,7 +42,19 @@ class HandleInertiaRequests extends Middleware
'recaptchaKey' => config('services.google.recaptcha.public'),
'dynamicPages' => $dynamicPages,
'activitySectorEnum' => ActivitySectorEnum::toArray(),
'objectEnum' => ObjectEnum::toArray()
'objectEnum' => ObjectEnum::toArray(),
'cookieEnv' => [
"matomoSiteId" => config('cookie-notice.matomo.site_id'),
"recaptchaKey" => config('cookie-notice.google.recaptcha.public'),
"recaptchaBypassKey" => config('cookie-notice.google.recaptcha.bypassKey'),
"gaMeasurementId" => config('cookie-notice.google.analytics.id')
],
'ziggy' => fn (): array => [
// Routes ziggy.
...new Ziggy()->toArray(),
// Use for route().current() by ziggy.
'location' => $request->url(),
],
// More props here.
];

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Front\Contact;
use App\Enums\Contact\ActivitySectorEnum;
use App\Enums\Contact\ObjectEnum;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Uwil\LaravelCommon\Helpers\ToolboxHelper;
class ContactRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
// ToolboxHelper::validateRecaptcha($this, \config('services.google.recaptcha.version'));
$rules = [
'phone' => ['required', 'min:1', 'max:60'],
'name' => ['required', 'min:1', 'max:60'],
'firstName' => ['required', 'min:1', 'max:60'],
'message' => ['required', 'min:10', 'max:255'],
'mail' => ['required', 'min:1', 'max:60', 'email:rfc,dns,spoof'],
'entrepriseName' => ['nullable', 'min:1', 'max:60'],
'activitySector' => ['nullable', Rule::enum(ActivitySectorEnum::class)],
'object' => ['required', Rule::enum(ObjectEnum::class)],
'terms' => 'boolean',
'optin' => 'boolean'
];
return $rules;
}
}

19
config/cookie-notice.php Normal file
View file

@ -0,0 +1,19 @@
<?php
return [
'google' => [
'recaptcha' => [
'public' => env('GOOGLE_RECAPTCHA_PUBLIC'),
'private' => env('GOOGLE_RECAPTCHA_PRIVATE'),
'version' => env('GOOGLE_RECAPTCHA_VERSION', "3") === "3" ? true : false,
'bypassKey' => "bypass"
],
'analytics' => [
'id' => env('GOOGLE_ANALYTICS_ID')
]
],
'matomo' => [
'site_id' => env('MATOMO_SITE_ID')
]
];

View file

@ -35,11 +35,5 @@ return [
],
],
'google' => [
'recaptcha' => [
'public' => env('GOOGLE_RECAPTCHA_PUBLIC'),
'private' => env('GOOGLE_RECAPTCHA_PRIVATE'),
'version' => env('GOOGLE_RECAPTCHA_VERSION', "3") === "3" ? true : false
]
],
];

55
lang/de/cookie-notice.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'button_banner_cookies' => 'Cookies',
'banner_text' => 'We use cookies to ensure the proper functioning of our site and for analytical purposes.
You can change your mind at any time by clicking on the icon on any page of our site.
By authorizing these third-party services, you agree to the deposit and reading of cookies and the use of tracking technologies necessary for their proper operation.',
'button_validate_and_close' => 'Confirm and close',
'button_forbid_all' => 'Refuse all',
'button_allow_all' => 'Accept all',
'consent_all_text' => 'By clicking on <span class="font-medium">"Accept all"</span>, you consent to use our cookies.',
'banner_title' => 'The :site_name website uses cookies!',
'learn_more' => 'Learn more',
'button_close_cookies' => 'Close cookie window',
'modal_title' => 'Cookie management window',
'panel_content' => 'Cookie management panel content',
'button_customize' => 'Customize',
'preferences_cookies' => 'Preferences cookies',
'button_toggle_cookie' => 'Accept or refuse :service',
'open_cookies' => 'Open cookies management panel',
'button_forbid_all_title' => 'Refuse all cookies and close the cookies panel',
'button_allow_all_title' => 'Accept all cookies and close the cookies panel',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Audience measurement services make it possible to generate attendance statistics useful for improving the site.',
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Cookies placed via video sharing services are intended to allow the user to view multimedia content directly on the site.',
'labelLink' => 'Learn more',
'warning' => 'You must accept cookies to view this video',
'accept' => 'Accept'
],
'youtube' => [
'title' => 'Youtube',
'description' => 'Cookies placed via video sharing services are intended to allow the user to view multimedia content directly on the site.',
'labelLink' => 'Learn more',
'warning' => 'You must accept cookies to view this video',
'accept' => 'Accept'
],
'matomo' => [
'title' => 'Matomo',
'description' => 'Audience measurement services are used to generate traffic statistics, which are useful for improving the site.',
'labelLink' => 'Learn more',
],
'recaptcha' => [
'title' => 'reCAPTCHA',
'description' => 'The purpose of the cookies placed on forms via the recaptcha is to prevent them from being submitted by robots.',
'labelLink' => 'Learn more',
'warning' => 'You must accept reCAPTCHA cookies to send the form',
'accept' => 'Accept reCAPTCHA'
]
]
];

View file

@ -1,29 +1,37 @@
<?php
return [
'button_customize_cookies' => 'Customize cookies',
'button_close_cookies' => 'Close cookie window',
'button_toggle_cookie' => 'Accept or refuse',
'button_forbid_all' => 'Refuse all',
'button_allow_all' => 'Accept all',
'button_forbid_all_title' => 'Refuse all cookies and close the cookies panel',
'button_allow_all_title' => 'Accept all cookies and close the cookies panel',
'button_customize' => 'Customize',
'button_validate_and_close' => 'Confirm and close',
'button_title_open_new_tab' => 'Open link in new tab',
'banner_title' => 'The :site_name website uses cookies!',
'button_banner_cookies' => 'Cookies',
'banner_text' => 'We use cookies to ensure the proper functioning of our site and for analytical purposes.
You can change your mind at any time by clicking on the icon on any page of our site.
By authorizing these third-party services, you agree to the deposit and reading of cookies and the use of tracking technologies necessary for their proper operation.',
'button_validate_and_close' => 'Confirm and close',
'button_forbid_all' => 'Refuse all',
'button_allow_all' => 'Accept all',
'consent_all_text' => 'By clicking on <span class="font-medium">"Accept all"</span>, you consent to use our cookies.',
'banner_title' => 'The :site_name website uses cookies!',
'learn_more' => 'Learn more',
'button_close_cookies' => 'Close cookie window',
'modal_title' => 'Cookie management window',
'panel_content' => 'Cookie management panel content',
'picture_cookie_title' => 'Image of a nice big cookie',
'navigation_title' => 'Cookies panel navigation',
'custom' => [
'button_customize' => 'Customize',
'preferences_cookies' => 'Preferences cookies',
'button_toggle_cookie' => 'Accept or refuse :service',
'open_cookies' => 'Open cookies management panel',
'button_forbid_all_title' => 'Refuse all cookies and close the cookies panel',
'button_allow_all_title' => 'Accept all cookies and close the cookies panel',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Audience measurement services make it possible to generate attendance statistics useful for improving the site.',
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Cookies placed via video sharing services are intended to allow the user to view multimedia content directly on the site.',
'labelLink' => 'Learn more',
'warning' => 'You must accept cookies to view this video',
'accept' => 'Accept'
],
'youtube' => [
'title' => 'Youtube',
'description' => 'Cookies placed via video sharing services are intended to allow the user to view multimedia content directly on the site.',

55
lang/es/cookie-notice.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'button_banner_cookies' => 'Cookies',
'banner_text' => 'Utilizamos cookies para garantizar el correcto funcionamiento de nuestro sitio y con fines analíticos.
Puede cambiar su decisión en cualquier momento haciendo clic en el icono en cualquier página de nuestro sitio.
Al autorizar estos servicios de terceros, acepta el depósito y la lectura de cookies, así como el uso de tecnologías de seguimiento necesarias para su correcto funcionamiento.',
'button_validate_and_close' => 'Confirmar y cerrar',
'button_forbid_all' => 'Rechazar todo',
'button_allow_all' => 'Aceptar todo',
'consent_all_text' => 'Al hacer clic en <span class="font-medium">"Aceptar todo"</span>, usted acepta el uso de nuestras cookies.',
'banner_title' => '¡El sitio web :site_name utiliza cookies!',
'learn_more' => 'Más información',
'button_close_cookies' => 'Cerrar ventana de cookies',
'modal_title' => 'Ventana de gestión de cookies',
'panel_content' => 'Contenido del panel de gestión de cookies',
'button_customize' => 'Personalizar',
'preferences_cookies' => 'Cookies de preferencias',
'button_toggle_cookie' => 'Aceptar o rechazar :service',
'open_cookies' => 'Abrir panel de gestión de cookies',
'button_forbid_all_title' => 'Rechazar todas las cookies y cerrar el panel de cookies',
'button_allow_all_title' => 'Aceptar todas las cookies y cerrar el panel de cookies',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Los servicios de medición de audiencia permiten generar estadísticas de visitas útiles para mejorar el sitio.',
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Las cookies colocadas a través de servicios de vídeo permiten al usuario visualizar contenido multimedia directamente en el sitio.',
'labelLink' => 'Más información',
'warning' => 'Debe aceptar las cookies para ver este vídeo',
'accept' => 'Aceptar'
],
'youtube' => [
'title' => 'YouTube',
'description' => 'Las cookies colocadas a través de servicios de vídeo permiten al usuario visualizar contenido multimedia directamente en el sitio.',
'labelLink' => 'Más información',
'warning' => 'Debe aceptar las cookies para ver este vídeo',
'accept' => 'Aceptar'
],
'matomo' => [
'title' => 'Matomo',
'description' => 'Los servicios de medición de audiencia se utilizan para generar estadísticas de tráfico útiles para mejorar el sitio.',
'labelLink' => 'Más información',
],
'recaptcha' => [
'title' => 'reCAPTCHA',
'description' => 'Las cookies de reCAPTCHA en formularios se utilizan para evitar envíos por robots.',
'labelLink' => 'Más información',
'warning' => 'Debe aceptar las cookies de reCAPTCHA para enviar el formulario',
'accept' => 'Aceptar reCAPTCHA'
]
]
];

View file

@ -1,31 +1,39 @@
<?php
return [
'button_customize_cookies' => 'Personnaliser les cookies',
'button_close_cookies' => 'Fermer la fenêtre des cookies',
'button_toggle_cookie' => 'Autoriser ou refuser',
'button_forbid_all' => 'Tout refuser',
'button_allow_all' => 'Tout accepter',
'button_forbid_all_title' => 'Refuser tous les cookies et fermer le panneau des cookies',
'button_allow_all_title' => 'Accepter tous les cookies et fermer le panneau des cookies',
'button_customize' => 'Personnaliser',
'button_validate_and_close' => 'Valider et fermer',
'button_title_open_new_tab' => 'Ouvrir le lien dans un nouvel onglet',
'banner_title' => 'Le site internet :site_name utilise des cookies !',
'button_banner_cookies' => 'Cookies',
'banner_text' => 'Nous utilisons des cookies pour nous assurer du bon fonctionnement de notre site et à des fins analytiques.
Vous pouvez changer d’avis à tout moment en cliquant sur l’icône présente sur chaque page de notre site.
En autorisant ces services tiers, vous acceptez le dépôt et la lecture de cookies et l’utilisation de technologies de suivi nécessaires à leur bon fonctionnement.',
'button_validate_and_close' => 'Valider et fermer',
'button_forbid_all' => 'Tout refuser',
'button_allow_all' => 'Tout accepter',
'consent_all_text' => 'En cliquant sur <span class="font-medium">"Tout accepter"</span>, vous consentez à utiliser nos cookies.',
'banner_title' => 'Le site internet :site_name utilise des cookies !',
'learn_more' => 'En savoir plus',
'button_close_cookies' => 'Fermer la fenêtre des cookies',
'modal_title' => 'Fenêtre de gestion de cookies',
'panel_content' => 'Contenu du panneau des cookies',
'picture_cookie_title' => 'Image d’un bon gros cookie',
'navigation_title' => 'Navigation du panneau des cookies',
'custom' => [
'button_customize' => 'Personnaliser',
'preferences_cookies' => 'Préferences cookies',
'button_toggle_cookie' => 'Accepter ou refuser :service',
'open_cookies' => 'Ouvrir la fenêtre de gestion des cookies',
'button_forbid_all_title' => 'Refuser tous les cookies et fermer le panneau des cookies',
'button_allow_all_title' => 'Accepter tous les cookies et fermer le panneau des cookies',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Les services de mesure d’audience permettent de générer des statistiques de fréquentation utiles à l’amélioration du site.',
],
'youtube' => [
'name' => 'Youtube',
'title' => 'Youtube',
'description' => 'Les cookies placés via les services de partage de vidéos sont destinés à permettre à l’utilisateur de visionner des contenus multimédias directement sur le site.',
'labelLink' => 'En savoir +',
'warning' => 'Vous devez accepter les cookies pour visionner cette vidéo',
'accept' => 'Autoriser'
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Les cookies placés via les services de partage de vidéos sont destinés à permettre à l’utilisateur de visionner des contenus multimédias directement sur le site.',
'labelLink' => 'En savoir +',
'warning' => 'Vous devez accepter les cookies pour visionner cette vidéo',

55
lang/nl/cookie-notice.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'button_banner_cookies' => 'Cookies',
'banner_text' => 'Wij gebruiken cookies om de goede werking van onze website te garanderen en voor analytische doeleinden.
U kunt uw keuze op elk moment wijzigen door op het pictogram op elke pagina van onze website te klikken.
Door deze externe diensten te autoriseren, stemt u in met het plaatsen en lezen van cookies en het gebruik van trackingtechnologieën die nodig zijn voor hun goede werking.',
'button_validate_and_close' => 'Bevestigen en sluiten',
'button_forbid_all' => 'Alles weigeren',
'button_allow_all' => 'Alles accepteren',
'consent_all_text' => 'Door te klikken op <span class="font-medium">"Alles accepteren"</span> stemt u in met het gebruik van onze cookies.',
'banner_title' => 'De website :site_name gebruikt cookies!',
'learn_more' => 'Meer informatie',
'button_close_cookies' => 'Cookievenster sluiten',
'modal_title' => 'Cookiebeheer venster',
'panel_content' => 'Inhoud van het cookiebeheer paneel',
'button_customize' => 'Aanpassen',
'preferences_cookies' => 'Voorkeurscookies',
'button_toggle_cookie' => ':service accepteren of weigeren',
'open_cookies' => 'Cookiebeheer paneel openen',
'button_forbid_all_title' => 'Alle cookies weigeren en het cookiepaneel sluiten',
'button_allow_all_title' => 'Alle cookies accepteren en het cookiepaneel sluiten',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Diensten voor publieksmeting maken het mogelijk om bezoekersstatistieken te genereren die nuttig zijn om de website te verbeteren.',
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Cookies geplaatst via videodiensten maken het mogelijk om multimedia-inhoud direct op de website weer te geven.',
'labelLink' => 'Meer informatie',
'warning' => 'U moet cookies accepteren om deze video te bekijken',
'accept' => 'Accepteren'
],
'youtube' => [
'title' => 'YouTube',
'description' => 'Cookies geplaatst via videodiensten maken het mogelijk om multimedia-inhoud direct op de website weer te geven.',
'labelLink' => 'Meer informatie',
'warning' => 'U moet cookies accepteren om deze video te bekijken',
'accept' => 'Accepteren'
],
'matomo' => [
'title' => 'Matomo',
'description' => 'Diensten voor publieksmeting worden gebruikt om verkeersstatistieken te genereren die nuttig zijn om de website te verbeteren.',
'labelLink' => 'Meer informatie',
],
'recaptcha' => [
'title' => 'reCAPTCHA',
'description' => 'De cookies van reCAPTCHA in formulieren worden gebruikt om inzendingen door robots te voorkomen.',
'labelLink' => 'Meer informatie',
'warning' => 'U moet reCAPTCHA-cookies accepteren om het formulier te verzenden',
'accept' => 'reCAPTCHA accepteren'
]
]
];

55
lang/pl/cookie-notice.php Normal file
View file

@ -0,0 +1,55 @@
<?php
return [
'button_banner_cookies' => 'Pliki cookie',
'banner_text' => 'Używamy plików cookie, aby zapewnić prawidłowe działanie naszej strony oraz do celów analitycznych.
Możesz zmienić swoją decyzję w dowolnym momencie, klikając ikonę na każdej stronie naszej witryny.
Wyrażając zgodę na te usługi stron trzecich, akceptujesz zapisywanie i odczytywanie plików cookie oraz wykorzystywanie technologii śledzenia niezbędnych do ich prawidłowego działania.',
'button_validate_and_close' => 'Potwierdź i zamknij',
'button_forbid_all' => 'Odrzuć wszystkie',
'button_allow_all' => 'Akceptuj wszystkie',
'consent_all_text' => 'Klikając <span class="font-medium">"Akceptuj wszystkie"</span>, wyrażasz zgodę na używanie naszych plików cookie.',
'banner_title' => 'Strona internetowa :site_name używa plików cookie!',
'learn_more' => 'Dowiedz się więcej',
'button_close_cookies' => 'Zamknij okno plików cookie',
'modal_title' => 'Okno zarządzania plikami cookie',
'panel_content' => 'Zawartość panelu zarządzania plikami cookie',
'button_customize' => 'Dostosuj',
'preferences_cookies' => 'Pliki cookie preferencji',
'button_toggle_cookie' => 'Akceptuj lub odrzuć :service',
'open_cookies' => 'Otwórz panel zarządzania plikami cookie',
'button_forbid_all_title' => 'Odrzuć wszystkie pliki cookie i zamknij panel',
'button_allow_all_title' => 'Akceptuj wszystkie pliki cookie i zamknij panel',
'custom' => [
'analytics' => [
'title' => 'Google Analytics',
'description' => 'Usługi pomiaru ruchu umożliwiają generowanie statystyk odwiedzin przydatnych do ulepszania strony.',
],
'vimeo' => [
'title' => 'Vimeo',
'description' => 'Pliki cookie ustawiane przez usługi wideo umożliwiają wyświetlanie treści multimedialnych bezpośrednio na stronie.',
'labelLink' => 'Dowiedz się więcej',
'warning' => 'Musisz zaakceptować pliki cookie, aby obejrzeć ten film',
'accept' => 'Akceptuj'
],
'youtube' => [
'title' => 'YouTube',
'description' => 'Pliki cookie ustawiane przez usługi wideo umożliwiają wyświetlanie treści multimedialnych bezpośrednio na stronie.',
'labelLink' => 'Dowiedz się więcej',
'warning' => 'Musisz zaakceptować pliki cookie, aby obejrzeć ten film',
'accept' => 'Akceptuj'
],
'matomo' => [
'title' => 'Matomo',
'description' => 'Usługi pomiaru ruchu służą do generowania statystyk odwiedzin, które pomagają ulepszać stronę.',
'labelLink' => 'Dowiedz się więcej',
],
'recaptcha' => [
'title' => 'reCAPTCHA',
'description' => 'Pliki cookie reCAPTCHA w formularzach służą do zapobiegania ich wysyłaniu przez boty.',
'labelLink' => 'Dowiedz się więcej',
'warning' => 'Musisz zaakceptować pliki cookie reCAPTCHA, aby wysłać formularz',
'accept' => 'Akceptuj reCAPTCHA'
]
]
];

2
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@inertiajs/core": "^2.1.5",
"@inertiajs/react": "^2.3.18",
"@ldc-web/cookie-notice": "^6.1.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.21.0",
@ -69,7 +70,6 @@
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",

View file

@ -3,7 +3,6 @@ import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { createRoot } from "react-dom/client";
import axios from "axios";
import CookieNotice from "@ldc-web/cookie-notice";
import recaptcha from "./hooks/use-recaptcha";
// @ts-expect-error Env is always present.
const appName = import.meta.env.VITE_APP_NAME || "Laravel";
@ -31,4 +30,3 @@ window.axios = axios;
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
window.CookieNotice = CookieNotice;
window.Recaptcha = recaptcha;

View file

@ -1,6 +1,6 @@
// * Packages
import { usePage } from "@inertiajs/react";
import { ReactNode } from "react";
import React, { ReactNode } from "react";
// * Components shadcn
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@ -11,50 +11,68 @@ import { cn } from "@/lib/utils";
type SimpleSwitchProps = {
name: string;
checked?: boolean;
description?: string | React.ReactNode;
label?: ReactNode | string;
leftHelper?: string;
rightHelper?: string;
className?: string;
inputClassName?: string;
required?: boolean;
disabled?: boolean;
parentErrors?: Record<string, string> | null;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: boolean) => void;
labelClassName?: string;
descClassName?: string;
title?: string;
};
export default function SimpleSwitch({
name,
checked,
description,
label,
leftHelper,
rightHelper,
className = "",
inputClassName= "",
required = false,
disabled = false,
parentErrors = null,
descClassName = "text-xs",
labelClassName = "text-sm",
title = name,
setData,
}: SimpleSwitchProps) {
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
return (
<div className={`flex flex-col items-start w-fit gap-1.5 ${className}`}>
{label && (
<Label htmlFor={name} className="flex items-center gap-1.5">
{label}
{required && " *"}
</Label>
)}
<div className={cn('flex flex-col items-start w-fit gap-1.5', className)}>
<div>
{label && (
<Label htmlFor={name} className={cn('flex items-center gap-1.5', labelClassName)}>
{label}
{required && " *"}
</Label>
)}
{description && (
<p className={cn('text-muted-foreground', descClassName)}>{description}</p>
)}
</div>
<div className="flex items-center gap-3">
{leftHelper && <small>{leftHelper}</small>}
<Switch
title={title}
id={name}
name={name}
onCheckedChange={(value) => setData(name, value)}
required={required}
checked={checked}
disabled={disabled}
className={cn({
className={cn(
inputClassName,
{
"border-destructive/50": localErrors[name],
})}
/>

View file

@ -1,31 +1,39 @@
// * REACT/INERTIA
import { Head as InertiaHead, usePage } from "@inertiajs/react";
import { AppEnv } from "@/types/global";
interface BreadcrumbItem {
title: string;
url: string;
}
type HeadProps = {
interface HeadProps {
title?: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
};
}
interface Ziggy {
location: string;
}
export default function Head({ title, description, breadcrumbs }: HeadProps) {
export default function Head({ title, description }: HeadProps) {
const appEnv = usePage().props.appEnv as AppEnv;
const { breadcrumbs } = usePage().props as {
breadcrumbs?: BreadcrumbItem[];
};
const { location } = usePage().props.ziggy as Ziggy;
const canonical = location.replace("index.php/", "").replace("index.php", "");
return (
<InertiaHead>
<title>{title}</title>
{title && <meta property="og:title" content={title} />}
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:url" content={appEnv.url} />
<meta property="og:site_name" content={appEnv.name} />
<meta property="og:image" content="" />
{description && <meta property="og:description" content={description} />}
{breadcrumbs && breadcrumbs?.length > 0 && (
{breadcrumbs && breadcrumbs.length > 0 && (
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",

View file

@ -1,18 +0,0 @@
import { cn } from "@/lib/utils";
export default function CookieNoticeLink({
className,
}: {
className?: string;
}) {
const handleCookieLink = () => {
const cookieButton = document.getElementById("cn-cookie-btn");
cookieButton?.click();
};
return (
<button type="button" onClick={handleCookieLink} className={cn(className)}>
Gestion des cookies
</button>
);
}

View file

@ -10,7 +10,7 @@ import { AppEnv } from "@/types/global";
import Logo from "./Logo";
import SocialNetworks from "./SocialNetworks";
import ActionSection from "$/uwil/ActionSection";
import CookieNoticeLink from "$/uwil/CookieNoticeLink";
import CookieNoticeLink from "@/front/Components/cookie/CookieNoticeLink";
export default function Footer() {
const { url } = usePage().props.appEnv as AppEnv;

View file

@ -1,10 +1,11 @@
import { videoProvider } from "@/front/lib/constants";
import VideoEmbedWithConsent from "$/base/VideoEmbedWithConsent";
import { videoCookieProvider } from "@/front/Components/cookie/constants";
import VideoEmbedWithConsent from "../cookie/video/VideoEmbedWithConsent";
export default function VideoBanner() {
return (
<VideoEmbedWithConsent
service={videoProvider.YOUTUBE}
service={videoCookieProvider.YOUTUBE}
title="Hero Banner"
embedUrl="https://www.youtube.com/embed/vAqG5-NYBMI?autoplay=1&mute=1&controls=0&loop=1&playlist=vAqG5-NYBMI"
/>

View file

@ -0,0 +1,470 @@
import { usePage } from "@inertiajs/react";
import { Cookie, X } from "lucide-react";
import * as React from "react";
import { Button, ButtonProps } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn, GetTranslation } from "@/lib/utils";
import {
useCookieNoticeHasPendingDecision,
useCookieNoticeIsOpen,
useCookieNoticeStatuses,
useCookieNoticeStore,
useCookieNoticeClose,
useCookieNoticeOpen
} from "@/store/use-cookie-notice-store";
import { animationClasses, animations, mappingBtnSize, mappingTextSize } from "./constants";
import type { CookieNoticeProps, CookieEnv, CookieName } from "./type";
import str from "@/hooks/use-string";
import { AppEnv } from "@/types/global";
import CookieSwitch from "./CookieSwitch";
/* ------------------------------------------------------------------ */
/* Composant */
/* ------------------------------------------------------------------ */
const CookieNotice = React.forwardRef<HTMLDivElement, CookieNoticeProps>(
(
{
services,
demo = false,
onAcceptCallback = () => { },
onDeclineCallback = () => { },
className,
learnMoreHref = "#",
animationBanner = animations.SCALE,
animationButton = animations.SCALE,
variants = {
btnActionVariant: "secondary",
bannerBtnVariant: "default",
closeBtnVariant: "ghost",
switchBg: "data-[state=checked]:bg-primary-foreground",
modalBg: "bg-card",
serviceBg: "bg-secondary"
},
texts = {
size: "normal"
},
...props
},
ref,
) => {
const [openCustomize, setOpenCustomize] = React.useState(false);
const statuses = useCookieNoticeStatuses();
const isOpen = useCookieNoticeIsOpen();
const hasPendingDecision = useCookieNoticeHasPendingDecision();
const open = useCookieNoticeOpen();
const close = useCookieNoticeClose();
const textSize = texts.size ?? "normal";
const { name, mode } = usePage().props.appEnv as AppEnv;
/** Libellés affichés dans le panneau de personnalisation, par service */
const COOKIE_LABELS: Record<
CookieName,
{ title: string; description: React.ReactNode }
> = {
recaptcha: {
title: str(GetTranslation('cookie-notice.custom.recaptcha.title')).ucFirst().value(),
description: (
<p className={cn(texts?.services?.description)}>
{str(GetTranslation('cookie-notice.custom.recaptcha.description')).ucFirst().value()}
<br />
<a target="_blank" href="https://policies.google.com/privacy" className={cn(texts?.services?.link, 'text-blue-500')}>{str(GetTranslation('cookie-notice.custom.recaptcha.labelLink')).ucFirst().value()}</a>
</p>
),
},
youtube: {
title: str(GetTranslation('cookie-notice.custom.youtube.title')).ucFirst().value(),
description: (
<p className={cn(texts?.services?.description)}>
{str(GetTranslation('cookie-notice.custom.youtube.description')).ucFirst().value()}
<br />
<a target="_blank" href="https://policies.google.com/technologies/cookies?hl=fr&utm_source=ucb" className={cn('text-blue-500', texts?.services?.link)}>{str(GetTranslation('cookie-notice.custom.youtube.labelLink')).ucFirst().value()}</a>
</p>
),
},
vimeo: {
title: str(GetTranslation('cookie-notice.custom.vimeo.title')).ucFirst().value(),
description: (
<p className={cn(texts?.services?.description)}>
{str(GetTranslation('cookie-notice.custom.vimeo.description')).ucFirst().value()}
<br />
<a target="_blank" href="https://vimeo.com/cookie_policy" className={cn(texts?.services?.link)}>{str(GetTranslation('cookie-notice.custom.vimeo.labelLink')).ucFirst().value()}</a>
</p>
),
},
matomo: {
title: str(GetTranslation('cookie-notice.custom.matomo.title')).ucFirst().value(),
description: (
<p className={cn(texts?.services?.description)}>
{str(GetTranslation('cookie-notice.custom.matomo.description')).ucFirst().value()}
<br />
<a target="_blank" href="https://fr.matomo.org/gdpr-analytics/" className={cn(texts?.services?.link)}>{str(GetTranslation('cookie-notice.custom.matomo.labelLink')).ucFirst().value()}</a>
</p>
),
},
analytics: {
title: str(GetTranslation('cookie-notice.custom.analytics.title')).ucFirst().value(),
description: (
<p className={cn(texts?.services?.description)}>
{str(GetTranslation('cookie-notice.custom.analytics.description')).ucFirst().value()}
<br />
<a target="_blank" href="#" className={cn(texts?.services?.link)}>{str(GetTranslation('cookie-notice.custom.analytics.labelLink')).ucFirst().value()}</a>
</p>
),
},
};
const cookieEnv = usePage().props.cookieEnv as CookieEnv & {
matomoUrl?: string | null;
matomoSiteId?: string | null;
gaMeasurementId?: string | null;
};
// Third-party identifiers: the modal reacts outside of React (store outside the tree),
// so we push values from `usePage()` into it instead of reading them directly there.
React.useEffect(() => {
useCookieNoticeStore.getState().setCredentials({
recaptchaKey: cookieEnv?.recaptchaKey ?? null,
matomoUrl: cookieEnv?.matomoUrl ?? "//yl.s-web.fr/",
matomoSiteId: cookieEnv?.matomoSiteId ?? null,
gaMeasurementId: cookieEnv?.gaMeasurementId ?? null,
});
}, [cookieEnv]);
React.useEffect(() => {
useCookieNoticeStore.getState().init(services, mode);
}, [services]);
// Locks page scrolling and disables interactions until the user has made a choice
// (cn-init is missing or expired).
React.useEffect(() => {
if (isOpen && hasPendingDecision) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen, hasPendingDecision]);
const cookiesData: {
name: CookieName;
title: string;
description: React.ReactNode;
}[] = services.map((name) => ({
name,
...COOKIE_LABELS[name],
}));
const handleAccept = React.useCallback(() => {
useCookieNoticeStore.getState().acceptAll();
onAcceptCallback();
}, [onAcceptCallback]);
const handleDecline = React.useCallback(() => {
useCookieNoticeStore.getState().declineAll();
onDeclineCallback();
}, [onDeclineCallback]);
const handleCustomize = () => {
if (openCustomize) {
// Services still pending (not interacted with) are rejected by default.
const { services: storeServices, statuses: storeStatuses } =
useCookieNoticeStore.getState();
const stillWaiting = storeServices.filter(
(name) => storeStatuses[name] === "wait",
);
if (stillWaiting.length > 0) {
useCookieNoticeStore.getState().setStatuses(
Object.fromEntries(stillWaiting.map((name) => [name, "false"]))
);
}
close();
} else {
setOpenCustomize(true);
}
};
/** Reopens the banner (e.g. when clicking the floating button) */
const reopenBanner = () => {
open();
setOpenCustomize(false);
};
React.useEffect(() => {
if (demo) {
open();
}
}, [demo, open]);
const containerClasses = cn(
"fixed z-50 transition-all duration-700",
isOpen ? animationClasses.open.scale : animationClasses.close.scale,
);
const commonWrapperProps = {
ref,
className: cn(
containerClasses,
"bottom-0 left-0 right-0 sm:left-4 sm:bottom-4 w-full sm:max-w-xl",
className,
),
...props,
};
const backdrop = (
<div
className={cn("fixed inset-0 z-40 bg-black/60 transition-all duration-700", isOpen
? animationClasses.open.fade
: animationClasses.close.fade, isOpen ? "z-40" : "z-[-10]")}
aria-hidden="true"
onClick={hasPendingDecision ? undefined : close}
/>
);
return (
<>
{backdrop}
<Button
variant={variants.bannerBtnVariant}
onClick={reopenBanner}
size={mappingBtnSize[texts.size as keyof typeof mappingBtnSize].icon as ButtonProps['size']}
aria-label={str(GetTranslation('cookie-notice.open_cookies')).ucFirst().value()}
title={str(GetTranslation('cookie-notice.open_cookies')).ucFirst().value()}
className={cn(
"fixed bottom-4 left-4 z-50 py-0.5 px-4 text-sm w-fit rounded-full shadow-lg transition-all duration-700",
!isOpen
? animationClasses.open[animationButton]
: animationClasses.close[animationButton],
)}
>
<span>{str(GetTranslation('cookie-notice.button_banner_cookies')).ucFirst().value()}</span>
<Cookie className="h-5 w-5" />
</Button>
{/* Bandeau */}
<div
{...(!isOpen ? { inert: true } : {})}
{...commonWrapperProps}
className={cn(
commonWrapperProps.className,
"transition-all duration-700 scale-100",
isOpen
? animationClasses.open[animationBanner]
: animationClasses.close[animationBanner],
)}
>
<Card className={cn('m-3 shadow-lg', variants.modalBg)}>
<CardHeader
title={str(GetTranslation('cookie-notice.modal_title')).ucFirst().value()}
className="flex flex-row items-center justify-between space-y-0 pb-2 relative">
<CardTitle
className={cn(mappingTextSize[texts.size as keyof typeof mappingTextSize].lg, texts.modal?.title)}>
{str(GetTranslation('cookie-notice.banner_title', { params: { site_name: name } })).ucFirst().value()}
</CardTitle>
<Cookie className="h-5 w-5 mr-5" />
{!hasPendingDecision && (
<Button
onClick={() => close()}
className="absolute top-0 right-0"
size={mappingBtnSize[texts.size as keyof typeof mappingBtnSize].sm as ButtonProps['size']}
variant={variants.closeBtnVariant}
title={str(GetTranslation('cookie-notice.button_close_cookies')).ucFirst().value()}
>
<X />
</Button>
)}
</CardHeader>
<CardContent
title={str(
GetTranslation("uwil-common::cookie-notice.panel_content"),
)
.ucFirst()
.value()}
className="relative min-h-[120px] h-fit overflow-hidden"
>
{/* Main view */}
<div
className={cn(
"space-y-2 transition-all duration-300",
openCustomize
? "opacity-0 pointer-events-none absolute inset-0 translate-y-full"
: "opacity-100 translate-y-0",
)}
>
<CardDescription
className={cn(
mappingTextSize[textSize].sm,
texts.modal?.description,
)}
>
{str(GetTranslation("uwil-common::cookie-notice.banner_text"))
.ucFirst()
.value()}
</CardDescription>
<div
className={cn(
"text-muted-foreground",
mappingTextSize[textSize].xs,
texts.modal?.description,
)}
>
<p
dangerouslySetInnerHTML={{
__html: str(
GetTranslation(
"uwil-common::cookie-notice.consent_all_text",
),
)
.ucFirst()
.value(),
}}
/>
</div>
<a
href={learnMoreHref}
target="_blank"
className={cn(
"text-blue-500 underline underline-offset-4 hover:no-underline",
mappingTextSize[textSize].xs,
texts.modal?.link,
)}
rel="noreferrer"
>
{str(GetTranslation("uwil-common::cookie-notice.learn_more"))
.ucFirst()
.value()}
</a>
</div>
{/* Customize view */}
<div
{...(!openCustomize ? { inert: true } : {})}
className={cn(
"space-y-2 transition-all duration-300",
openCustomize
? "opacity-100 translate-y-0"
: "opacity-0 pointer-events-none absolute inset-0 translate-y-full",
)}
>
<p className={cn("font-medium", mappingTextSize[textSize].sm)}>
{str(
GetTranslation(
"uwil-common::cookie-notice.preferences_cookies",
),
)
.ucFirst()
.value()}
</p>
<div className="space-y-2">
{cookiesData.map((cookie) => (
<CookieSwitch
title={str(
GetTranslation(
"uwil-common::cookie-notice.button_toggle_cookie",
{
params: { service: cookie.title },
},
),
)
.ucFirst()
.value()}
key={cookie.name}
className={cn(
"flex items-center justify-between w-full flex-row p-4 border border-primary rounded-md bg-primary",
variants.serviceBg,
)}
inputClassName={variants.switchBg}
labelClassName={cn(
mappingTextSize[textSize].sm,
texts.services?.title,
)}
descClassName={mappingTextSize[textSize].xs}
name={cookie.name}
checked={statuses[cookie.name] === "true"}
disabled={!openCustomize}
setData={(_, value) => {
useCookieNoticeStore
.getState()
.setStatus(cookie.name, value ? "true" : "false");
}}
label={cookie.title}
description={cookie.description}
/>
))}
</div>
</div>
</CardContent>
<CardFooter className="block space-y-3 md:space-y-0 md:flex md:gap-2 pt-2">
<Button
onClick={handleDecline}
size={mappingBtnSize[texts.size as keyof typeof mappingBtnSize].md as ButtonProps['size']}
variant={variants.btnActionVariant}
className="w-full md:flex-1"
aria-label={str(GetTranslation('cookie-notice.button_forbid_all_title')).ucFirst().value()}
title={str(GetTranslation('cookie-notice.button_forbid_all_title')).ucFirst().value()}
>
{str(GetTranslation('cookie-notice.button_forbid_all')).ucFirst().value()}
</Button>
<Button onClick={handleAccept}
size={mappingBtnSize[texts.size as keyof typeof mappingBtnSize].md as ButtonProps['size']}
variant={variants.btnActionVariant}
className="w-full md:flex-1"
title={str(GetTranslation('cookie-notice.button_allow_all_title')).ucFirst().value()}
aria-label={str(GetTranslation('cookie-notice.button_allow_all_title')).ucFirst().value()}
>
{str(GetTranslation('cookie-notice.button_allow_all')).ucFirst().value()}
</Button>
<Button
onClick={handleCustomize}
variant={variants.btnActionVariant}
size={mappingBtnSize[texts.size as keyof typeof mappingBtnSize].md as ButtonProps['size']}
className="w-full md:flex-1"
title={
openCustomize ? str(GetTranslation('cookie-notice.button_validate_and_close')).ucFirst().value() :
str(GetTranslation('cookie-notice.button_customize')).ucFirst().value()
}
aria-label={
openCustomize ? str(GetTranslation('cookie-notice.button_validate_and_close')).ucFirst().value() :
str(GetTranslation('cookie-notice.button_customize')).ucFirst().value()
}
>
{
openCustomize ? str(GetTranslation('cookie-notice.button_validate_and_close')).ucFirst().value() :
str(GetTranslation('cookie-notice.button_customize')).ucFirst().value()
}
</Button>
</CardFooter>
</Card>
</div>
</>
);
},
);
CookieNotice.displayName = "CookieNotice";
export { CookieNotice };
export default CookieNotice;

View file

@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
import { useCookieNoticeOpen } from "@/store/use-cookie-notice-store";
export default function CookieNoticeLink({
className,
label = "Gestion des cookies"
}: {
className?: string;
label?: string;
}) {
const open = useCookieNoticeOpen();
return (
<button type="button" onClick={open} className={cn(className)}>
{label}
</button>
);
}

View file

@ -0,0 +1,78 @@
// * Packages
import { usePage } from "@inertiajs/react";
import React, { ReactNode } from "react";
// * Components shadcn
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
// * Other
import InputError from "@/back/Components/InputError";
import { cn } from "@/lib/utils";
type CookieSwitchProps = {
name: string;
checked?: boolean;
description?: string | React.ReactNode;
label?: ReactNode | string;
className?: string;
inputClassName?: string;
required?: boolean;
disabled?: boolean;
parentErrors?: Record<string, string> | null;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: boolean) => void;
labelClassName?: string;
descClassName?: string;
title?: string;
};
export default function CookieSwitch({
name,
checked,
description,
label,
className = "",
inputClassName= "",
required = false,
disabled = false,
parentErrors = null,
descClassName = "text-xs",
labelClassName = "text-sm",
title = name,
setData,
}: CookieSwitchProps) {
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
return (
<div className={cn('flex flex-col items-start w-fit gap-1.5', className)}>
<div>
{label && (
<Label htmlFor={name} className={cn('flex items-center gap-1.5', labelClassName)}>
{label}
{required && " *"}
</Label>
)}
{description && (
<p className={cn('text-muted-foreground', descClassName)}>{description}</p>
)}
</div>
<div className="flex items-center gap-3">
<Switch
title={title}
id={name}
name={name}
onCheckedChange={(value) => setData(name, value)}
required={required}
checked={checked}
disabled={disabled}
className={cn(
inputClassName,
{
"border-destructive/50": localErrors[name],
})}
/>
</div>
<InputError message={localErrors[name]} />
</div>
);
}

View file

@ -0,0 +1,97 @@
import { AnimationClasses, CookieName } from "./type";
export const animations = {
FADE: "fade",
SCALE: "scale",
TRANSLATEX: "translateX",
TRANSLATEY: "translateY",
} as const;
export const animationDuration = {
SHORT: "duration-300",
NORMAL: "duration-500",
LONG: "duration-700",
EXTRALONG: "duration-900"
}
export const COOKIE_NAMES: CookieName[] = [
"recaptcha",
"youtube",
"vimeo",
"matomo",
"analytics",
];
export const videoCookieProvider = {
YOUTUBE: "youtube",
VIMEO: "vimeo",
} as const;
export const cnServices = {
...videoCookieProvider,
RECAPTCHA: "recaptcha",
MATOMO: "matomo",
ANALYTICS: "analytics"
} as const;
/** Nom du cookie unique regroupant tous les consentements */
export const CN_INIT_COOKIE = "cn-init";
/** Clé sous laquelle la date d'expiration est stockée dans le cookie cn-init */
export const CN_EXPIRATION_KEY = "_expiration";
/** Durée de validité du consentement (CNIL : 6 mois max recommandé, 13 mois maxi) */
export const CONSENT_DURATION_DAYS = 180;
export const animationClasses: {
open: AnimationClasses;
close: AnimationClasses;
} = {
open: {
scale: 'opacity-100 scale-100',
translateY: 'translate-y-0 opacity-100',
translateX: 'translate-x-0 opacity-100',
fade: 'opacity-100',
},
close: {
scale: 'opacity-0 scale-75 pointer-events-none',
translateY: 'translate-y-full opacity-0 pointer-events-none',
translateX: '-translate-x-full opacity-0 pointer-events-none',
fade: 'opacity-0'
}
}
export const mappingTextSize = {
large: {
lg: "text-xl",
sm: "text-md",
xs: "text-sm"
},
normal: {
lg: "text-lg",
sm: "text-sm",
xs: "text-xs"
},
small: {
lg: "text-md",
sm: "text-xs",
xs: "text-xs"
},
};
export const mappingBtnSize = {
large: {
icon: "sm",
sm: "default",
md: "lg"
},
normal: {
icon: "icon",
sm: "sm",
md: "default"
},
small: {
icon: "icon",
sm: "icon",
md: "sm"
},
}

View file

@ -0,0 +1,405 @@
import { CN_EXPIRATION_KEY, CN_INIT_COOKIE, CONSENT_DURATION_DAYS } from "./constants";
import type { CookieName, CookieStatusMap } from "./type";
/* ------------------------------------------------------------------ */
/* Utilitaires cookies */
/* ------------------------------------------------------------------ */
/**
* Read a cookie
* @param name string
* @returns string | null
*/
export const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`));
return match ? match.split("=").slice(1).join("=") : null;
};
/**
* Write a cookie
* @param name string
* @param value string
* @param days number
* @returns void
*/
export const setCookie = (name: string, value: string, days: number) => {
if (typeof document === "undefined") return;
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`;
};
/**
* Revoke and delete cookie by his name
* @returns void
*/
export const deleteCookie = (name: string) => {
if (typeof document === "undefined") return;
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
};
/**
* Decpde cn-init cookie
* @param raw
* @returns Record<string, string>
*/
export const parseCnInit = (raw: string | null): Record<string, string> => {
if (!raw) return {};
return Object.fromEntries(
raw
.split(",")
.filter(Boolean)
.map((pair) => pair.split("=") as [string, string]),
);
};
/**
* Encode statuses and expiration for cn-init cookie format
* @param services CookieName[]
* @param statuses CookieStatusMap
* @param expiresAt Date
* @returns string
*/
export const serializeCnInit = (
services: CookieName[],
statuses: CookieStatusMap,
expiresAt: Date,
): string =>
[
...services.map((name) => `${name}=${statuses[name]}`),
`${CN_EXPIRATION_KEY}=${expiresAt.getTime()}`,
].join(",");
/**
* Persist current statuses in cn-init cookie
* @param services CookieName[]
* @param statuses CookieStatusMap
* @returns void
*/
export const persistCnInit = (
services: CookieName[],
statuses: CookieStatusMap,
) => {
const expiresAt = new Date(
Date.now() + CONSENT_DURATION_DAYS * 24 * 60 * 60 * 1000,
);
setCookie(
CN_INIT_COOKIE,
serializeCnInit(services, statuses, expiresAt),
CONSENT_DURATION_DAYS,
);
};
/**
* If a service is in waiting status
* @param services
* @param statuses
* @returns boolean
*/
export const hasPendingDecision = (
services: CookieName[],
statuses: CookieStatusMap,
): boolean => services.some((name) => statuses[name] === "wait");
/* ------------------------------------------------------------------ */
/* Dynamic loading of scripts */
/* ------------------------------------------------------------------ */
/**
* Inject a <script> into the DOM if not already present (indempotent)
* @param id string
* @param src string
* @param attrs Record<string, string>
* @returns Promise<void>
*/
export const loadScript = (
id: string,
src: string,
attrs: Record<string, string> = {},
): Promise<void> =>
new Promise((resolve, reject) => {
if (document.getElementById(id)) {
resolve(); // déjà chargé
return;
}
const script = document.createElement("script");
script.id = id;
script.src = src;
script.async = true;
Object.entries(attrs).forEach(([key, value]) =>
script.setAttribute(key, value),
);
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Échec du chargement : ${src}`));
document.head.appendChild(script);
});
/**
* Delete a <script> from the DOM (don't cancel executed code)
* @param id string
* @returns void
*/
export const unloadScript = (id: string) => {
document.getElementById(id)?.remove();
};
/* ------------------------------------------------------------------ */
/* reCAPTCHA v2 */
/* ------------------------------------------------------------------ */
declare global {
interface Window {
grecaptcha?: {
ready: (callback: () => void) => void;
render: (
container: Element,
params: { sitekey: string },
) => number;
};
}
}
const RECAPTCHA_V2_SCRIPT_ID = "script-recaptcha-v2";
const RECAPTCHA_CONTAINER_SELECTOR = ".r_captcha";
/**
* Load Recaptcha V2 API, and display widget in every RECAPTCHA_CONTAINER_SELECTOR container of the page, activating the associate form
* @param sitekey string
* @returns Promise<void>
*/
export const renderRecaptchaWidgets = (sitekey: string): Promise<void> =>
loadScript(
RECAPTCHA_V2_SCRIPT_ID,
"https://www.google.com/recaptcha/api.js?render=explicit",
).then(
() =>
new Promise<void>((resolve) => {
window.grecaptcha?.ready(() => {
document
.querySelectorAll<HTMLElement>(RECAPTCHA_CONTAINER_SELECTOR)
.forEach((container) => {
if (container.querySelector(":scope > div")) return; // already displayed
const widget = document.createElement("div");
container.appendChild(widget);
window.grecaptcha?.render(widget, { sitekey });
container.closest("form")?.removeAttribute("inert");
});
resolve();
});
}),
);
/**
* Delete the recaptcha v2 widgets and inert the associate forms
* @param mode string
* @returns void
*/
export const resetRecaptchaWidgets = (mode?: string) => {
document
.querySelectorAll<HTMLElement>(RECAPTCHA_CONTAINER_SELECTOR)
.forEach((container) => {
container.innerHTML = "";
if (mode !== "testing") {
container.closest("form")?.setAttribute("inert", "");
}
});
};
/**
* Notify the app that a consent has changed (not used actually but available if needed)
* @param name CookieName
* @param accepted boolean
* @returns void
*/
export const notifyConsentChange = (name: CookieName, accepted: boolean) => {
window.dispatchEvent(
new CustomEvent("cookie-notice-changed", {
detail: { name, accepted },
}),
);
};
/* ------------------------------------------------------------------ */
/* Service-specific side effects */
/* ------------------------------------------------------------------ */
/**
* Third-party identifiers required by the services (reCAPTCHA key, Matomo URL/ID, GA ID).
* The store is a module outside the React tree and therefore cannot call `usePage()`.
* These values must be read from a component (e.g. `usePage().props.appEnv`)
* and then passed to the store via
* `useCookieNoticeStore.getState().setCredentials(...)`.
*/
export type CookieServiceCredentials = {
recaptchaKey: string | null;
matomoUrl: string | null;
matomoSiteId: string | null;
gaMeasurementId: string | null;
};
export const EMPTY_CREDENTIALS: CookieServiceCredentials = {
recaptchaKey: null,
matomoUrl: null,
matomoSiteId: null,
gaMeasurementId: null,
};
/**
* Creates side-effect handlers using the provided credentials
* @param credentials CookieServiceCredentials
* @param mode string|undefined
* @returns Record<CookieName, (accepted: boolean) => void>
*/
export const createServiceHandlers = (
credentials: CookieServiceCredentials,
mode?: string
): Record<CookieName, (accepted: boolean) => void> => ({
recaptcha: (accepted) => {
// Invisible reCAPTCHA v3 (RecaptchaForm/useGoogleReCaptcha) is handled by
// ConsentProvider + GoogleReCaptchaProvider, which already mounts/unmounts
// its own script based on the same consent status — we do not duplicate that logic here.
//
// This code only manages explicit reCAPTCHA v2 widgets
// rendered inside `.r_captcha` containers.
if (accepted) {
if (!credentials.recaptchaKey) {
console.error(
"[CookieNotice] recaptchaKey manquant dans les credentials du store — reCAPTCHA non chargé",
);
return;
}
renderRecaptchaWidgets(credentials.recaptchaKey).catch(console.error);
} else {
resetRecaptchaWidgets(mode);
unloadScript("script-recaptcha-v2");
deleteCookie("_GRECAPTCHA");
// The reCAPTCHA badge stays in the DOM and is not removed until the page is reloaded.
document.querySelector(".grecaptcha-badge")?.parentElement?.remove();
delete (window as any).grecaptcha;
}
notifyConsentChange("recaptcha", accepted);
},
matomo: (accepted) => {
if (!accepted) {
// No "requireConsent" is configured for Matomo in the original script
// (cn-cookies.blade.php): declining consent simply removes the script,
// without calling the Matomo consent API.
unloadScript("script-matomo");
notifyConsentChange("matomo", accepted);
return;
}
if (!credentials.matomoUrl || !credentials.matomoSiteId) {
console.error(
"[CookieNotice] matomoUrl/matomoSiteId manquant dans les credentials du store — Matomo non chargé",
);
return;
}
const w = window as any;
w._paq = w._paq || [];
w._paq.push(["trackPageView"]);
w._paq.push(["enableLinkTracking"]);
w._paq.push(["setTrackerUrl", `${credentials.matomoUrl}matomo.php`]);
w._paq.push(["setSiteId", credentials.matomoSiteId]);
loadScript("script-matomo", `${credentials.matomoUrl}matomo.js`).catch(
console.error,
);
notifyConsentChange("matomo", accepted);
},
analytics: (accepted) => {
const w = window as any;
if (accepted) {
if (!credentials.gaMeasurementId) {
console.error(
"[CookieNotice] gaMeasurementId manquant dans les credentials du store — Google Analytics non chargé",
);
return;
}
w[`ga-disable-${credentials.gaMeasurementId}`] = false;
loadScript(
"script-ga",
`https://www.googletagmanager.com/gtag/js?id=${credentials.gaMeasurementId}`,
)
.then(() => {
w.dataLayer = w.dataLayer || [];
w.gtag = function gtag(...args: unknown[]) {
w.dataLayer.push(args);
};
w.gtag("js", new Date());
w.gtag("config", credentials.gaMeasurementId);
})
.catch(console.error);
} else if (credentials.gaMeasurementId) {
// Official Google flag that disables tracking, even when the script is loaded.
w[`ga-disable-${credentials.gaMeasurementId}`] = true;
unloadScript("script-ga");
// gtag.js tracker cookies (see cn-cookies.blade.php): _gat_gtag_<id with "-" replaced by "_">
const gatCookie = `_gat_gtag_${credentials.gaMeasurementId.replace(/-/g, "_")}`;
["_ga", "_gid", gatCookie].forEach(deleteCookie);
}
notifyConsentChange("analytics", accepted);
},
// YouTube / Vimeo: no global script is loaded. Consent controls whether
// embedded <iframe> elements are rendered (facade pattern).
youtube: (accepted) => {
notifyConsentChange("youtube", accepted);
},
vimeo: (accepted) => {
notifyConsentChange("vimeo", accepted);
},
});
/**
* Applies side effects (script loading) for already-determined consent states,
* without persisting or reloading — used on mount to re-initialize scripts
* (each page load starts with a fresh <head>, even if the cookie already says "true").
*
* @param services CookieName[]
* @param statuses CookieStatusMap
* @param credentials CookieServiceCredentials
* @param mode string
* @returns void
*/
export const applyDecidedStatuses = (
services: CookieName[],
statuses: CookieStatusMap,
credentials: CookieServiceCredentials,
mode: string
) => {
const serviceHandlers = createServiceHandlers(credentials, mode);
services.forEach((name) => {
if (statuses[name] === "wait") return;
serviceHandlers[name](statuses[name] === "true");
});
};
/**
* Persists consent and applies side effects; reloads the page when consent is revoked.
* @param services CookieName[]
* @param nextStatuses CookieStatusMap
* @param credentials CookieServiceCredentials
* @param mode string
* @returns void
*/
export const persistAndApply = (
services: CookieName[],
nextStatuses: CookieStatusMap,
credentials: CookieServiceCredentials,
mode?: string
) => {
persistCnInit(services, nextStatuses);
const serviceHandlers = createServiceHandlers(credentials, mode);
services.forEach((name) => {
if (nextStatuses[name] === "wait") return;
serviceHandlers[name](nextStatuses[name] === "true");
});
};

View file

@ -6,10 +6,14 @@ import { InfoIcon } from "lucide-react";
/** SHADCN */
import { Button } from "@/components/ui/button";
/** BASE */
import { useRecaptcha } from "@/front/providers/RecaptchaProvider";
import { useRecaptcha } from "@/front/Components/cookie/recaptcha/RecaptchaProvider";
import InputError from "$/base/InputError";
import useTrans from "@/hooks/use-translations";
import { cn } from "@/lib/utils";
import { CookieEnv } from "../type";
import useCookieNotice from "@/hooks/use-cookie-notice";
import { cnServices } from "../constants";
import { AppEnv } from "@/types/global";
interface CustomData {
"g-recaptcha-response"?: string;
@ -42,10 +46,10 @@ function RecaptchaNotice({ banner }: { banner?: BannerClassName }) {
const warning = useTrans("cookie-notice.custom.recaptcha.warning");
const label = useTrans("cookie-notice.custom.recaptcha.accept");
const handleAccept = () => {
// @ts-expect-error External library
window.WebCookieNotice?.acceptCookie("recaptcha");
};
const { accept } = useCookieNotice(cnServices.RECAPTCHA);
if (enabled) return null;
@ -67,7 +71,7 @@ function RecaptchaNotice({ banner }: { banner?: BannerClassName }) {
className={cn(banner?.classNames?.btnClassName)}
id="recaptcha-accept"
type="button"
onClick={handleAccept}
onClick={accept}
>
{label}
</Button>
@ -86,13 +90,19 @@ export default function RecaptchaForm<TFormData>({
}: Props<TFormData>) {
const { getToken, formRef, enabled } = useRecaptcha();
const { errors } = usePage().props;
const recaptchaKey = usePage().props.recaptchaKey as string;
const {recaptchaKey, recaptchaBypassKey} = usePage().props.cookieEnv as CookieEnv;
const { mode } = usePage().props.appEnv as AppEnv;
const isTestingEnv = mode === 'testing';
const formIsEnabled = isTestingEnv || enabled;
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!enabled) return;
if (!formIsEnabled) return;
const token = getToken();
// eslint-disable-next-line no-param-reassign
data["g-recaptcha-response"] = token;
data["g-recaptcha-response"] = isTestingEnv ? recaptchaBypassKey : token;
onSubmit(e);
};
@ -104,10 +114,10 @@ export default function RecaptchaForm<TFormData>({
ref={formRef}
onSubmit={handleSubmit}
data-recaptcha-key={recaptchaKey}
inert={!enabled}
{...(!formIsEnabled ? { inert: true } : {})}
>
{children}
{enabled && (
{formIsEnabled && (
<>
<InputError message={errors["g-recaptcha-response"]} />
<div className="r_captcha flex justify-center mt-8" />

View file

@ -2,13 +2,11 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import InitRecaptcha from "@/hooks/use-recaptcha";
import useCookieNotice from "@/hooks/use-cookie-notice";
import { cnServices } from "../lib/constants";
import { cnServices } from "../constants";
type ContextType = {
enabled: boolean;
@ -22,12 +20,6 @@ export function RecaptchaProvider({ children }: { children: React.ReactNode }) {
const formRef = useRef<HTMLFormElement>(null);
const { status } = useCookieNotice(cnServices.RECAPTCHA);
useEffect(() => {
if (status && formRef.current) {
InitRecaptcha([formRef.current]);
}
}, [status]);
const getToken = useCallback(() => {
// @ts-expect-error grecaptcha global
return window.grecaptcha?.getResponse() || "";
@ -44,7 +36,7 @@ export function RecaptchaProvider({ children }: { children: React.ReactNode }) {
return (
<RecaptchaContext.Provider value={value}>
<div>{children}</div>
{children}
</RecaptchaContext.Provider>
);
}

View file

@ -0,0 +1,81 @@
import { ButtonProps } from "@/components/ui/button";
import { animations, cnServices, videoCookieProvider } from "./constants";
export type ConsentUIState =
| "banner"
| "customize"
| "minimized";
export type VideoCookieName =
(typeof videoCookieProvider)[keyof typeof videoCookieProvider];
export type AnimationsName =
(typeof animations)[keyof typeof animations];
export type CnServiceName = (typeof cnServices)[keyof typeof cnServices];
export type CookieName = VideoCookieName | CnServiceName;
// Define prop types
export interface CookieNoticeProps extends React.HTMLAttributes<HTMLDivElement> {
/** Services activés pour ce site (chaque site déclare ceux qu'il utilise réellement) */
services: CookieName[];
/** Use the modal has demo state, openning in every loading page */
demo?: boolean;
/** Callback when clik on Accept all */
onAcceptCallback?: () => void;
/** Callback when click on Refuse all */
onDeclineCallback?: () => void;
/** Link for the link "En savoir plus" */
learnMoreHref?: string;
/** Choose a cookie button animation */
animationButton?: AnimationsName;
/** Animation on modal entrance/exit */
animationBanner?: AnimationsName;
/** Different variation for the buttons or bgs */
variants?: {
btnActionVariant?: ButtonProps["variant"];
closeBtnVariant?: ButtonProps["variant"];
bannerBtnVariant?: ButtonProps["variant"];
serviceBg?: string;
switchBg?: string;
modalBg?: string;
}
/** Options for differents texts */
texts?: {
services?: {
title?: string;
description?: string;
link?: string;
}
modal?: {
title?: string;
description?: string;
link?: string;
}
size?: "small" | "normal" | "large"
}
}
export type AnimationClasses = Record<AnimationsName, string>;
export type CookiesState = Record<CookieName, boolean>;
/** Statut d'un service : `wait` tant que l'utilisateur n'a pas tranché */
export type CookieStatus = "true" | "false" | "wait";
export type CookieStatusMap = Record<CookieName, CookieStatus>;
export type ServiceHandlers = Record<
CookieName,
(accepted: boolean) => void
>;
export type CookieEnv = {
recaptchaKey?: string;
matomoUrl?: string;
matomoSiteId?: number;
gaMeasurementId?: number;
recaptchaBypassKey: string;
}

View file

@ -1,10 +1,11 @@
import { Loader } from "lucide-react";
import { cn, GetTranslation } from "@/lib/utils";
import { VideoProviderName } from "@/front/lib/constants";
import useCookieNotice from "@/hooks/use-cookie-notice";
import { VideoCookieName } from "../type";
type Props = {
service: VideoProviderName;
service: VideoCookieName;
embedUrl: string;
title: string;
className?: string;
@ -17,7 +18,7 @@ function NoStatusComponent({
accept,
}: {
loading: boolean;
service: VideoProviderName;
service: VideoCookieName;
accept: () => void;
}) {
const warning = GetTranslation(`cookie-notice.custom.${service}.warning`);
@ -50,7 +51,7 @@ export default function VideoEmbedWithConsent({
const { status, loading, accept } = useCookieNotice(service);
return (
<div className={`video-inlaid text-center ${className}`}>
<div className={` text-center ${className}`}>
{!status ? (
<NoStatusComponent
loading={loading}

View file

@ -5,8 +5,11 @@ import Footer from "$/uwil/Footer";
import GoToTop from "$/uwil/GoToTop";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
import { RecaptchaProvider } from "../providers/RecaptchaProvider";
import Head from "@/front/Components/Base/Head";
import { RecaptchaProvider } from "../Components/cookie/recaptcha/RecaptchaProvider";
import CookieNotice from "../Components/cookie/CookieNotice";
import { animations } from "@/front/Components/cookie/constants";
import { useCookieNoticeIsOpen } from "@/store/use-cookie-notice-store";
interface BreadcrumbItem {
title: string;
@ -17,21 +20,54 @@ interface AppLayoutProps {
children: ReactNode;
}
export default function AppLayout({ children }: AppLayoutProps) {
const { breadcrumbs } = usePage().props as {
breadcrumbs?: BreadcrumbItem[];
};
const SITE_COOKIE_SERVICES = ["matomo", "youtube", "recaptcha"] as const;
return (
<div className="relative">
<Head breadcrumbs={breadcrumbs} />
<Header />
<Toaster />
<RecaptchaProvider>
<TooltipProvider>{children}</TooltipProvider>
<CookieNotice
onAcceptCallback={() => { }}
onDeclineCallback={() => { }}
services={[...SITE_COOKIE_SERVICES]}
animationButton={animations.TRANSLATEX}
animationBanner={animations.TRANSLATEX}
variants={{
btnActionVariant: "default",
closeBtnVariant: "ghost",
bannerBtnVariant: "default",
serviceBg: "bg-secondary border-secondary",
switchBg: "data-[state=checked]:bg-primary",
modalBg: "bg-card"
}}
texts={{
size: "normal",
services: {
// description: "text-red-600",
link: "text-red-500",
// title: "text-yellow-500"
},
modal: {
// description: "text-red-600",
// link: "text-blue-500",
// title: "text-yellow-500"
}
}}
/>
<main>
<Header />
<Toaster />
<TooltipProvider>{children}</TooltipProvider>
<GoToTop />
<Footer />
</main>
</RecaptchaProvider>
<GoToTop />
<Footer />
</div>
);
}

View file

@ -5,7 +5,7 @@ import { FormEvent } from "react";
import SimpleString from "$/base/Inputs/SimpleString";
import SimpleTextarea from "$/base/Inputs/SimpleTextarea";
import SimpleCheckbox from "$/base/Inputs/SimpleCheckbox";
import RecaptchaForm from "$/base/RecaptchaForm";
import RecaptchaForm from "@/front/Components/cookie/recaptcha/RecaptchaForm";
import EnumSelect from "$/base/Inputs/EnumSelect";
// * THEME
import Section from "$/uwil/Section";
@ -37,26 +37,26 @@ export default function ContactForm() {
const submit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// console.log(data);
// post(route("front.contact.send"), {
// preserveScroll: true,
// onSuccess: () => {
// toast({
// title: "Mail envoyé",
// variant: "default",
// description: "Votre message a bien été envoyé. Merci !",
// });
// reset();
// },
// onError: (e) => {
// console.log(e, errors);
// toast({
// title: "Erreur",
// variant: "destructive",
// description: "Une erreur est survenue. Veuillez réessayer.",
// });
// },
// });
console.log(data);
post(route("front.contact.send"), {
preserveScroll: true,
onSuccess: () => {
toast({
title: "Mail envoyé",
variant: "default",
description: "Votre message a bien été envoyé. Merci !",
});
reset();
},
onError: (e) => {
console.log(e, errors);
toast({
title: "Erreur",
variant: "destructive",
description: "Une erreur est survenue. Veuillez réessayer.",
});
}
});
};
return (

View file

@ -1,25 +1,13 @@
import { CnServiceName } from "@/front/lib/constants";
import { useEffect, useState } from "react";
import type { CookieName } from "@/front/Components/cookie/type";
import { useCookieNoticeStatuses, useCookieNoticeStore } from "@/store/use-cookie-notice-store";
export default function useCookieNotice(service: CnServiceName) {
const [status, setStatus] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const interval = setInterval(() => {
const currentStatus =
window.WebCookieNotice?.getCnCookieStatus(service) === "true";
setStatus((prev) => (prev !== currentStatus ? currentStatus : prev));
setLoading(false);
}, 500);
return () => clearInterval(interval);
}, [service]);
export default function useCookieNotice(service: CookieName) {
const statuses = useCookieNoticeStatuses();
const status = statuses[service] === "true";
const loading = statuses[service] === undefined;
const accept = () => {
// @ts-expect-error external lib
window.WebCookieNotice?.acceptCookie(service);
setStatus(true);
useCookieNoticeStore.getState().setStatus(service, "true");
};
return { status, loading, accept };

View file

@ -1,72 +0,0 @@
declare global {
interface Window {
__CN_Recaptcha?: () => void;
__CN_Recaptcha_Reset?: () => void;
__CN_Recaptcha_Loaded?: () => void;
grecaptcha?: unknown;
WebCookieNotice?: {
// eslint-disable-next-line no-unused-vars
getCnCookieStatus: (key: string) => string;
};
}
}
export default function InitRecaptcha(forms: HTMLFormElement[]) {
if (!forms.length) return;
// Setup reCAPTCHA loader
// eslint-disable-next-line no-underscore-dangle
window.__CN_Recaptcha = () => {
forms.forEach((form) => {
if (!form.querySelector(".r_captcha > div")) {
const newDiv = document.createElement("div");
newDiv.classList.add("g-recaptcha");
form.querySelector(".r_captcha")?.appendChild(newDiv);
}
// eslint-disable-next-line no-param-reassign
form.inert = true;
});
// Inject reCAPTCHA script
const script = document.createElement("script");
const { lang } = document.documentElement;
script.src = `https://www.google.com/recaptcha/api.js?render=explicit&onload=__CN_Recaptcha_Loaded&hl=${lang}`;
script.async = true;
document.head.appendChild(script);
};
// eslint-disable-next-line no-underscore-dangle
window.__CN_Recaptcha_Reset = () => {
forms.forEach((form) => {
form.querySelector(".r_captcha > div")?.remove();
// eslint-disable-next-line no-param-reassign
form.inert = true;
});
};
// eslint-disable-next-line no-underscore-dangle
window.__CN_Recaptcha_Loaded = () => {
forms.forEach((form) => {
// eslint-disable-next-line no-param-reassign
form.inert = false;
const target = form.querySelector(".r_captcha > div");
if (target) {
// @ts-expect-error Render exist on grecaptcha.
window.grecaptcha?.render(target, {
sitekey: form.dataset.recaptchaKey ?? "",
});
}
});
};
// Set recaptcha when cookie doesn't do it.
forms.forEach((form) => {
if (
window.WebCookieNotice?.getCnCookieStatus("recaptcha") === "true" &&
!form.querySelector(".r_captcha > div")
) {
// @ts-expect-error AcceptCookie exist on WebCookieNotice.
window.WebCookieNotice?.acceptCookie("recaptcha");
}
});
}

View file

@ -0,0 +1,140 @@
import { create } from "zustand";
import {
applyDecidedStatuses,
EMPTY_CREDENTIALS,
getCookie,
hasPendingDecision,
parseCnInit,
persistAndApply,
persistCnInit,
} from "@/front/Components/cookie/lib";
import type { CookieServiceCredentials } from "@/front/Components/cookie/lib";
import type {
CookieName,
CookieStatus,
CookieStatusMap,
} from "@/front/Components/cookie/type";
import { CN_EXPIRATION_KEY, CN_INIT_COOKIE } from "@/front/Components/cookie/constants";
/* ------------------------------------------------------------------ */
/* Store */
/* ------------------------------------------------------------------ */
interface CookieNoticeStore {
/** Services activés pour ce site */
services: CookieName[];
/** Statut courant de chaque service activé */
statuses: CookieStatusMap;
/** Identifiants tiers (clé reCAPTCHA, URL/ID Matomo, ID GA), poussés depuis un composant React via usePage() */
credentials: CookieServiceCredentials;
/** La modale de cookies est ouverte */
isOpen: boolean;
/** Hydrate le store depuis le cookie cn-init pour la liste de services donnée.
* Si cn-init est absent, expiré, ou ne couvre pas exactement ces services,
* tous les statuts repassent à `wait` et la modale s'ouvre. */
init: (services: CookieName[], mode: string) => void;
/** À appeler depuis un composant ayant accès à usePage() pour fournir les credentials tiers */
setCredentials: (credentials: CookieServiceCredentials) => void;
/** Change le statut d'un seul service (utilisable depuis n'importe où sur le site) */
setStatus: (name: CookieName, status: CookieStatus) => void;
/** Change plusieurs statuts à la fois (ex: validation du panneau de personnalisation) */
setStatuses: (statuses: Partial<CookieStatusMap>) => void;
acceptAll: () => void;
declineAll: () => void;
open: () => void;
close: () => void;
}
export const useCookieNoticeStore = create<CookieNoticeStore>(
(set, get) => ({
services: [],
statuses: {} as CookieStatusMap,
credentials: EMPTY_CREDENTIALS,
isOpen: false,
setCredentials: (credentials) => set({ credentials }),
init: (services, mode) => {
const parsed = parseCnInit(getCookie(CN_INIT_COOKIE));
const expiration = Number.parseInt(parsed[CN_EXPIRATION_KEY] ?? "", 10);
const isExpired = Number.isNaN(expiration) || expiration < Date.now();
const isFullyDecided = services.every(
(name) => parsed[name] === "true" || parsed[name] === "false",
);
if (isExpired || !isFullyDecided) {
const statuses = Object.fromEntries(
services.map((name) => [name, "wait"]),
) as CookieStatusMap;
persistCnInit(services, statuses);
set({ services, statuses, isOpen: true });
return;
}
const statuses = Object.fromEntries(
services.map((name) => [name, parsed[name] as CookieStatus]),
) as CookieStatusMap;
set({ services, statuses, isOpen: false });
applyDecidedStatuses(services, statuses, get().credentials, mode);
},
setStatus: (name, status) => {
const { services, statuses: previousStatuses, credentials } = get();
const statuses = { ...previousStatuses, [name]: status };
set({ statuses });
persistAndApply(services, statuses, credentials);
},
setStatuses: (partial) => {
const { services, statuses: previousStatuses, credentials } = get();
const statuses = { ...previousStatuses, ...partial } as CookieStatusMap;
set({ statuses });
persistAndApply(services, statuses, credentials);
},
acceptAll: () => {
const { services, credentials } = get();
const statuses = Object.fromEntries(
services.map((name) => [name, "true"]),
) as CookieStatusMap;
set({ statuses, isOpen: false });
persistAndApply(services, statuses, credentials);
},
declineAll: () => {
const { services, credentials } = get();
const statuses = Object.fromEntries(
services.map((name) => [name, "false"]),
) as CookieStatusMap;
set({ statuses, isOpen: false });
persistAndApply(services, statuses, credentials);
},
open: () => set({ isOpen: true }),
close: () => {
const { services, statuses } = get();
if (hasPendingDecision(services, statuses)) return;
set({ isOpen: false });
},
}),
);
/* ------------------------------------------------------------------ */
/* Sélecteurs */
/* ------------------------------------------------------------------ */
export const useCookieNoticeServices = () =>
useCookieNoticeStore((state) => state.services);
export const useCookieNoticeStatuses = () =>
useCookieNoticeStore((state) => state.statuses);
export const useCookieNoticeIsOpen = () =>
useCookieNoticeStore((state) => state.isOpen);
export const useCookieNoticeHasPendingDecision = () =>
useCookieNoticeStore((state) =>
hasPendingDecision(state.services, state.statuses),
);
export const useCookieNoticeOpen = () =>
useCookieNoticeStore((state) => state.open);
export const useCookieNoticeClose = () =>
useCookieNoticeStore((state) => state.close);

View file

@ -8,7 +8,6 @@
<title inertia>{{ config('app.name') }}</title>
<x-uwil-common::head.robots />
<x-uwil-common::head.canonical />
<x-uwil-common::head.hreflang />
<x-uwil-common::head.favicon mask-icon-color="#ff0000" tile-color="#ff0000" theme-color="#ff0000" />
@ -23,7 +22,7 @@
<body>
@inertia
@include('front.vendor.cookie-notice.cn-cookies')
{{-- @include('front.vendor.cookie-notice.cn-cookies') --}}
</body>
</html>

View file

@ -24,6 +24,8 @@ Route::get('/nous-contacter', function () {
return Inertia::render('front/Pages/Contact/ContactUs');
})->name('contact-us');
Route::post('/nous-contacter', [ContactController::class, 'send'])->name('contact.send');
Route::get('/rechercher', function () {
return Inertia::render('front/Pages/Search');
})->name('search');