feat: ✨ cookie notice, the end ?
This commit is contained in:
parent
f43cb28a7d
commit
3c9a9d3ae4
36 changed files with 1885 additions and 223 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -23,3 +23,5 @@ Homestead.json
|
|||
Homestead.yaml
|
||||
Thumbs.db
|
||||
.npmrc
|
||||
bootstrap/ssr
|
||||
public/favicon-back
|
||||
|
|
|
|||
28
Ssr-Dockerfile
Normal file
28
Ssr-Dockerfile
Normal 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
|
||||
83
app/Http/Controllers/Front/ContactController.php
Normal file
83
app/Http/Controllers/Front/ContactController.php
Normal 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)
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
];
|
||||
|
||||
|
|
|
|||
36
app/Http/Requests/Front/Contact/ContactRequest.php
Normal file
36
app/Http/Requests/Front/Contact/ContactRequest.php
Normal 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
19
config/cookie-notice.php
Normal 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')
|
||||
]
|
||||
];
|
||||
|
|
@ -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
55
lang/de/cookie-notice.php
Normal 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'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
@ -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
55
lang/es/cookie-notice.php
Normal 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'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
@ -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
55
lang/nl/cookie-notice.php
Normal 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
55
lang/pl/cookie-notice.php
Normal 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
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
470
resources/ts/front/Components/cookie/CookieNotice.tsx
Normal file
470
resources/ts/front/Components/cookie/CookieNotice.tsx
Normal 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;
|
||||
17
resources/ts/front/Components/cookie/CookieNoticeLink.tsx
Normal file
17
resources/ts/front/Components/cookie/CookieNoticeLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
resources/ts/front/Components/cookie/CookieSwitch.tsx
Normal file
78
resources/ts/front/Components/cookie/CookieSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
resources/ts/front/Components/cookie/constants.tsx
Normal file
97
resources/ts/front/Components/cookie/constants.tsx
Normal 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"
|
||||
},
|
||||
}
|
||||
405
resources/ts/front/Components/cookie/lib.tsx
Normal file
405
resources/ts/front/Components/cookie/lib.tsx
Normal 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");
|
||||
});
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
81
resources/ts/front/Components/cookie/type.ts
Normal file
81
resources/ts/front/Components/cookie/type.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
140
resources/ts/store/use-cookie-notice-store.ts
Normal file
140
resources/ts/store/use-cookie-notice-store.ts
Normal 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue