741 lines
23 KiB
TypeScript
741 lines
23 KiB
TypeScript
import { usePage } from "@inertiajs/react";
|
|
import { Cookie, X } from "lucide-react";
|
|
import { motion } from "motion/react";
|
|
import { forwardRef, useCallback, useEffect, useState } from "react";
|
|
|
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import Logo from "@/front/Components/Layout/Logo";
|
|
import type {
|
|
CookieNoticeProps,
|
|
CookieEnv,
|
|
CookieName,
|
|
} from "@/front/types/cookie";
|
|
import str from "@/hooks/use-string";
|
|
import { cn, GetTranslation } from "@/lib/utils";
|
|
import {
|
|
useCookieNoticeHasPendingDecision,
|
|
useCookieNoticeIsOpen,
|
|
useCookieNoticeStatuses,
|
|
useCookieNoticeStore,
|
|
useCookieNoticeClose,
|
|
useCookieNoticeOpen,
|
|
} from "@/store/use-cookie-notice-store";
|
|
|
|
import {
|
|
animationClasses,
|
|
animationDuration,
|
|
animations,
|
|
mappingBtnSize,
|
|
mappingTextSize,
|
|
positionBannerButton,
|
|
} from "./constants";
|
|
import CookieSwitch from "./CookieSwitch";
|
|
|
|
const CookieNotice = forwardRef<HTMLDivElement, CookieNoticeProps>(
|
|
(
|
|
{
|
|
services,
|
|
demo = false,
|
|
hasLogo = false,
|
|
onAcceptCallback,
|
|
onDeclineCallback,
|
|
className,
|
|
learnMoreHref = "#",
|
|
animationsDuration = animationDuration.LONG,
|
|
animationBanner = animations.SCALE,
|
|
animationButton = animations.SCALE,
|
|
bannerButtonPosition = positionBannerButton.bottom.left,
|
|
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] = useState(false);
|
|
|
|
const statuses = useCookieNoticeStatuses();
|
|
const isOpen = useCookieNoticeIsOpen();
|
|
const hasPendingDecision = useCookieNoticeHasPendingDecision();
|
|
const open = useCookieNoticeOpen();
|
|
const close = useCookieNoticeClose();
|
|
|
|
const { name, mode } = usePage().props.appEnv as AppEnv;
|
|
|
|
const textSize = texts.size ?? "normal";
|
|
|
|
/** 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("uwil-common::cookie-notice.custom.recaptcha.title"),
|
|
)
|
|
.ucFirst()
|
|
.value(),
|
|
description: (
|
|
<span className={cn(texts.services?.description)}>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.recaptcha.description",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
<br />
|
|
<a
|
|
target="_blank"
|
|
href="https://policies.google.com/privacy"
|
|
className={cn("text-blue-500", texts.services?.link)}
|
|
rel="noreferrer noopener"
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.recaptcha.labelLink",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</span>
|
|
),
|
|
},
|
|
youtube: {
|
|
title: str(
|
|
GetTranslation("uwil-common::cookie-notice.custom.youtube.title"),
|
|
)
|
|
.ucFirst()
|
|
.value(),
|
|
description: (
|
|
<span className={cn(texts.services?.description)}>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::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)}
|
|
rel="noreferrer noopener"
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.youtube.labelLink",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</span>
|
|
),
|
|
},
|
|
vimeo: {
|
|
title: str(
|
|
GetTranslation("uwil-common::cookie-notice.custom.vimeo.title"),
|
|
)
|
|
.ucFirst()
|
|
.value(),
|
|
description: (
|
|
<span className={cn(texts.services?.description)}>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.vimeo.description",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
<br />
|
|
<a
|
|
target="_blank"
|
|
href="https://vimeo.com/cookie_policy"
|
|
className={cn("text-blue-500", texts.services?.link)}
|
|
rel="noreferrer noopener"
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.vimeo.labelLink",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</span>
|
|
),
|
|
},
|
|
matomo: {
|
|
title: str(
|
|
GetTranslation("uwil-common::cookie-notice.custom.matomo.title"),
|
|
)
|
|
.ucFirst()
|
|
.value(),
|
|
description: (
|
|
<span className={cn(texts.services?.description)}>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.matomo.description",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
<br />
|
|
<a
|
|
target="_blank"
|
|
href="https://fr.matomo.org/gdpr-analytics/"
|
|
className={cn("text-blue-500", texts.services?.link)}
|
|
rel="noreferrer noopener"
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.matomo.labelLink",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</span>
|
|
),
|
|
},
|
|
analytics: {
|
|
title: str(
|
|
GetTranslation("uwil-common::cookie-notice.custom.analytics.title"),
|
|
)
|
|
.ucFirst()
|
|
.value(),
|
|
description: (
|
|
<span className={cn(texts.services?.description)}>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.analytics.description",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
<br />
|
|
<a
|
|
target="_blank"
|
|
href="https://policies.google.com/privacy"
|
|
className={cn("text-blue-500", texts.services?.link)}
|
|
rel="noreferrer noopener"
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.custom.analytics.labelLink",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</span>
|
|
),
|
|
},
|
|
};
|
|
|
|
const cookieEnv = usePage().props.cookieEnv as CookieEnv;
|
|
|
|
// 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.
|
|
useEffect(() => {
|
|
useCookieNoticeStore.getState().setCredentials({
|
|
recaptchaKey: cookieEnv.recaptchaKey ?? null,
|
|
matomoUrl: cookieEnv.matomoUrl ?? "//yl.s-web.fr/",
|
|
matomoSiteId:
|
|
cookieEnv.matomoSiteId != null
|
|
? String(cookieEnv.matomoSiteId)
|
|
: null,
|
|
gaMeasurementId:
|
|
cookieEnv.gaMeasurementId != null
|
|
? String(cookieEnv.gaMeasurementId)
|
|
: null,
|
|
});
|
|
}, [cookieEnv]);
|
|
|
|
useEffect(() => {
|
|
useCookieNoticeStore.getState().init(services, mode);
|
|
}, [services, mode]);
|
|
|
|
// Locks page scrolling and disables interactions until the user has made a choice
|
|
// (cn-init is missing or expired).
|
|
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 = useCallback(() => {
|
|
useCookieNoticeStore.getState().acceptAll();
|
|
onAcceptCallback?.();
|
|
}, [onAcceptCallback]);
|
|
|
|
const handleDecline = 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);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (demo) {
|
|
open();
|
|
}
|
|
}, [demo, open]);
|
|
|
|
const containerClasses = cn(
|
|
"fixed z-600 transition-[opacity,scale,rotate,background-color]",
|
|
isOpen ? animationClasses.open.scale : animationClasses.close.scale,
|
|
animationsDuration,
|
|
);
|
|
|
|
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,
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || hasPendingDecision) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
close();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [isOpen, hasPendingDecision, close]);
|
|
|
|
const backdrop = (
|
|
<div
|
|
className={cn(
|
|
"fixed inset-0 bg-black/60 transition-all",
|
|
animationsDuration,
|
|
isOpen ? "z-40 opacity-100" : "z-[-1] opacity-0",
|
|
)}
|
|
aria-hidden="true"
|
|
onClick={hasPendingDecision ? undefined : close}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{backdrop}
|
|
<Button
|
|
variant={variants.bannerBtnVariant}
|
|
onClick={reopenBanner}
|
|
size={mappingBtnSize[textSize].icon as ButtonProps["size"]}
|
|
aria-label={str(
|
|
GetTranslation("uwil-common::cookie-notice.open_cookies"),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
title={str(GetTranslation("uwil-common::cookie-notice.open_cookies"))
|
|
.ucFirst()
|
|
.value()}
|
|
className={cn(
|
|
"text-sm z-300 shadow-lg fixed transition-[opacity,scale,rotate,background-color]",
|
|
bannerButtonPosition,
|
|
!isOpen
|
|
? animationClasses.open[animationButton]
|
|
: animationClasses.close[animationButton],
|
|
animationsDuration,
|
|
)}
|
|
>
|
|
<span>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::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 scale-100",
|
|
isOpen
|
|
? animationClasses.open[animationBanner]
|
|
: animationClasses.close[animationBanner],
|
|
animationsDuration,
|
|
)}
|
|
>
|
|
<Card className={cn("m-3 shadow-lg border-0", variants.modalBg)}>
|
|
<motion.div
|
|
className="rounded-lg"
|
|
layout
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<CardHeader
|
|
title={str(
|
|
GetTranslation("uwil-common::cookie-notice.modal_title"),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
className="flex flex-row items-end gap-4 space-y-0 pb-2 relative"
|
|
>
|
|
{hasLogo && <Logo />}
|
|
<CardTitle
|
|
className={cn(
|
|
mappingTextSize[textSize].lg,
|
|
texts.modal?.title,
|
|
)}
|
|
>
|
|
{str(
|
|
GetTranslation("uwil-common::cookie-notice.banner_title", {
|
|
params: { site_name: name },
|
|
}),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</CardTitle>
|
|
{!hasPendingDecision && (
|
|
<Button
|
|
onClick={() => {
|
|
close();
|
|
}}
|
|
className="absolute top-0 right-0"
|
|
size={mappingBtnSize[textSize].sm as ButtonProps["size"]}
|
|
variant={variants.closeBtnVariant}
|
|
title={str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_close_cookies",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
>
|
|
<X />
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent
|
|
title={str(
|
|
GetTranslation("uwil-common::cookie-notice.panel_content"),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
>
|
|
{/* Main view */}
|
|
<div
|
|
{...(openCustomize ? { inert: true } : {})}
|
|
className={cn(
|
|
"space-y-2 transition-opacity duration-300",
|
|
openCustomize
|
|
? "opacity-0 h-0 overflow-hidden pointer-events-none"
|
|
: "opacity-100 h-fit",
|
|
)}
|
|
>
|
|
<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 noopener"
|
|
>
|
|
{str(
|
|
GetTranslation("uwil-common::cookie-notice.learn_more"),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</a>
|
|
</div>
|
|
|
|
{/* Customize view */}
|
|
<div
|
|
{...(!openCustomize ? { inert: true } : {})}
|
|
className={cn(
|
|
"space-y-2 transition-opacity duration-300",
|
|
openCustomize
|
|
? "opacity-100 h-50 overflow-y-auto"
|
|
: "opacity-0 pointer-events-none h-0",
|
|
)}
|
|
>
|
|
<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[textSize].md as ButtonProps["size"]}
|
|
variant={variants.btnActionVariant}
|
|
className="w-full md:flex-1"
|
|
aria-label={str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_forbid_all_title",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
title={str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_forbid_all_title",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_forbid_all",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleAccept}
|
|
size={mappingBtnSize[textSize].md as ButtonProps["size"]}
|
|
variant={variants.btnActionVariant}
|
|
className="w-full md:flex-1"
|
|
title={str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_allow_all_title",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
aria-label={str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_allow_all_title",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
>
|
|
{str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_allow_all",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={handleCustomize}
|
|
variant={variants.btnActionVariant}
|
|
size={mappingBtnSize[textSize].md as ButtonProps["size"]}
|
|
className="w-full md:flex-1"
|
|
title={
|
|
openCustomize
|
|
? str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_validate_and_close",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()
|
|
: str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_customize",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()
|
|
}
|
|
aria-label={
|
|
openCustomize
|
|
? str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_validate_and_close",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()
|
|
: str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_customize",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()
|
|
}
|
|
>
|
|
{openCustomize
|
|
? str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_validate_and_close",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()
|
|
: str(
|
|
GetTranslation(
|
|
"uwil-common::cookie-notice.button_customize",
|
|
),
|
|
)
|
|
.ucFirst()
|
|
.value()}
|
|
</Button>
|
|
</CardFooter>
|
|
</motion.div>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
},
|
|
);
|
|
|
|
CookieNotice.displayName = "CookieNotice";
|
|
export { CookieNotice };
|
|
export default CookieNotice;
|