ldc-ker-chant/resources/ts/front/Components/cookie/CookieNotice.tsx

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;