Skip to content

Commit b1f9960

Browse files
committed
feat: i18n
1 parent 0592b30 commit b1f9960

File tree

9 files changed

+144
-60
lines changed

9 files changed

+144
-60
lines changed

src/ConsentBanner/ConsentBannerActions.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React from "react";
66
import { useGdprStore } from "../useGdprStore";
77
import { GdprService } from "../gdpr";
88
import { ModalProps } from "../Modal";
9+
import { useTranslation } from "./i18n";
910

1011
export interface ConsentBannerActionsProps {
1112
services: GdprService[];
@@ -18,6 +19,9 @@ export const ConsentBannerActions = ({
1819
}: ConsentBannerActionsProps) => {
1920
const setConsent = useGdprStore(state => state.setConsent);
2021
const setFirstChoiceMade = useGdprStore(state => state.setFirstChoiceMade);
22+
23+
const { t } = useTranslation();
24+
2125
const acceptAll = () => {
2226
services.forEach(service => {
2327
if (!service.mandatory) setConsent(service.name, true);
@@ -39,18 +43,18 @@ export const ConsentBannerActions = ({
3943
inlineLayoutWhen="sm and up"
4044
buttons={[
4145
{
42-
children: "Tout accepter",
43-
title: "Autoriser tous les cookies",
46+
children: t("accept all"),
47+
title: t("accept all - title"),
4448
onClick: () => acceptAll()
4549
},
4650
{
47-
children: "Tout refuser",
48-
title: "Refuser tous les cookies",
51+
children: t("refuse all"),
52+
title: t("refuse all - title"),
4953
onClick: () => refuseAll()
5054
},
5155
{
52-
children: "Personnaliser",
53-
title: "Personnaliser les cookies",
56+
children: t("customize cookies"),
57+
title: t("customize cookies - title"),
5458
priority: "secondary",
5559
...consentModalButtonProps
5660
}

src/ConsentBanner/ConsentBannerContent.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import React from "react";
22
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
33
import { FooterProps } from "../Footer";
44
import { fr } from "../fr";
5-
import { getLink } from "../link";
65
import { ConsentBannerActions, ConsentBannerActionsProps } from "./ConsentBannerActions";
6+
import { useTranslation } from "./i18n";
77

88
export interface ConsentBannerContentProps extends ConsentBannerActionsProps {
99
/** Usually the same as {@link FooterProps.personalDataLinkProps} */
@@ -18,16 +18,15 @@ export const ConsentBannerContent = ({
1818
services,
1919
consentModalButtonProps
2020
}: ConsentBannerContentProps) => {
21-
const { Link } = getLink();
21+
const { t } = useTranslation();
2222
return (
2323
<div className={fr.cx("fr-consent-banner")}>
24-
<h2 className={fr.cx("fr-h6")}>À propos des cookies sur {siteName}</h2>
24+
<h2 className={fr.cx("fr-h6")}>{t("about cookies", { siteName })}</h2>
2525
<div className="fr-consent-banner__content">
2626
<p className="fr-text--sm">
27-
Bienvenue ! Nous utilisons des cookies pour améliorer votre expérience et les
28-
services disponibles sur ce site. Pour en savoir plus, visitez la page{" "}
29-
<Link href={gdprPageLink}>Données personnelles et cookies</Link>. Vous pouvez, à
30-
tout moment, avoir le contrôle sur les cookies que vous souhaitez activer.
27+
{t("welcome message", {
28+
gdprPageLink
29+
})}
3130
</p>
3231
</div>
3332
<ConsentBannerActions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use client";
2+
3+
import React, { useState, useEffect } from "react";
4+
import { useGdprStore } from "../useGdprStore";
5+
import { ConsentBannerContent, ConsentBannerContentProps } from "./ConsentBannerContent";
6+
7+
export const ConsentBannerContentDisplayer = (props: ConsentBannerContentProps) => {
8+
const firstChoiceMade = useGdprStore(state => state.firstChoiceMade);
9+
const __inited = useGdprStore(state => state.__inited);
10+
const [stateFCM, setStateFCM] = useState(true);
11+
12+
useEffect(() => {
13+
__inited && setStateFCM(firstChoiceMade);
14+
}, [firstChoiceMade, __inited]);
15+
16+
if (stateFCM) return null;
17+
return <ConsentBannerContent {...props} />;
18+
};

src/ConsentBanner/ConsentManager.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
"use client";
2+
13
import React, { useState, useEffect } from "react";
24
import ButtonsGroup from "../ButtonsGroup";
35
import { fr } from "../fr";
46
import { GdprService } from "../gdpr";
57
import { getLink } from "../link";
68
import { useGdprStore } from "../useGdprStore";
79
import { ConsentBannerContentProps } from "./ConsentBannerContent";
10+
import { useTranslation } from "./i18n";
811

912
const partition = <T,>(arr: T[], criteria: (item: T) => boolean): [T[], T[]] => [
1013
arr.filter(item => criteria(item)),
@@ -23,6 +26,8 @@ export const ConsentManager = ({
2326
const consents = useGdprStore(state => state.consents);
2427
const [accepted, setAccepted] = useState<string[]>([]);
2528

29+
const { t } = useTranslation();
30+
2631
useEffect(() => {
2732
setAccepted([
2833
...Object.entries(consents)
@@ -69,9 +74,9 @@ export const ConsentManager = ({
6974
className={fr.cx("fr-consent-service__title")}
7075
id="fr-consent-service__title"
7176
>
72-
Préférences pour tous les services.
77+
{t("all services pref")}
7378
<br />
74-
<Link href={gdprPageLink}>Données personnelles et cookies</Link>
79+
<Link href={gdprPageLink}>{t("personal data cookies")}</Link>
7580
</legend>
7681
<div className={fr.cx("fr-consent-service__radios")}>
7782
<ButtonsGroup
@@ -80,13 +85,13 @@ export const ConsentManager = ({
8085
buttons={[
8186
{
8287
onClick: () => accept(),
83-
title: "Tout accepter",
84-
children: "Tout accepter"
88+
title: t("accept all"),
89+
children: t("accept all")
8590
},
8691
{
8792
onClick: () => refuse(),
88-
title: "Tout refuser",
89-
children: "Tout refuser",
93+
title: t("refuse all"),
94+
children: t("refuse all"),
9095
priority: "secondary"
9196
}
9297
]}
@@ -119,7 +124,7 @@ export const ConsentManager = ({
119124
htmlFor={`consent-finality-${index}-accept`}
120125
className={fr.cx("fr-label")}
121126
>
122-
Accepter
127+
{t("accept")}
123128
</label>
124129
</div>
125130
<div className={fr.cx("fr-radio-group")}>
@@ -137,7 +142,7 @@ export const ConsentManager = ({
137142
htmlFor={`consent-finality-${index}-refuse`}
138143
className={fr.cx("fr-label")}
139144
>
140-
Refuser
145+
{t("refuse")}
141146
</label>
142147
</div>
143148
</div>
@@ -157,8 +162,8 @@ export const ConsentManager = ({
157162
buttons={[
158163
{
159164
...consentModalButtonProps,
160-
children: "Confirmer mes choix",
161-
title: "Confirmer mes choix",
165+
children: t("confirm choices"),
166+
title: t("confirm choices"),
162167
onClick: () => confirm()
163168
}
164169
]}

src/ConsentBanner/i18n.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createComponentI18nApi } from "../i18n";
2+
import React from "react";
3+
import { getLink } from "../link";
4+
5+
export const { useTranslation, addConsentBannerTranslations } = createComponentI18nApi({
6+
componentName: "ConsentBanner",
7+
frMessages: {
8+
"all services pref": "Préférences pour tous les services.",
9+
"personal data cookies": "Données personnelles et cookies",
10+
"accept all": "Tout accepter",
11+
"accept all - title": "Autoriser tous les cookies",
12+
"refuse all": "Tout refuser",
13+
"refuse all - title": "Refuser tous les cookies",
14+
"accept": "Accepter",
15+
"refuse": "Refuser",
16+
"confirm choices": "Confirmer mes choix",
17+
"about cookies": (p: { siteName: string }) => `À propos des cookies sur ${p.siteName}`,
18+
"welcome message": (p: { gdprPageLink: string }) => {
19+
const { Link } = getLink();
20+
return (
21+
<>
22+
Bienvenue ! Nous utilisons des cookies pour améliorer votre expérience et les
23+
services disponibles sur ce site. Pour en savoir plus, visitez la page{" "}
24+
<Link href={p.gdprPageLink}>Données personnelles et cookies</Link>. Vous pouvez,
25+
à tout moment, avoir le contrôle sur les cookies que vous souhaitez activer.
26+
</>
27+
);
28+
},
29+
"customize cookies": "Personnaliser",
30+
"customize cookies - title": "Personnaliser les cookies",
31+
"consent modal title": "Panneau de gestion des cookies"
32+
}
33+
});
34+
35+
addConsentBannerTranslations({
36+
lang: "en",
37+
messages: {
38+
"all services pref": "Preferences for all services.",
39+
"personal data cookies": "Personal data and cookies",
40+
"accept all": "Accept all",
41+
"accept all - title": "Accept all cookies",
42+
"refuse all": "Refuse all",
43+
"refuse all - title": "Refuse all cookies",
44+
"accept": "Accept",
45+
"refuse": "Refuse",
46+
"confirm choices": "Confirm my choices",
47+
"about cookies": (p: { siteName: string }) => `About cookies on ${p.siteName}`,
48+
"welcome message": (p: { gdprPageLink: string }) => {
49+
const { Link } = getLink();
50+
return (
51+
<>
52+
Welcome to our website! We use cookies to improve your experience and the
53+
services available services available on this site. To learn more, visit the{" "}
54+
<Link href={p.gdprPageLink}>"Personal Data and Cookies"</Link> page. You can, at
55+
any time, have control over which cookies you wish to enable at any time.
56+
</>
57+
);
58+
},
59+
"customize cookies": "Customize",
60+
"customize cookies - title": "Customize cookies",
61+
"consent modal title": "Cookie management panel"
62+
}
63+
});

src/ConsentBanner/index.tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
"use client";
2-
31
import React, { memo } from "react";
4-
import { useEffect, useState } from "react";
52

6-
import { useGdprStore } from "../useGdprStore";
73
import { symToStr } from "tsafe/symToStr";
84
import { createModal } from "../Modal";
9-
import { ConsentBannerContent, ConsentBannerContentProps } from "./ConsentBannerContent";
5+
import { ConsentBannerContentProps } from "./ConsentBannerContent";
106
import { ConsentManager } from "./ConsentManager";
7+
import { ConsentBannerContentDisplayer } from "./ConsentBannerContentDisplayer";
8+
import { useTranslation } from "./i18n";
119

1210
const { ConsentModal, consentModalButtonProps } = createModal({
1311
name: "Consent",
@@ -18,32 +16,24 @@ export { consentModalButtonProps };
1816

1917
export type ConsentBannerProps = Omit<ConsentBannerContentProps, "consentModalButtonProps">;
2018

19+
// TODO handle sub finalities (https://www.systeme-de-design.gouv.fr/uploads/Capture_d_ecran_2021_03_24_a_17_45_33_8ba8e1fabb_1_1dd3309589.png)
2120
export const ConsentBanner = memo((props: ConsentBannerProps) => {
2221
const { gdprPageLink, services } = props;
23-
24-
const firstChoiceMade = useGdprStore(state => state.firstChoiceMade);
25-
const __inited = useGdprStore(state => state.__inited);
26-
const [stateFCM, setStateFCM] = useState(true);
27-
28-
useEffect(() => {
29-
__inited && setStateFCM(firstChoiceMade);
30-
}, [firstChoiceMade, __inited]);
22+
const { t } = useTranslation();
3123

3224
return (
3325
<>
34-
<ConsentModal title="Panneau de gestion des cookies" size="large">
26+
<ConsentModal title={t("consent modal title")} size="large">
3527
<ConsentManager
3628
gdprPageLink={gdprPageLink}
3729
services={services}
3830
consentModalButtonProps={consentModalButtonProps}
3931
/>
4032
</ConsentModal>
41-
{!stateFCM && (
42-
<ConsentBannerContent
43-
{...props}
44-
consentModalButtonProps={consentModalButtonProps}
45-
/>
46-
)}
33+
<ConsentBannerContentDisplayer
34+
{...props}
35+
consentModalButtonProps={consentModalButtonProps}
36+
/>
4737
</>
4838
);
4939
});

src/Modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const Modal = memo(
8989
<div className={fr.cx("fr-modal__body")}>
9090
<div className={fr.cx("fr-modal__header")}>
9191
<button
92-
className={fr.cx("fr-link--close", "fr-link")}
92+
className={fr.cx("fr-btn--close", "fr-btn")}
9393
title={t("close")}
9494
aria-controls={id}
9595
>

src/i18n.tsx

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from "react";
1+
import { type ReactNode, useMemo } from "react";
22

33
function getLanguageBestApprox<Language extends string>(params: {
44
languages: readonly Language[];
@@ -31,29 +31,31 @@ function getLanguageBestApprox<Language extends string>(params: {
3131
return undefined;
3232
}
3333

34-
type FrMessagesToTranslationFunction<
35-
FrMessages extends Record<string, string | ((params: any) => string)>
36-
> = {
34+
type Message = NonNullable<ReactNode> | ((params: any) => NonNullable<ReactNode>);
35+
type Messages = Record<string, Message>;
36+
37+
type FrMessagesToTranslationFunction<FrMessages extends Messages> = {
3738
(messageKey: NonFunctionMessageKey<FrMessages>): string;
3839
<K extends FunctionMessageKey<FrMessages>>(
3940
messageKey: K,
4041
params: ExtractArgument<FrMessages[K]>
4142
): string;
4243
};
4344

44-
type ExtractArgument<Message extends string | ((params: any) => string)> = Message extends (
45+
type ExtractArgument<TMessage extends Message> = TMessage extends (
4546
params: any
46-
) => string
47-
? Parameters<Message>[0]
47+
) => Exclude<Message, string>
48+
? Parameters<TMessage>[0]
4849
: never;
4950

50-
type NonFunctionMessageKey<FrMessages extends Record<string, string | ((params: any) => string)>> =
51-
{
52-
[Key in keyof FrMessages]: FrMessages[Key] extends string ? Key : never;
53-
}[keyof FrMessages];
51+
type NonFunctionMessageKey<FrMessages extends Messages> = {
52+
[Key in keyof FrMessages]: FrMessages[Key] extends string ? Key : never;
53+
}[keyof FrMessages];
5454

55-
type FunctionMessageKey<FrMessages extends Record<string, string | ((params: any) => string)>> =
56-
Exclude<keyof FrMessages, NonFunctionMessageKey<FrMessages>>;
55+
type FunctionMessageKey<FrMessages extends Messages> = Exclude<
56+
keyof FrMessages,
57+
NonFunctionMessageKey<FrMessages>
58+
>;
5759

5860
let useLang: () => string = () => "fr";
5961

@@ -63,7 +65,7 @@ export function setUseLang(params: { useLang: () => string }) {
6365

6466
export function createComponentI18nApi<
6567
ComponentName extends string,
66-
FrMessages extends Record<string, string | ((params: any) => string)>
68+
FrMessages extends Messages
6769
>(params: {
6870
componentName: ComponentName;
6971
frMessages: FrMessages;
@@ -89,7 +91,7 @@ export function createComponentI18nApi<
8991
return bestApproxLang ?? "fr";
9092
}, [lang]);
9193

92-
function t(messageKey: keyof FrMessages, params?: any): string {
94+
function t(messageKey: keyof FrMessages, params?: any): ReactNode {
9395
const messageOrFn =
9496
(messagesByLang as any)[bestMatchLang][messageKey] ??
9597
(messagesByLang["fr"] as any)[messageKey];

test/integration/next-appdir/app/StartDsfr.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { startReactDsfr } from "@codegouvfr/react-dsfr/next-appdir";
44
import { defaultColorScheme } from "./defaultColorScheme";
55
import Link from "next/link";
66

7+
78
declare module "@codegouvfr/react-dsfr/next-appdir" {
89
interface RegisterLink {
910
Link: typeof Link;
@@ -12,7 +13,9 @@ declare module "@codegouvfr/react-dsfr/next-appdir" {
1213

1314
startReactDsfr({
1415
defaultColorScheme,
15-
Link
16+
Link,
17+
// Uncomment to test in english
18+
// useLang: () => "en",
1619
});
1720

1821
export default function StartDsfr(){

0 commit comments

Comments
 (0)