From 1c2508cf0fe8499795b985fe73789d8020f4fd0f Mon Sep 17 00:00:00 2001 From: Mounir Date: Tue, 6 Aug 2024 15:54:05 -0400 Subject: [PATCH] feat: cookie consent --- frontend/index.html | 2 +- frontend/package.json | 2 +- frontend/src/App.tsx | 14 +- .../app/components/accordion/Accordion.tsx | 24 +++ .../accordionSummary/AccordionSummary.tsx | 30 ++++ frontend/src/app/components/button/Button.tsx | 2 +- frontend/src/app/components/dialog/Dialog.tsx | 32 ++++ .../app/components/iconButton/IconButton.tsx | 5 + .../src/app/components/layout/layout.scss | 10 +- frontend/src/app/components/slide/Slide.tsx | 9 + frontend/src/app/components/switch/Switch.tsx | 39 +++++ frontend/src/app/components/table/Table.tsx | 30 ++++ .../src/app/components/tableRow/TableRow.tsx | 28 +++ .../app/components/typography/Typography.tsx | 107 +++++++----- .../app/components/typography/typography.scss | 6 +- .../cookieConsent/CookieConsent.tsx | 61 +++++++ .../cookieBanner/CookieBanner.tsx | 74 ++++++++ .../cookieBanner/cookie-banner.scss | 73 ++++++++ .../cookieConsent/cookieConsent.config.ts | 101 +++++++++++ .../cookieConsent/cookieConsentHelper.ts | 20 +++ .../cookieConsent/cookieModal/CookieModal.tsx | 164 ++++++++++++++++++ .../cookieModal/cookie-modal.scss | 17 ++ .../cookieConsent/interfaces/ICookieInfo.ts | 5 + .../interfaces/ICookiePreferences.ts | 4 + .../interfaces/ICookieSection.ts | 9 + .../containers/debugBanner/DebugBanner.tsx | 6 +- frontend/src/app/icons/CaretIcon.tsx | 24 +++ frontend/src/app/icons/CloseIcon.tsx | 27 +++ frontend/src/app/icons/CookieIcon.tsx | 47 +++++ frontend/src/app/services/auth/authService.ts | 11 +- .../src/app/services/users/userService.ts | 10 +- frontend/src/assets/locales/en.json | 25 +++ frontend/src/assets/locales/fr.json | 25 +++ frontend/src/main.tsx | 6 +- frontend/src/sheet2i18n.config.cjs | 2 + frontend/src/styles/_colors.scss | 7 + frontend/src/styles/_export.scss | 4 +- frontend/src/styles/_variables.scss | 2 + frontend/yarn.lock | 15 +- 39 files changed, 999 insertions(+), 80 deletions(-) create mode 100644 frontend/src/app/components/accordion/Accordion.tsx create mode 100644 frontend/src/app/components/accordionSummary/AccordionSummary.tsx create mode 100644 frontend/src/app/components/dialog/Dialog.tsx create mode 100644 frontend/src/app/components/iconButton/IconButton.tsx create mode 100644 frontend/src/app/components/slide/Slide.tsx create mode 100644 frontend/src/app/components/switch/Switch.tsx create mode 100644 frontend/src/app/components/table/Table.tsx create mode 100644 frontend/src/app/components/tableRow/TableRow.tsx create mode 100644 frontend/src/app/containers/cookieConsent/CookieConsent.tsx create mode 100644 frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx create mode 100644 frontend/src/app/containers/cookieConsent/cookieBanner/cookie-banner.scss create mode 100644 frontend/src/app/containers/cookieConsent/cookieConsent.config.ts create mode 100644 frontend/src/app/containers/cookieConsent/cookieConsentHelper.ts create mode 100644 frontend/src/app/containers/cookieConsent/cookieModal/CookieModal.tsx create mode 100644 frontend/src/app/containers/cookieConsent/cookieModal/cookie-modal.scss create mode 100644 frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts create mode 100644 frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts create mode 100644 frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts create mode 100644 frontend/src/app/icons/CaretIcon.tsx create mode 100644 frontend/src/app/icons/CloseIcon.tsx create mode 100644 frontend/src/app/icons/CookieIcon.tsx diff --git a/frontend/index.html b/frontend/index.html index dfbfa79..8630eb2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,7 +14,7 @@ /> React Template - +
diff --git a/frontend/package.json b/frontend/package.json index 67448db..c893198 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,7 @@ "postcss": "^8.4.29", "prettier": "^3.3.2", "sass": "^1.77.6", - "sheet2i18n": "^1.0.2", + "sheet2i18n": "^1.1.2", "stylelint": "^16.6.1", "stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-standard-scss": "^13.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da34923..136dc18 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,6 @@ import Loading from "@components/loading/Loading"; +import CookieConsent from "@containers/cookieConsent/CookieConsent"; +import { hasConsent } from "@containers/cookieConsent/cookieConsentHelper"; import DebugBanner from "@containers/debugBanner/DebugBanner"; import Router from "@routes/Router"; import "@shared/i18n"; @@ -9,16 +11,18 @@ import { ToastContainer } from "react-toastify"; function App() { useEffect(() => { - ReactGA.initialize([ - { - trackingId: __GA_TRACKING_ID__, - }, - ]); + if (hasConsent("analytics")) + ReactGA.initialize([ + { + trackingId: __GA_TRACKING_ID__, + }, + ]); }, []); return ( }> + + {children} + + ); +} diff --git a/frontend/src/app/components/accordionSummary/AccordionSummary.tsx b/frontend/src/app/components/accordionSummary/AccordionSummary.tsx new file mode 100644 index 0000000..fd0615e --- /dev/null +++ b/frontend/src/app/components/accordionSummary/AccordionSummary.tsx @@ -0,0 +1,30 @@ +import CaretIcon from "@icons/CaretIcon"; +import { + AccordionSummaryProps, + AccordionSummary as MuiAccordionSummary, + styled, +} from "@mui/material"; +import style from "@styles/style.module.scss"; + +const StyledMuiAccordionSummary = styled(MuiAccordionSummary)({ + flexDirection: "row-reverse", + "& .MuiAccordionSummary-expandIconWrapper.Mui-expanded": { + transform: "rotate(90deg)", + }, + "& .MuiAccordionSummary-content": { + marginLeft: style["spacing-md"], + alignItems: "center", + }, +}); + +export default function AccordionSummary({ + children, + expandIcon = , + ...props +}: AccordionSummaryProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/components/button/Button.tsx b/frontend/src/app/components/button/Button.tsx index 27cf77f..c34c9ef 100644 --- a/frontend/src/app/components/button/Button.tsx +++ b/frontend/src/app/components/button/Button.tsx @@ -2,7 +2,7 @@ import { ButtonProps, Button as MuiButton, styled } from "@mui/material"; import style from "@styles/style.module.scss"; const StyledMuiButton = styled(MuiButton)({ - borderRadius: style["border-radius-lg"], + borderRadius: style["border-radius-xs"], }); interface IButton extends ButtonProps { diff --git a/frontend/src/app/components/dialog/Dialog.tsx b/frontend/src/app/components/dialog/Dialog.tsx new file mode 100644 index 0000000..294a4f5 --- /dev/null +++ b/frontend/src/app/components/dialog/Dialog.tsx @@ -0,0 +1,32 @@ +import Slide from "@components/slide/Slide"; +import { DialogProps, Dialog as MuiDialog, styled } from "@mui/material"; +import { TransitionProps } from "@mui/material/transitions"; +import style from "@styles/style.module.scss"; +import { forwardRef, JSXElementConstructor, ReactElement, Ref } from "react"; + +const StyledMuiDialog = styled(MuiDialog)({ + "& .MuiDialog-paper": { + margin: style["spacing-md"], + }, +}); + +const Transition = forwardRef(function Transition( + props: TransitionProps & { + children: ReactElement>; + }, + ref: Ref, +) { + return ; +}); + +export default function Dialog({ ...props }: DialogProps) { + return ( + + {props.children} + + ); +} diff --git a/frontend/src/app/components/iconButton/IconButton.tsx b/frontend/src/app/components/iconButton/IconButton.tsx new file mode 100644 index 0000000..74ffdf5 --- /dev/null +++ b/frontend/src/app/components/iconButton/IconButton.tsx @@ -0,0 +1,5 @@ +import { IconButtonProps, IconButton as MuiIconButton } from "@mui/material"; + +export default function IconButton({ ...props }: IconButtonProps) { + return ; +} diff --git a/frontend/src/app/components/layout/layout.scss b/frontend/src/app/components/layout/layout.scss index e12c139..6927ac3 100644 --- a/frontend/src/app/components/layout/layout.scss +++ b/frontend/src/app/components/layout/layout.scss @@ -8,15 +8,15 @@ align-items: center; background-color: get-color(basic, background); - @media screen and (min-width: get-media(md)) { + @media (min-width: get-media(md)) { padding: get-spacing(lg); } - @media screen and (min-width: get-media(lg)) { + @media (min-width: get-media(lg)) { padding: get-spacing(lg) 4rem; } - @media screen and (min-width: get-media(xl)) { + @media (min-width: get-media(xl)) { padding: get-spacing(lg) 6rem; } @@ -34,7 +34,7 @@ align-items: center; background-color: get-color(basic, background); - @media screen and (min-width: get-media(xs)) { + @media (min-width: get-media(xs)) { flex: 1 1 auto; } @@ -43,7 +43,7 @@ padding: get-spacing(md); background-color: get-color(basic, brightest); - @media screen and (min-width: get-media(xs)) { + @media (min-width: get-media(xs)) { max-width: 442px; border-radius: get-border-radius(md); padding: get-spacing(xl); diff --git a/frontend/src/app/components/slide/Slide.tsx b/frontend/src/app/components/slide/Slide.tsx new file mode 100644 index 0000000..e2f7c1e --- /dev/null +++ b/frontend/src/app/components/slide/Slide.tsx @@ -0,0 +1,9 @@ +import { Slide as MuiSlide, SlideProps } from "@mui/material"; + +export default function Slide({ + direction = "up", + timeout = 500, + ...props +}: SlideProps) { + return ; +} diff --git a/frontend/src/app/components/switch/Switch.tsx b/frontend/src/app/components/switch/Switch.tsx new file mode 100644 index 0000000..ede7422 --- /dev/null +++ b/frontend/src/app/components/switch/Switch.tsx @@ -0,0 +1,39 @@ +import { Switch as MuiSwitch, styled, SwitchProps } from "@mui/material"; +import style from "@styles/style.module.scss"; + +const StyledMuiSwitch = styled(MuiSwitch)({ + padding: style["spacing-xs"], + transform: "scale(1.125)", + + "& .MuiSwitch-track": { + borderRadius: style["border-radius-md"], + + "&::before, &::after": { + content: '""', + position: "absolute", + top: "50%", + transform: "translateY(-50%)", + width: 16, + height: 16, + }, + "&::before": { + backgroundImage: `url('data:image/svg+xml;utf8,')`, + left: 12, + }, + "&::after": { + backgroundImage: `url('data:image/svg+xml;utf8,')`, + right: 12, + }, + }, + + "& .MuiSwitch-thumb": { + boxShadow: "none", + width: 16, + height: 16, + margin: 2, + }, +}); + +export default function Switch({ ...props }: SwitchProps) { + return ; +} diff --git a/frontend/src/app/components/table/Table.tsx b/frontend/src/app/components/table/Table.tsx new file mode 100644 index 0000000..0964c54 --- /dev/null +++ b/frontend/src/app/components/table/Table.tsx @@ -0,0 +1,30 @@ +import { + Table as MuiTable, + TableBody, + TableCell, + TableContainer, + TableHead, + TableProps, + TableRow, +} from "@mui/material"; + +interface ITable extends TableProps { + columnTitles: Array; +} + +export default function Table({ children, columnTitles, ...props }: ITable) { + return ( + + + + + {columnTitles.map((columnTitle, index) => ( + {columnTitle} + ))} + + + {children} + + + ); +} diff --git a/frontend/src/app/components/tableRow/TableRow.tsx b/frontend/src/app/components/tableRow/TableRow.tsx new file mode 100644 index 0000000..db71974 --- /dev/null +++ b/frontend/src/app/components/tableRow/TableRow.tsx @@ -0,0 +1,28 @@ +import { + TableRow as MuiTableRow, + styled, + TableCell, + TableRowProps, +} from "@mui/material"; +import style from "@styles/style.module.scss"; + +interface ITableRow extends TableRowProps { + columns: Array; +} + +const StyledMuiTableRow = styled(MuiTableRow)({ + "&:last-child td, &:last-child th": { border: 0 }, + "&:nth-of-type(odd)": { + backgroundColor: style["basic-background"], + }, +}); + +export default function TableRowRow({ columns, ...props }: ITableRow) { + return ( + + {columns.map((column, index) => ( + {column} + ))} + + ); +} diff --git a/frontend/src/app/components/typography/Typography.tsx b/frontend/src/app/components/typography/Typography.tsx index 6393076..5d2da7f 100644 --- a/frontend/src/app/components/typography/Typography.tsx +++ b/frontend/src/app/components/typography/Typography.tsx @@ -5,115 +5,138 @@ import "./typography.scss"; interface ITypography { children: ReactNode; className?: string; + as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span"; } -function Heading1({ children, className }: ITypography) { +function Heading1({ children, className, as: Component = "h1" }: ITypography) { return ( -

+ {children} -

+ ); } -function Heading2({ children, className }: ITypography) { +function Heading2({ children, className, as: Component = "h2" }: ITypography) { return ( -

+ {children} -

+ ); } -function Heading3({ children, className }: ITypography) { +function Heading3({ children, className, as: Component = "h3" }: ITypography) { return ( -

+ {children} -

+ ); } -function Heading4({ children, className }: ITypography) { +function Heading4({ children, className, as: Component = "h4" }: ITypography) { return ( -

+ {children} -

+ ); } -function Heading5({ children, className }: ITypography) { +function Heading5({ children, className, as: Component = "h5" }: ITypography) { return ( -
+ {children} -
+ ); } -function Heading6({ children, className }: ITypography) { +function Heading6({ children, className, as: Component = "h6" }: ITypography) { return ( -
+ {children} -
+ ); } -function Subtitle1({ children, className }: ITypography) { +function Caption({ children, className, as: Component = "p" }: ITypography) { return ( -

{children}

+ + {children} + ); } -function Subtitle2({ children, className }: ITypography) { +function Overline({ children, className, as: Component = "p" }: ITypography) { return ( -

{children}

+ + {children} + ); } -function Body1({ children, className }: ITypography) { +function Subtitle1({ children, className, as: Component = "p" }: ITypography) { return ( -

{children}

+ + {children} + ); } -function Body2({ children, className }: ITypography) { +function Subtitle2({ children, className, as: Component = "p" }: ITypography) { return ( -

{children}

+ + {children} + ); } -function Caption({ children, className }: ITypography) { +function Body1({ children, className, as: Component = "p" }: ITypography) { return ( -

{children}

+ + {children} + ); } -function Overline({ children, className }: ITypography) { +function Body2({ children, className, as: Component = "p" }: ITypography) { return ( -
+ {children} -
+ ); } -function ButtonSmall({ children, className }: ITypography) { +function ButtonSmall({ + children, + className, + as: Component = "span", +}: ITypography) { return ( - + {children} - + ); } -function ButtonMedium({ children, className }: ITypography) { +function ButtonMedium({ + children, + className, + as: Component = "span", +}: ITypography) { return ( - + {children} - + ); } -function ButtonLarge({ children, className }: ITypography) { +function ButtonLarge({ + children, + className, + as: Component = "span", +}: ITypography) { return ( - + {children} - + ); } @@ -124,12 +147,12 @@ const Typography = { Heading4, Heading5, Heading6, + Caption, + Overline, Subtitle1, Subtitle2, Body1, Body2, - Caption, - Overline, ButtonSmall, ButtonMedium, ButtonLarge, diff --git a/frontend/src/app/components/typography/typography.scss b/frontend/src/app/components/typography/typography.scss index 0bc8879..6a1f98d 100644 --- a/frontend/src/app/components/typography/typography.scss +++ b/frontend/src/app/components/typography/typography.scss @@ -40,7 +40,7 @@ &__heading6 { font-size: get-font-size(sm); line-height: get-line-height(xs); - font-weight: get-font-weight(semi-bold); + font-weight: get-font-weight(medium); letter-spacing: get-letter-spacing(md); color: get-color(basic, text); } @@ -48,14 +48,14 @@ &__subtitle1 { font-size: get-font-size(xs); line-height: get-line-height(xs); - font-weight: get-font-weight(semi-bold); + font-weight: get-font-weight(medium); letter-spacing: get-letter-spacing(lg); } &__subtitle2 { font-size: get-font-size(xxs); line-height: get-line-height(xxs); - font-weight: get-font-weight(semi-bold); + font-weight: get-font-weight(medium); letter-spacing: get-letter-spacing(md); } diff --git a/frontend/src/app/containers/cookieConsent/CookieConsent.tsx b/frontend/src/app/containers/cookieConsent/CookieConsent.tsx new file mode 100644 index 0000000..ad1e9dc --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/CookieConsent.tsx @@ -0,0 +1,61 @@ +import cookieTypes from "@containers/cookieConsent/cookieConsent.config"; +import { + COOKIE_CONSENT_DURATION, + getCookieConsentPreferences, + setCookiePreferencesInStorage, +} from "@containers/cookieConsent/cookieConsentHelper"; +import ICookiePreferences from "@containers/cookieConsent/interfaces/ICookiePreferences"; +import { useCallback, useEffect, useState } from "react"; +import CookieBanner from "./cookieBanner/CookieBanner"; +import CookieModal from "./cookieModal/CookieModal"; + +const ALL_COOKIE_TYPES = cookieTypes.map((cookieType) => cookieType.id); + +export default function CookieConsent() { + const [cookieModalOpen, setCookieModalOpen] = useState(false); + const [cookieBannerOpen, setCookieBannerOpen] = useState(false); + const [cookiePreferences, setCookiePreferences] = + useState>(ALL_COOKIE_TYPES); + + const handleAccept = useCallback((preferences: Array) => { + setCookieBannerOpen(false); + setCookieModalOpen(false); + const cookieConsentPreferences: ICookiePreferences = { + consentDate: new Date().getTime(), + preferences, + }; + setCookiePreferencesInStorage(cookieConsentPreferences); + }, []); + + useEffect(() => { + const currentTimestamp = new Date().getTime(); + const cookieConsentPreferences = getCookieConsentPreferences(); + + if ( + !cookieConsentPreferences || + cookieConsentPreferences.consentDate < + currentTimestamp - COOKIE_CONSENT_DURATION + ) + setTimeout(() => setCookieBannerOpen(true), 4000); + }, []); + + return ( + <> + setCookieModalOpen(false)} + handleAcceptAll={() => handleAccept(ALL_COOKIE_TYPES)} + handleAcceptSelection={() => handleAccept(cookiePreferences)} + open={cookieModalOpen} + cookieTypes={cookieTypes} + cookiePreferences={cookiePreferences} + setCookiePreferences={setCookiePreferences} + /> + setCookieModalOpen(true)} + handleAcceptAll={() => handleAccept(ALL_COOKIE_TYPES)} + handleAcceptNecessary={() => handleAccept(["necessary"])} + /> + + ); +} diff --git a/frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx b/frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx new file mode 100644 index 0000000..4095d54 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx @@ -0,0 +1,74 @@ +import Button from "@components/button/Button"; +import Link from "@components/link/Link"; +import Slide from "@components/slide/Slide"; +import Typography from "@components/typography/Typography"; +import CookieIcon from "@icons/CookieIcon"; +import { useTranslation } from "react-i18next"; +import "./cookie-banner.scss"; + +interface ICookieBanner { + handleAcceptAll: () => void; + handleAcceptNecessary: () => void; + showBanner: boolean; + openModal: () => void; +} + +export default function CookieBanner({ + handleAcceptAll, + handleAcceptNecessary, + showBanner, + openModal, +}: ICookieBanner) { + const [t] = useTranslation(); + + return ( + +
+
+
+
+ +
+
+ + {t("cookie_banner__description")} + +
+ + + {t("cookie_consent__learn_more")} + + +
+
+
+
+
+ openModal()}> + + {t("cookie_banner__manage")} + + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/containers/cookieConsent/cookieBanner/cookie-banner.scss b/frontend/src/app/containers/cookieConsent/cookieBanner/cookie-banner.scss new file mode 100644 index 0000000..237fc85 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieBanner/cookie-banner.scss @@ -0,0 +1,73 @@ +.cookie-banner { + width: 100%; + position: fixed; + bottom: get-spacing(md); + + &__container { + position: relative; + left: 50%; + width: 80%; + transform: translateX(-50%); + z-index: get-z(100); + padding: get-spacing(md); + gap: get-spacing(xxl); + display: flex; + justify-content: space-between; + background-color: get-color(basic, brightest); + border: 1px solid get-color(stone, light); + box-shadow: 0 0 10px -6px get-color(basic, darkest); + + @media (max-width: (get-media(xxs) - 1)) { + width: 90%; + } + + @media (max-width: (get-media(md) - 1)) { + flex-direction: column; + gap: get-spacing(lg); + padding: get-spacing(lg) get-spacing(md); + } + } + + &__description { + display: flex; + align-items: center; + } + + &__actions { + display: flex; + align-items: center; + gap: get-spacing(sm); + + @media (max-width: (get-media(lg) - 1)) { + flex-direction: column; + } + + &-link { + display: flex; + justify-content: center; + white-space: nowrap; + margin-top: get-spacing(xxs); + + @media (min-width: get-media(lg)) { + margin-top: 0; + margin-right: get-spacing(md); + } + } + + &-buttons { + display: flex; + white-space: nowrap; + gap: get-spacing(md); + + @media (max-width: (get-media(xs) - 1)) { + width: 100%; + flex-direction: column; + gap: get-spacing(xs); + } + + > * { + flex: 1; + } + } + } +} diff --git a/frontend/src/app/containers/cookieConsent/cookieConsent.config.ts b/frontend/src/app/containers/cookieConsent/cookieConsent.config.ts new file mode 100644 index 0000000..c1242f4 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieConsent.config.ts @@ -0,0 +1,101 @@ +import ICookieSection from "@containers/cookieConsent/interfaces/ICookieSection"; + +const cookieConsentConfig: Array = [ + { + id: "necessary", + title: "cookie_modal__necessary_title", + description: ["cookie_modal__necessary_description"], + cookies: [ + { + name: "i18nextLng", + description: + "Stores the language preference of the user for localization purposes.", + duration: "1 year", + }, + { + name: "hideBannerUntil", + description: + "Keeps track of when the user last dismissed the banner to avoid showing it repeatedly.", + duration: "4 hours", + }, + { + name: "REFRESH_TOKEN", + description: + "Used to refresh the authentication token for continued user sessions without re-login.", + duration: "14 days", + }, + { + name: "ACCESS_TOKEN", + description: + "Used for authenticating API requests and securing user sessions.", + duration: "1 hour", + }, + { + name: "COOKIE_PREFERENCES", + description: + "Stores user's cookie consent preferences. This cookie helps in remembering your choices regarding different types of cookies and ensures that the cookie consent banner is not displayed repeatedly based on the saved preferences.", + duration: "1 year", + }, + ], + required: true, + }, + { + id: "analytics", + title: "cookie_modal__analytics_title", + description: [ + "cookie_modal__analytics_description_1", + "cookie_modal__analytics_description_2", + ], + }, + { + id: "marketing", + title: "cookie_modal__marketing_title", + description: ["cookie_modal__marketing_description"], + cookies: [ + { + name: "facebook_pixel", + description: + "Enables tracking of user actions on the website for targeted advertising and measurement of the effectiveness of Facebook ads.", + duration: "90 days", + }, + { + name: "hubspotutk", + description: + "Keeps track of a visitor's identity and is used to track their interactions with the website. This helps in personalizing the user's experience and improving engagement.", + duration: "13 months", + }, + { + name: "doubleclick", + description: + "Used to manage ad campaigns and track ad performance, facilitating targeted advertising based on user behavior.", + duration: "2 years", + }, + { + name: "adroll", + description: + "Used to identify users and show them personalized ads across the web, as well as to measure the effectiveness of ad campaigns.", + duration: "1 year", + }, + { + name: "criteo", + description: + "Enables personalized retargeting by serving relevant ads to users based on their previous browsing behavior on the website.", + duration: "6 months", + }, + { + name: "linkedin_insight", + description: + "Tracks user interactions with the website via LinkedIn, including conversions, and provides data to optimize LinkedIn ad campaigns.", + duration: "6 months", + }, + { + name: "pinterest_tag", + description: + "Helps track the conversion of Pinterest ads, allowing for the analysis and optimization of ad performance and user targeting.", + duration: "1 year", + }, + ], + }, +]; + +export default cookieConsentConfig; diff --git a/frontend/src/app/containers/cookieConsent/cookieConsentHelper.ts b/frontend/src/app/containers/cookieConsent/cookieConsentHelper.ts new file mode 100644 index 0000000..b5f6a73 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieConsentHelper.ts @@ -0,0 +1,20 @@ +import ICookiePreferences from "@containers/cookieConsent/interfaces/ICookiePreferences"; + +export const COOKIE_PREFERENCES = "COOKIE_PREFERENCES"; +export const COOKIE_CONSENT_DURATION = 1000 * 60 * 60 * 24 * 365; + +export const getCookieConsentPreferences = () => { + const preferences = localStorage.getItem(COOKIE_PREFERENCES); + return preferences ? (JSON.parse(preferences) as ICookiePreferences) : null; +}; + +export const setCookiePreferencesInStorage = ( + preferences: ICookiePreferences, +) => { + localStorage.setItem(COOKIE_PREFERENCES, JSON.stringify(preferences)); +}; + +export const hasConsent = (consentId: string) => { + const cookieConsentPreferences = getCookieConsentPreferences(); + return !!cookieConsentPreferences?.preferences.includes(consentId); +}; diff --git a/frontend/src/app/containers/cookieConsent/cookieModal/CookieModal.tsx b/frontend/src/app/containers/cookieConsent/cookieModal/CookieModal.tsx new file mode 100644 index 0000000..333095d --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieModal/CookieModal.tsx @@ -0,0 +1,164 @@ +import Accordion from "@components/accordion/Accordion"; +import AccordionSummary from "@components/accordionSummary/AccordionSummary"; +import Button from "@components/button/Button"; +import Dialog from "@components/dialog/Dialog"; +import IconButton from "@components/iconButton/IconButton"; +import Link from "@components/link/Link"; +import Switch from "@components/switch/Switch"; +import Table from "@components/table/Table"; +import TableRow from "@components/tableRow/TableRow"; +import Typography from "@components/typography/Typography"; +import ICookieSection from "@containers/cookieConsent/interfaces/ICookieSection"; +import CloseIcon from "@icons/CloseIcon"; +import { + Dispatch, + MouseEvent, + SetStateAction, + SyntheticEvent, + useCallback, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import "./cookie-modal.scss"; + +interface ICookieModal { + open: boolean; + handleAcceptAll: () => void; + handleAcceptSelection: () => void; + closeModal: () => void; + cookieTypes: Array; + cookiePreferences: Array; + setCookiePreferences: Dispatch>>; +} + +export default function CookieModal({ + open, + handleAcceptAll, + handleAcceptSelection, + closeModal, + cookieTypes, + cookiePreferences, + setCookiePreferences, +}: ICookieModal) { + const { t } = useTranslation(); + const [expandedSection, setExpandedSection] = useState( + undefined, + ); + + const handleExpand = useCallback( + (section: number) => (_: SyntheticEvent, newExpanded: boolean) => { + setExpandedSection(newExpanded ? section : undefined); + }, + [], + ); + + const handleCookieTypeClick = useCallback( + (event: MouseEvent, id: string) => { + event.stopPropagation(); + setCookiePreferences((prevState) => + prevState.includes(id) + ? prevState.filter((cookieTypeId) => cookieTypeId !== id) + : [...prevState, id], + ); + }, + [setCookiePreferences], + ); + + return ( + +
+
+ + {t("cookie_modal__title")} + + + + +
+ + {t("cookie_modal__description_1")} + + + {t("cookie_modal__description_2")} + + + {t("cookie_consent__learn_more")} + + + {t("cookie_modal__description_3")} + + +
+ {cookieTypes.map((cookieType, index) => ( + + + + {t(cookieType.title)} + + + handleCookieTypeClick(event, cookieType.id) + } + disabled={cookieType.required} + /> + + {cookieType.description.map((description, index) => ( + + {t(description)} + + ))} + {cookieType.cookies && ( +
+ + {cookieType.cookies.map((cookie, index) => ( + + ))} +
+
+ )} +
+ ))} +
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/app/containers/cookieConsent/cookieModal/cookie-modal.scss b/frontend/src/app/containers/cookieConsent/cookieModal/cookie-modal.scss new file mode 100644 index 0000000..2bbff58 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/cookieModal/cookie-modal.scss @@ -0,0 +1,17 @@ +.cookie-modal { + margin: get-spacing(lg); + + &__title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: get-spacing(md); + } + + &__buttons { + display: flex; + justify-content: center; + flex-direction: column; + gap: get-spacing(sm); + } +} diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts b/frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts new file mode 100644 index 0000000..6190fd0 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts @@ -0,0 +1,5 @@ +export default interface ICookieInfo { + name: string; + description: string; + duration: string; +} diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts b/frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts new file mode 100644 index 0000000..92105b0 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts @@ -0,0 +1,4 @@ +export default interface ICookiePreferences { + consentDate: number; + preferences: Array; +} diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts b/frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts new file mode 100644 index 0000000..af983f9 --- /dev/null +++ b/frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts @@ -0,0 +1,9 @@ +import ICookieInfo from "@containers/cookieConsent/interfaces/ICookieInfo"; + +export default interface ICookieSection { + id: string; + title: string; + description: Array; + required?: boolean; + cookies?: Array; +} diff --git a/frontend/src/app/containers/debugBanner/DebugBanner.tsx b/frontend/src/app/containers/debugBanner/DebugBanner.tsx index a62386d..9b70001 100644 --- a/frontend/src/app/containers/debugBanner/DebugBanner.tsx +++ b/frontend/src/app/containers/debugBanner/DebugBanner.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import Button from "@components/button/Button"; import classNames from "classnames"; +import { useState } from "react"; import "./debug-banner.scss"; -import Button from "@components/button/Button"; const HIDE_BANNER_UNTIL_KEY = "hideBannerUntil"; const FOUR_HOURS = 4 * 60 * 60 * 1000; @@ -36,7 +36,7 @@ export default function DebugBanner() { "debug-banner__staging": __ENV__ === "staging", })} > - diff --git a/frontend/src/app/icons/CaretIcon.tsx b/frontend/src/app/icons/CaretIcon.tsx new file mode 100644 index 0000000..482297f --- /dev/null +++ b/frontend/src/app/icons/CaretIcon.tsx @@ -0,0 +1,24 @@ +import style from "@styles/style.module.scss"; +import IIcon from "./IIcon"; + +export default function CaretIcon({ + className, + color = style["stone-dark"], + width = 24, + height = 24, + alt = "Caret Icon", +}: IIcon) { + return ( + + {alt} + + + ); +} diff --git a/frontend/src/app/icons/CloseIcon.tsx b/frontend/src/app/icons/CloseIcon.tsx new file mode 100644 index 0000000..73ec147 --- /dev/null +++ b/frontend/src/app/icons/CloseIcon.tsx @@ -0,0 +1,27 @@ +import style from "@styles/style.module.scss"; +import IIcon from "./IIcon"; + +export default function CloseIcon({ + className, + color = style["stone-dark"], + width = 24, + height = 24, + alt = "Close Icon", +}: IIcon) { + return ( + + {alt} + + + ); +} diff --git a/frontend/src/app/icons/CookieIcon.tsx b/frontend/src/app/icons/CookieIcon.tsx new file mode 100644 index 0000000..1ce77c5 --- /dev/null +++ b/frontend/src/app/icons/CookieIcon.tsx @@ -0,0 +1,47 @@ +import style from "@styles/style.module.scss"; +import IIcon from "./IIcon"; + +export default function CookieIcon({ + className, + color = style["primary-main"], + width = 24, + height = 25, + alt = "Cookie Icon", +}: IIcon) { + return ( + + {alt} + + + + + + + + + + + + + ); +} diff --git a/frontend/src/app/services/auth/authService.ts b/frontend/src/app/services/auth/authService.ts index 3ba6748..b23641b 100644 --- a/frontend/src/app/services/auth/authService.ts +++ b/frontend/src/app/services/auth/authService.ts @@ -1,11 +1,16 @@ import ILogin from "@services/auth/interfaces/ILogin"; import axiosInstance from "@services/axiosInstance"; import IUser from "@services/users/interfaces/IUser"; -import { AxiosResponse } from "axios"; +import { AxiosResponse, CancelToken } from "axios"; const AUTH_PREFIX = "/auth"; const POST_LOGIN = `${AUTH_PREFIX}/login`; -export async function postLogin(login: ILogin): Promise> { - return await axiosInstance.post(POST_LOGIN, login); +export async function postLogin( + login: ILogin, + cancelToken?: CancelToken, +): Promise> { + return await axiosInstance.post(POST_LOGIN, login, { + cancelToken, + }); } diff --git a/frontend/src/app/services/users/userService.ts b/frontend/src/app/services/users/userService.ts index 0eab162..6561459 100644 --- a/frontend/src/app/services/users/userService.ts +++ b/frontend/src/app/services/users/userService.ts @@ -1,10 +1,14 @@ import axiosInstance from "@services/axiosInstance"; import IUser from "@services/users/interfaces/IUser"; -import { AxiosResponse } from "axios"; +import { AxiosResponse, CancelToken } from "axios"; const USER_PREFIX = "/user"; const GET_ME = `${USER_PREFIX}/me`; -export async function getMe(): Promise> { - return await axiosInstance.get(GET_ME); +export async function getMe( + cancelToken?: CancelToken, +): Promise> { + return await axiosInstance.get(GET_ME, { + cancelToken, + }); } diff --git a/frontend/src/assets/locales/en.json b/frontend/src/assets/locales/en.json index 3b62b96..ab6bdd0 100644 --- a/frontend/src/assets/locales/en.json +++ b/frontend/src/assets/locales/en.json @@ -6,14 +6,39 @@ "global__current_locale": "Current language", "global__switch_locale": "Switch language", "global__version": "Version", + "global__hide": "Hide", + "global__close": "Close", "not_found__page_title": "Page not found", "not_found__title": "We're sorry, but the page you are looking for cannot be found.", "not_found__description": "Error code 404", "not_found__description_secondary": "The URL may be spelled incorrectly or the page you are looking for may no longer exist.", "not_found__go_to_home_page": "Go to the home page", + "cookie_consent_link": "https://nventive.com/en/privacy-policy/", + "cookie_consent__learn_more": "Learn more about privacy policy", + "cookie_banner__description": "This website uses cookies to ensure you get the best experience on our website.", + "cookie_banner__manage": "Manage Cookies", + "cookie_banner__accept_necessary": "Necessary", + "cookie_banner__accept_all": "Accept all", + "cookie_modal__title": "Cookie preferences", + "cookie_modal__description_1": "Cookies are small text files that can be used by websites to make a user's experience more efficient.", + "cookie_modal__description_2": "You can at any time change or withdraw your consent from the Cookie Declaration on our website.", + "cookie_modal__description_3": "This website uses the following types of services.", + "cookie_modal__cookie_name": "Name", + "cookie_modal__cookie_description": "Description", + "cookie_modal__cookie_duration": "Duration", + "cookie_modal__necessary_title": "Necessary Cookies", + "cookie_modal__necessary_description": "Strictly necessary cookies that are essential for functions such as page navigation or access to secure areas. The website cannot function properly without these cookies.", + "cookie_modal__analytics_title": "Analytics", + "cookie_modal__analytics_description_1": "We use Google Analytics to collect and analyze data about how visitors interact with our website. This helps us understand and improve your browsing experience.", + "cookie_modal__analytics_description_2": "GA4 uses cookies to gather anonymous information, such as the number of visitors, the pages they visit, and the time spent on our site. These cookies do not collect personally identifiable information and are used solely for statistical analysis.", + "cookie_modal__marketing_title": "Marketing", + "cookie_modal__marketing_description": "These cookies are used to track visitors across websites. They are designed to collect information about your interests and browsing habits, allowing the delivery of advertisements that are more relevant to you. They help measure the effectiveness of ad campaigns and may limit the number of times you see an ad. Marketing cookies often link to social media and other advertising networks to provide personalized advertising experiences.", + "cookie_modal__allow_selection": "Allow selection", + "cookie_modal__allow_all": "Allow all", "routes__page_title": "React Template", "routes__login": "login", "routes__home": "home", + "routes__uikit": "uikit", "routes__not_found": "not-found", "validations__required": "{{ field }} is required.", "validations__max_characters": "{{ field }} can have a maximum of {{ max }} characters.", diff --git a/frontend/src/assets/locales/fr.json b/frontend/src/assets/locales/fr.json index b257733..00d6868 100644 --- a/frontend/src/assets/locales/fr.json +++ b/frontend/src/assets/locales/fr.json @@ -6,14 +6,39 @@ "global__current_locale": "Langue actuelle", "global__switch_locale": "Changer de langue", "global__version": "Version", + "global__hide": "Cacher", + "global__close": "Fermer", "not_found__page_title": "Page non trouvée", "not_found__title": "Nous sommes désolés, mais la page que vous recherchez semble introuvable.", "not_found__description": "Code d’erreur 404", "not_found__description_secondary": "Il est possible que l'URL soit incorrectement orthographiée ou que la page que vous cherchez n'existe plus.", "not_found__go_to_home_page": "Aller à la page d’accueil", + "cookie_consent_link": "https://nventive.com/fr/politique-confidentialite/", + "cookie_consent__learn_more": "En savoir plus sur la politique de confidentialité", + "cookie_banner__description": "Ce site Web utilise des cookies pour vous garantir la meilleure expérience sur notre site.", + "cookie_banner__manage": "Gérer les cookies", + "cookie_banner__accept_necessary": "Nécessaire", + "cookie_banner__accept_all": "Tout accepter", + "cookie_modal__title": "Préférences en matière de cookies", + "cookie_modal__description_1": "Les cookies sont de petits fichiers texte qui peuvent être utilisés par les sites Web pour rendre l'expérience utilisateur plus efficace.", + "cookie_modal__description_2": "Vous pouvez à tout moment modifier ou retirer votre consentement de la Déclaration relative aux cookies sur notre site Web.", + "cookie_modal__description_3": "Ce site Web utilise les types de services suivants.", + "cookie_modal__cookie_name": "Nom", + "cookie_modal__cookie_description": "Description", + "cookie_modal__cookie_duration": "Durée", + "cookie_modal__necessary_title": "Cookies nécessaires", + "cookie_modal__necessary_description": "Cookies strictement nécessaires qui sont indispensables aux fonctions telles que la navigation sur la page ou l'accès aux zones sécurisées. Le site Web ne peut pas fonctionner correctement sans ces cookies.", + "cookie_modal__analytics_title": "Analytics", + "cookie_modal__analytics_description_1": "Nous utilisons Google Analytics pour collecter et analyser des données sur la façon dont les visiteurs interagissent avec notre site Web. Cela nous aide à comprendre et à améliorer votre expérience de navigation.", + "cookie_modal__analytics_description_2": "GA4 utilise des cookies pour collecter des informations anonymes, telles que le nombre de visiteurs, les pages qu'ils visitent et le temps passé sur notre site. Ces cookies ne collectent pas d'informations personnelles identifiables et sont utilisés uniquement à des fins d'analyse statistique.", + "cookie_modal__marketing_title": "Marketing", + "cookie_modal__marketing_description": "Ces cookies sont utilisés pour suivre les visiteurs sur les sites Web. Ils sont conçus pour collecter des informations sur vos centres d'intérêt et vos habitudes de navigation, permettant la diffusion de publicités plus pertinentes pour vous. Ils aident à mesurer l'efficacité des campagnes publicitaires et peuvent limiter le nombre de fois que vous voyez une publicité. Les cookies marketing sont souvent liés aux réseaux sociaux et à d'autres réseaux publicitaires pour offrir des expériences publicitaires personnalisées.", + "cookie_modal__allow_selection": "Autoriser la sélection", + "cookie_modal__allow_all": "Autoriser tout", "routes__page_title": "React Template", "routes__login": "connexion", "routes__home": "accueil", + "routes__uikit": "uikit", "routes__not_found": "page-introuvable", "validations__required": "{{ field }} est obligatoire.", "validations__max_characters": "{{ field }} peut avoir un maximum de {{ max }} caractères.", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4ad4af6..b686aef 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,5 @@ import { ThemeProvider } from "@emotion/react"; -import * as React from "react"; +import { StrictMode } from "react"; import * as ReactDOM from "react-dom/client"; import { HelmetProvider } from "react-helmet-async"; import App from "./App"; @@ -8,11 +8,11 @@ import theme from "./app/shared/theme"; ReactDOM.createRoot( document.getElementById("root") as ReactDOM.Container, ).render( - + - , + , ); diff --git a/frontend/src/sheet2i18n.config.cjs b/frontend/src/sheet2i18n.config.cjs index d5e19c2..7d76812 100644 --- a/frontend/src/sheet2i18n.config.cjs +++ b/frontend/src/sheet2i18n.config.cjs @@ -4,6 +4,8 @@ module.exports = { tabsUrl: [ // Global "https://docs.google.com/spreadsheets/d/e/2PACX-1vQe6sBfW-7S3xGPlYVaOB8v39yfZHx0FqCOeGEChuWlkObw-F5EsuVag_olya-psWYyKOuCl9y8ZGcf/pub?gid=877120618&single=true&output=csv", + // Components + "https://docs.google.com/spreadsheets/d/e/2PACX-1vQe6sBfW-7S3xGPlYVaOB8v39yfZHx0FqCOeGEChuWlkObw-F5EsuVag_olya-psWYyKOuCl9y8ZGcf/pub?gid=1989943737&single=true&output=csv", // Routes "https://docs.google.com/spreadsheets/d/e/2PACX-1vQe6sBfW-7S3xGPlYVaOB8v39yfZHx0FqCOeGEChuWlkObw-F5EsuVag_olya-psWYyKOuCl9y8ZGcf/pub?gid=430014378&single=true&output=csv", // Validations diff --git a/frontend/src/styles/_colors.scss b/frontend/src/styles/_colors.scss index 8a99983..b55b659 100644 --- a/frontend/src/styles/_colors.scss +++ b/frontend/src/styles/_colors.scss @@ -27,8 +27,15 @@ $color-basic: ( warning: #fbc02d, ); +$color-stone: ( + veryLight: #f3f2f2, + light: #c8c8c8, + dark: #444, +); + $color: ( primary: $color-primary, secondary: $color-secondary, basic: $color-basic, + stone: $color-stone, ); diff --git a/frontend/src/styles/_export.scss b/frontend/src/styles/_export.scss index 4934339..4b04632 100755 --- a/frontend/src/styles/_export.scss +++ b/frontend/src/styles/_export.scss @@ -4,7 +4,7 @@ @each $colorKey, $colorValue in $color { @each $colorSubkey, $colorSubvalue in $colorValue { - #root .color-#{$colorKey}-#{$colorSubkey} { + #body .color-#{$colorKey}-#{$colorSubkey} { color: $colorSubvalue; } } @@ -13,7 +13,7 @@ @each $propertyKey, $propertyValue in $property { @each $spacingKey, $spacingValue in $spacing { @each $directionKey, $directionValues in $direction { - #root .#{$propertyKey}#{$directionKey}-#{$spacingKey} { + #body .#{$propertyKey}#{$directionKey}-#{$spacingKey} { @if $directionValues == all { #{$propertyValue}: $spacingValue; } @else { diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss index 6a4d14b..2f8df71 100755 --- a/frontend/src/styles/_variables.scss +++ b/frontend/src/styles/_variables.scss @@ -102,6 +102,7 @@ $spacing: ( ); $border-radius: ( + xs: 0.25rem, sm: 0.5rem, md: 0.75rem, lg: 1.5rem, @@ -114,6 +115,7 @@ $z: ( ); $media: ( + xxs: 380px, xs: 640px, sm: 768px, md: 1024px, diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a72f2c8..0919c66 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1316,7 +1316,7 @@ axios@^0.18.0: follow-redirects "1.5.10" is-buffer "^2.0.2" -axios@^1.1.3, axios@^1.7.2: +axios@^1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.2.tgz#b625db8a7051fbea61c35a3cbb3a1daa7b9c7621" integrity sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw== @@ -1683,7 +1683,7 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== -csv-parse@^5.3.1: +csv-parse@^5.5.6: version "5.5.6" resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a" integrity sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A== @@ -4305,13 +4305,12 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -sheet2i18n@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/sheet2i18n/-/sheet2i18n-1.0.2.tgz#25c640dc5c98ba6f1d24562cdddef3390921bfef" - integrity sha512-OInJ5JLhVWwLeieIVy+jqCpJv+h/8zllJoBRiNZlAzFHvzLrq1x0Gz2gH9ej+iTaZke9wDMRy/8rp6qDooqyBA== +sheet2i18n@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sheet2i18n/-/sheet2i18n-1.1.2.tgz#d7c1a358fc2d4b6f77b50f1000694dee92e6187e" + integrity sha512-wx1manoS4j7+FIBOJrWPSToqqgXPeOLaitP1UbVr+hGXRDzxHHwWbFXbJXmxXa4+ELIsOJPNJmyC/rckSVLbHA== dependencies: - axios "^1.1.3" - csv-parse "^5.3.1" + csv-parse "^5.5.6" side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6"