Skip to content

Commit 01444ed

Browse files
lsagetlethiasclement-duportenguerranwsgarronejClementNumericite
authored
feat: ConsentBanner & GDPR (#107)
* fix createModal example (#106) * fix createModal example * Delete package-lock.json Signed-off-by: Enguerran Weiss <[email protected]> --------- Signed-off-by: Enguerran Weiss <[email protected]> Co-authored-by: Enguerran Weiss <[email protected]> * wip consent banner * add gdpr to export list * vanilla partition * feat: full footer with link list (#109) * Bump version Signed-off-by: Joseph Garrone <[email protected]> * feat: finish consent banner and gdpr store * doc * fix(footer): add missing key in footer link list (#110) * fix: add imageWidth & imageHeight properties on Tile (#108) The proposed change for the Tile component involves adding a feature to specify the width and height of the optional image. I added imageWidth & imageHeight optionnal properties to follow the imageUrl & imageAlt pattern. I'll make another suggestion : introduce an imageProps object, similar to the existing linkProps object, which would provide greater flexibility and control over the image properties. I am open to discussing this further. Signed-off-by: Clément Lelong <[email protected]> * Bump version Signed-off-by: Joseph Garrone <[email protected]> * feat: i18n * chore: story * self review * test other than appdir * fix gdpr folder export * chore: review * Release beta Signed-off-by: Joseph Garrone <[email protected]> * Mark ConsentBanner implementation as temporary --------- Signed-off-by: Enguerran Weiss <[email protected]> Signed-off-by: Joseph Garrone <[email protected]> Signed-off-by: Clément Lelong <[email protected]> Co-authored-by: clement-duport <[email protected]> Co-authored-by: Enguerran Weiss <[email protected]> Co-authored-by: Joseph Garrone <[email protected]> Co-authored-by: Clément Lelong <[email protected]> Co-authored-by: garronej <[email protected]>
1 parent f97ee60 commit 01444ed

34 files changed

+1048
-173
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codegouvfr/react-dsfr",
3-
"version": "0.48.4",
3+
"version": "0.49.0",
44
"description": "French State Design System React integration library",
55
"repository": {
66
"type": "git",
@@ -121,13 +121,15 @@
121121
"module": "dist/fr/index.js",
122122
"exports": {
123123
".": "./dist/fr/index.js",
124+
"./gdpr": "./dist/gdpr/index.js",
124125
"./spa": "./dist/spa.js",
125126
"./next-appdir": "./dist/next-appdir/index.js",
126127
"./next-appdir/DsfrHead": "./dist/next-appdir/DsfrHead.js",
127128
"./next-appdir/DsfrProvider": "./dist/next-appdir/DsfrProvider.js",
128129
"./next-appdir/getColorSchemeHtmlAttributes": "./dist/next-appdir/getColorSchemeHtmlAttributes.js",
129130
"./next-pagesdir": "./dist/next-pagesdir.js",
130131
"./useIsDark": "./dist/useIsDark/index.js",
132+
"./useGdprStore": "./dist/useGdprStore.js",
131133
"./useColors": "./dist/useColors.js",
132134
"./useBreakpointsValues": "./dist/useBreakpointsValues.js",
133135
"./mui": "./dist/mui.js",
@@ -160,6 +162,7 @@
160162
"./Footer": "./dist/Footer.js",
161163
"./Download": "./dist/Download.js",
162164
"./Display": "./dist/Display.js",
165+
"./ConsentBanner": "./dist/ConsentBanner/index.js",
163166
"./Checkbox": "./dist/Checkbox.js",
164167
"./Card": "./dist/Card.js",
165168
"./CallOut": "./dist/CallOut.js",

src/ButtonsGroup.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export namespace ButtonsGroupProps {
1919
alignment?: "left" | "center" | "right";
2020
/** Default: false */
2121
buttonsEquisized?: boolean;
22-
buttons: [ButtonProps, ButtonProps, ...ButtonProps[]];
22+
buttons: [ButtonProps, ...ButtonProps[]];
2323
style?: CSSProperties;
2424
};
2525

src/ConsentBanner/ConsentBanner.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React, { memo } from "react";
2+
3+
import { symToStr } from "tsafe/symToStr";
4+
import { createModal } from "../Modal";
5+
import { type ConsentBannerContentProps } from "./ConsentBannerContent";
6+
import { ConsentManager } from "./ConsentManager";
7+
import { ConsentBannerContentDisplayer } from "./ConsentBannerContentDisplayer";
8+
import { useTranslation } from "./i18n";
9+
10+
const { ConsentModal, consentModalButtonProps } = createModal({
11+
name: "Consent",
12+
isOpenedByDefault: false
13+
});
14+
15+
export { consentModalButtonProps };
16+
17+
export type ConsentBannerProps = Omit<ConsentBannerContentProps, "consentModalButtonProps">;
18+
19+
/** @see <https://react-dsfr-components.etalab.studio/?path=/docs/components-consentbanner> */
20+
// 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)
21+
export const ConsentBanner = memo((props: ConsentBannerProps) => {
22+
const { gdprLinkProps, services } = props;
23+
const { t } = useTranslation();
24+
25+
return (
26+
<>
27+
<ConsentModal title={t("consent modal title")} size="large">
28+
<ConsentManager
29+
gdprLinkProps={gdprLinkProps}
30+
services={services}
31+
consentModalButtonProps={consentModalButtonProps}
32+
/>
33+
</ConsentModal>
34+
<ConsentBannerContentDisplayer
35+
{...props}
36+
consentModalButtonProps={consentModalButtonProps}
37+
/>
38+
</>
39+
);
40+
});
41+
42+
ConsentBanner.displayName = symToStr({ ConsentBanner });
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client";
2+
3+
import { ButtonsGroup } from "../ButtonsGroup";
4+
import { fr } from "../fr";
5+
import React from "react";
6+
import { useGdprStore } from "../useGdprStore";
7+
import { type GdprService } from "../gdpr";
8+
import { type ModalProps } from "../Modal";
9+
import { useTranslation } from "./i18n";
10+
11+
export interface ConsentBannerActionsProps {
12+
services: GdprService[];
13+
consentModalButtonProps: ModalProps.ModalButtonProps;
14+
}
15+
16+
export function ConsentBannerActions({
17+
services,
18+
consentModalButtonProps
19+
}: ConsentBannerActionsProps) {
20+
const setConsent = useGdprStore(state => state.setConsent);
21+
const setFirstChoiceMade = useGdprStore(state => state.setFirstChoiceMade);
22+
23+
const { t } = useTranslation();
24+
25+
const acceptAll = () => {
26+
services.forEach(service => {
27+
if (!service.mandatory) setConsent(service.name, true);
28+
});
29+
setFirstChoiceMade();
30+
};
31+
32+
const refuseAll = () => {
33+
services.forEach(service => {
34+
if (!service.mandatory) setConsent(service.name, false);
35+
});
36+
setFirstChoiceMade();
37+
};
38+
return (
39+
<ButtonsGroup
40+
className={fr.cx("fr-consent-banner__buttons")}
41+
alignment="right"
42+
isReverseOrder
43+
inlineLayoutWhen="sm and up"
44+
buttons={[
45+
{
46+
children: t("accept all"),
47+
title: t("accept all - title"),
48+
onClick: () => acceptAll()
49+
},
50+
{
51+
children: t("refuse all"),
52+
title: t("refuse all - title"),
53+
onClick: () => refuseAll()
54+
},
55+
{
56+
children: t("customize cookies"),
57+
title: t("customize cookies - title"),
58+
priority: "secondary",
59+
...consentModalButtonProps
60+
}
61+
]}
62+
/>
63+
);
64+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from "react";
2+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
3+
import { type FooterProps } from "../Footer";
4+
import { fr } from "../fr";
5+
import { type RegisteredLinkProps } from "../link";
6+
import { ConsentBannerActions, type ConsentBannerActionsProps } from "./ConsentBannerActions";
7+
import { useTranslation } from "./i18n";
8+
9+
export interface ConsentBannerContentProps extends ConsentBannerActionsProps {
10+
/** Usually the same as {@link FooterProps.personalDataLinkProps} */
11+
gdprLinkProps: RegisteredLinkProps;
12+
/** Current website name */
13+
siteName: string;
14+
}
15+
16+
export function ConsentBannerContent({
17+
gdprLinkProps,
18+
siteName,
19+
services,
20+
consentModalButtonProps
21+
}: ConsentBannerContentProps) {
22+
const { t } = useTranslation();
23+
return (
24+
<div className={fr.cx("fr-consent-banner")}>
25+
<h2 className={fr.cx("fr-h6")}>{t("about cookies", { siteName })}</h2>
26+
<div className="fr-consent-banner__content">
27+
<p className="fr-text--sm">
28+
{t("welcome message", {
29+
gdprLinkProps
30+
})}
31+
</p>
32+
</div>
33+
<ConsentBannerActions
34+
services={services}
35+
consentModalButtonProps={consentModalButtonProps}
36+
/>
37+
</div>
38+
);
39+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"use client";
2+
3+
import React, { useState, useEffect } from "react";
4+
import { useGdprStore } from "../useGdprStore";
5+
import { ConsentBannerContent, type ConsentBannerContentProps } from "./ConsentBannerContent";
6+
7+
export function ConsentBannerContentDisplayer(props: ConsentBannerContentProps) {
8+
const firstChoiceMade = useGdprStore(state => state.firstChoiceMade);
9+
const __inited = useGdprStore(state => state.__inited);
10+
const [isFCM, setIsFCM] = useState(true);
11+
12+
useEffect(() => {
13+
if (!__inited) return;
14+
setIsFCM(firstChoiceMade);
15+
}, [firstChoiceMade, __inited]);
16+
17+
if (isFCM) return null;
18+
return <ConsentBannerContent {...props} />;
19+
}

src/ConsentBanner/ConsentManager.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"use client";
2+
3+
import React, { useState, useEffect } from "react";
4+
import { ButtonsGroup } from "../ButtonsGroup";
5+
import { fr } from "../fr";
6+
import { type GdprService } from "../gdpr";
7+
import { getLink } from "../link";
8+
import { partition } from "../tools/partition";
9+
import { useGdprStore } from "../useGdprStore";
10+
import { type ConsentBannerContentProps } from "./ConsentBannerContent";
11+
import { useTranslation } from "./i18n";
12+
13+
export type ConsentManagerProps = Required<Omit<ConsentBannerContentProps, "siteName">>;
14+
export function ConsentManager({
15+
gdprLinkProps,
16+
services,
17+
consentModalButtonProps
18+
}: ConsentManagerProps) {
19+
const { Link } = getLink();
20+
const setConsent = useGdprStore(state => state.setConsent);
21+
const setFirstChoiceMade = useGdprStore(state => state.setFirstChoiceMade);
22+
const consents = useGdprStore(state => state.consents);
23+
const [accepted, setAccepted] = useState<string[]>([]);
24+
25+
const { t } = useTranslation();
26+
27+
useEffect(() => {
28+
setAccepted([
29+
...Object.entries(consents)
30+
.filter(([, consent]) => consent)
31+
.map(([name]) => name)
32+
]);
33+
}, [consents]);
34+
35+
const accept = (service?: GdprService) => {
36+
if (service && !service.mandatory && !accepted.includes(service.name)) {
37+
return setAccepted([...accepted, service.name]);
38+
}
39+
40+
const filtered = services
41+
.filter(service => !service.mandatory)
42+
.map(service => service.name);
43+
setAccepted([...new Set([...filtered, ...accepted])]);
44+
};
45+
46+
const refuse = (service?: GdprService) => {
47+
if (service && !service.mandatory && accepted.includes(service.name))
48+
return setAccepted(accepted.filter(name => service.name !== name));
49+
50+
setAccepted([]);
51+
};
52+
53+
const confirm = () => {
54+
const [acceptedServices, refusedServices] = partition(services, service =>
55+
accepted.includes(service.name)
56+
);
57+
acceptedServices.forEach(service => setConsent(service.name, true));
58+
refusedServices.forEach(service => setConsent(service.name, false));
59+
setFirstChoiceMade();
60+
};
61+
62+
return (
63+
<div className="fr-consent-manager">
64+
<div className={fr.cx("fr-consent-service", "fr-consent-manager__header")}>
65+
<fieldset
66+
className={fr.cx("fr-fieldset", "fr-fieldset--inline")}
67+
aria-describedby="fr-consent-service__title"
68+
>
69+
<legend
70+
className={fr.cx("fr-consent-service__title")}
71+
id="fr-consent-service__title"
72+
>
73+
{t("all services pref")}
74+
<br />
75+
<Link {...gdprLinkProps}>{t("personal data cookies")}</Link>
76+
</legend>
77+
<div className={fr.cx("fr-consent-service__radios")}>
78+
<ButtonsGroup
79+
inlineLayoutWhen="always"
80+
alignment="right"
81+
buttons={[
82+
{
83+
onClick: () => accept(),
84+
title: t("accept all"),
85+
children: t("accept all")
86+
},
87+
{
88+
onClick: () => refuse(),
89+
title: t("refuse all"),
90+
children: t("refuse all"),
91+
priority: "secondary"
92+
}
93+
]}
94+
/>
95+
</div>
96+
</fieldset>
97+
</div>
98+
{services.map((service, index) => (
99+
<div className={fr.cx("fr-consent-service")} key={`consent-service-${index}`}>
100+
<fieldset className={fr.cx("fr-fieldset", "fr-fieldset--inline")}>
101+
<legend
102+
aria-describedby={`finality-${index}-desc`}
103+
className={fr.cx("fr-consent-service__title")}
104+
>
105+
{service.title}
106+
</legend>
107+
<div className={fr.cx("fr-consent-service__radios")}>
108+
<div className={fr.cx("fr-radio-group")}>
109+
<input
110+
type="radio"
111+
id={`consent-finality-${index}-accept`}
112+
name={`consent-finality-${index}`}
113+
{...(service.mandatory
114+
? { disabled: true, checked: true }
115+
: { checked: accepted.includes(service.name) })}
116+
readOnly
117+
onClick={() => accept(service)}
118+
/>
119+
<label
120+
htmlFor={`consent-finality-${index}-accept`}
121+
className={fr.cx("fr-label")}
122+
>
123+
{t("accept")}
124+
</label>
125+
</div>
126+
<div className={fr.cx("fr-radio-group")}>
127+
<input
128+
{...(service.mandatory
129+
? { disabled: true, checked: false }
130+
: { checked: !accepted.includes(service.name) })}
131+
type="radio"
132+
id={`consent-finality-${index}-refuse`}
133+
name={`consent-finality-${index}`}
134+
readOnly
135+
onClick={() => refuse(service)}
136+
/>
137+
<label
138+
htmlFor={`consent-finality-${index}-refuse`}
139+
className={fr.cx("fr-label")}
140+
>
141+
{t("refuse")}
142+
</label>
143+
</div>
144+
</div>
145+
<p
146+
id={`finality-${index}-desc`}
147+
className={fr.cx("fr-consent-service__desc")}
148+
>
149+
{service.description}
150+
</p>
151+
</fieldset>
152+
</div>
153+
))}
154+
<ButtonsGroup
155+
className={fr.cx("fr-consent-manager__buttons")}
156+
alignment="right"
157+
inlineLayoutWhen="sm and up"
158+
buttons={[
159+
{
160+
...consentModalButtonProps,
161+
children: t("confirm choices"),
162+
title: t("confirm choices"),
163+
onClick: () => confirm()
164+
}
165+
]}
166+
/>
167+
</div>
168+
);
169+
}

0 commit comments

Comments
 (0)