diff --git a/packages/shared/src/components/Pixels.tsx b/packages/shared/src/components/Pixels.tsx index e3a68d9b8e..3fd5d8a1c6 100644 --- a/packages/shared/src/components/Pixels.tsx +++ b/packages/shared/src/components/Pixels.tsx @@ -6,6 +6,8 @@ import { isProduction } from '../lib/constants'; import type { UserExperienceLevel } from '../lib/user'; import { useAuthContext } from '../contexts/AuthContext'; import { fromCDN } from '../lib'; +import { GdprConsentKey } from '../hooks/useCookieBanner'; +import { useConsentCookie } from '../hooks/useCookieConsent'; const FB_PIXEL_ID = '519268979315924'; const GA_TRACKING_ID = 'G-VTGLXD7QSN'; @@ -196,7 +198,10 @@ export const logPixelPayment = ( }; export const Pixels = ({ hotjarId }: Partial): ReactElement => { - const { user, anonymous } = useAuthContext(); + const { cookieExists: acceptedMarketing } = useConsentCookie( + GdprConsentKey.Marketing, + ); + const { user, anonymous, isAuthReady, isGdprCovered } = useAuthContext(); const userId = user?.id || anonymous?.id; const { query } = useRouter(); @@ -204,7 +209,12 @@ export const Pixels = ({ hotjarId }: Partial): ReactElement => { const props: PixelProps = { userId, instanceId }; - if (!isProduction || !userId) { + if ( + !isProduction || + !userId || + !isAuthReady || + (isGdprCovered && !acceptedMarketing) + ) { return null; } diff --git a/packages/shared/src/components/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu.tsx index fd7b7ff0d8..9270d5218c 100644 --- a/packages/shared/src/components/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu.tsx @@ -13,6 +13,7 @@ import { PauseIcon, EditIcon, DevPlusIcon, + PrivacyIcon, } from './icons'; import InteractivePopup, { InteractivePopupPosition, @@ -50,7 +51,7 @@ export default function ProfileMenu({ onClose, }: ProfileMenuProps): ReactElement { const { openModal } = useLazyModal(); - const { user, logout } = useAuthContext(); + const { user, logout, isGdprCovered } = useAuthContext(); const { isActive: isDndActive, setShowDnd } = useDndContext(); const { showPlusSubscription, isPlus, logSubscriptionEvent } = usePlusSubscription(); @@ -142,6 +143,17 @@ export default function ProfileMenu({ }, }); + if (isGdprCovered) { + list.push({ + title: 'Privacy', + buttonProps: { + tag: 'a', + icon: , + href: `${webappUrl}account/privacy`, + }, + }); + } + list.push({ title: 'Logout', buttonProps: { @@ -152,6 +164,7 @@ export default function ProfileMenu({ return list.filter(Boolean); }, [ + isGdprCovered, isDndActive, isPlus, logSubscriptionEvent, diff --git a/packages/shared/src/components/accordion/index.tsx b/packages/shared/src/components/accordion/index.tsx new file mode 100644 index 0000000000..4093b28f44 --- /dev/null +++ b/packages/shared/src/components/accordion/index.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; + +interface AccordionProps { + title: ReactNode; + children: ReactNode; +} + +export function Accordion({ title, children }: AccordionProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + {title} +
+
+ ); +} diff --git a/packages/shared/src/components/icons/Privacy/filled.svg b/packages/shared/src/components/icons/Privacy/filled.svg new file mode 100644 index 0000000000..a589ed0bc2 --- /dev/null +++ b/packages/shared/src/components/icons/Privacy/filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/shared/src/components/icons/Privacy/index.tsx b/packages/shared/src/components/icons/Privacy/index.tsx new file mode 100644 index 0000000000..62dbe9d624 --- /dev/null +++ b/packages/shared/src/components/icons/Privacy/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const PrivacyIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Privacy/outlined.svg b/packages/shared/src/components/icons/Privacy/outlined.svg new file mode 100644 index 0000000000..8111aaf8ec --- /dev/null +++ b/packages/shared/src/components/icons/Privacy/outlined.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 18357ab6c2..b9a8517e7b 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -129,3 +129,4 @@ export * from './ShieldWarning'; export * from './ShieldPlus'; export * from './Sidebar'; export * from './Folder'; +export * from './Privacy'; diff --git a/packages/shared/src/components/modals/SharedBookmarksModal.spec.tsx b/packages/shared/src/components/modals/SharedBookmarksModal.spec.tsx index 174183d28e..66358edc5e 100644 --- a/packages/shared/src/components/modals/SharedBookmarksModal.spec.tsx +++ b/packages/shared/src/components/modals/SharedBookmarksModal.spec.tsx @@ -6,7 +6,7 @@ import { screen, waitFor, } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; import React from 'react'; import nock from 'nock'; import SharedBookmarksModal from './SharedBookmarksModal'; @@ -17,6 +17,7 @@ import { BOOKMARK_SHARING_QUERY, } from '../../graphql/bookmarksSharing'; import { waitForNock } from '../../../__tests__/helpers/utilities'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; const onRequestClose = jest.fn(); @@ -37,13 +38,13 @@ const renderComponent = (mocks: MockedGraphQLResponse[] = []): RenderResult => { mocks.forEach(mockGraphQL); return render( - + - , + , ); }; diff --git a/packages/shared/src/components/modals/SubmitArticleModal.spec.tsx b/packages/shared/src/components/modals/SubmitArticleModal.spec.tsx index 21814050d4..788385e0a6 100644 --- a/packages/shared/src/components/modals/SubmitArticleModal.spec.tsx +++ b/packages/shared/src/components/modals/SubmitArticleModal.spec.tsx @@ -1,10 +1,9 @@ import type { RenderResult } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClient } from '@tanstack/react-query'; import React from 'react'; import nock from 'nock'; -import { AuthContextProvider } from '../../contexts/AuthContext'; import type { AnonymousUser, LoggedUser } from '../../lib/user'; import type { MockedGraphQLResponse } from '../../../__tests__/helpers/graphql'; import { mockGraphQL } from '../../../__tests__/helpers/graphql'; @@ -17,6 +16,7 @@ import user from '../../../__tests__/fixture/loggedUser'; import { NotificationsContextProvider } from '../../contexts/NotificationsContext'; import { waitForNock } from '../../../__tests__/helpers/utilities'; import Toast from '../notifications/Toast'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; const onRequestClose = jest.fn(); @@ -43,21 +43,22 @@ const renderComponent = ( const client = new QueryClient(); mocks.forEach(mockGraphQL); return render( - - - - - - - - , + + + + + + , ); }; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 31bf056ac4..45ccf4f0ba 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -214,6 +214,13 @@ const AddToCustomFeedModal = dynamic( ), ); +const CookieConsentModal = dynamic( + () => + import( + /* webpackChunkName: "cookieConsentModal" */ './user/CookieConsentModal' + ), +); + const ReportUserModal = dynamic( () => import( @@ -257,6 +264,7 @@ export const modals = { [LazyModal.ClickbaitShield]: ClickbaitShieldModal, [LazyModal.MoveBookmark]: MoveBookmarkModal, [LazyModal.AddToCustomFeed]: AddToCustomFeedModal, + [LazyModal.CookieConsent]: CookieConsentModal, [LazyModal.ReportUser]: ReportUserModal, }; diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 64a3b9ff5e..50a9914e82 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -60,6 +60,7 @@ export enum LazyModal { ClickbaitShield = 'clickbaitShield', MoveBookmark = 'moveBookmark', AddToCustomFeed = 'addToCustomFeed', + CookieConsent = 'cookieConsent', ReportUser = 'reportUser', } diff --git a/packages/shared/src/components/modals/user/CookieConsentItem.tsx b/packages/shared/src/components/modals/user/CookieConsentItem.tsx new file mode 100644 index 0000000000..8eb8a3d691 --- /dev/null +++ b/packages/shared/src/components/modals/user/CookieConsentItem.tsx @@ -0,0 +1,76 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { Switch } from '../../fields/Switch'; +import type { GdprConsentKey } from '../../../hooks/useCookieBanner'; +import { gdprConsentSettings } from '../../../hooks/useCookieBanner'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { Accordion } from '../../accordion'; +import { getCookies } from '../../../lib/cookie'; + +interface CookieConsentItemProps { + consent: GdprConsentKey; + onToggle?: (value: boolean) => void; +} + +const getCookie = (key: GdprConsentKey) => { + const cookies = getCookies([key]); + const disabled = globalThis?.localStorage.getItem(key); + + return !!cookies[key] || !disabled; +}; + +export function CookieConsentItem({ + consent, + onToggle, +}: CookieConsentItemProps): ReactElement { + const { title, description, isAlwaysOn } = gdprConsentSettings[consent]; + const [isChecked, setIsChecked] = useState( + isAlwaysOn ?? getCookie(consent), + ); + + if (!gdprConsentSettings[consent]) { + return null; + } + + const onToggleSwitch = () => { + if (isAlwaysOn) { + return; + } + + const value = !isChecked; + + setIsChecked(value); + + if (onToggle) { + onToggle(value); + } + }; + + return ( + + {title} + + } + > + + {description} + + + ); +} diff --git a/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx b/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx new file mode 100644 index 0000000000..ce5bc5a77b --- /dev/null +++ b/packages/shared/src/components/modals/user/CookieConsentModal.spec.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { fireEvent, screen, render } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import ReactModal from 'react-modal'; +import { CookieConsentModal } from './CookieConsentModal'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { + GdprConsentKey, + useCookieBanner, +} from '../../../hooks/useCookieBanner'; +import { expireCookie, getCookies } from '../../../lib/cookie'; + +let client: QueryClient; + +ReactModal.setAppElement('body'); + +beforeEach(() => { + client = new QueryClient(); + localStorage.clear(); + document.cookie = ''; + Object.values(GdprConsentKey).forEach((key) => { + expireCookie(key); + }); +}); + +const Wrapper = () => { + const { onAcceptCookies } = useCookieBanner(); + + return ( + + ); +}; + +const renderComponent = () => { + return render( + + + , + ); +}; + +describe('CookieConsentModal', () => { + it('should render', async () => { + renderComponent(); + await screen.findByText('Cookie preferences'); + }); + + it('should render default value to be true', async () => { + const cookies = getCookies(Object.values(GdprConsentKey)); + expect(cookies.ilikecookies).toBeUndefined(); + expect(cookies.ilikecookies_marketing).toBeUndefined(); + + renderComponent(); + + const options = screen.getAllByRole('checkbox', { hidden: true }); + const isEveryChecked = options.every( + (checkbox) => (checkbox as HTMLInputElement).checked, + ); + expect(isEveryChecked).toBeTruthy(); + const save = screen.getByText('Save preferences'); + fireEvent.click(save); + const cookiesAfter = getCookies(Object.values(GdprConsentKey)); + expect(cookiesAfter.ilikecookies).toBeTruthy(); + expect(cookiesAfter.ilikecookies_marketing).toBeTruthy(); + }); + + it('should allow toggling the other options', async () => { + const cookies = getCookies(Object.values(GdprConsentKey)); + expect(cookies.ilikecookies_marketing).toBeUndefined(); + renderComponent(); + const clickable = await screen.findByText('Marketing cookies'); + const [, marketingBefore] = screen.getAllByRole('checkbox', { + hidden: true, + }); + expect(marketingBefore).toBeChecked(); + + fireEvent.click(clickable); + + const [, marketingAfter] = screen.getAllByRole('checkbox', { + hidden: true, + }); + expect(marketingAfter).not.toBeChecked(); + const save = screen.getByText('Save preferences'); + fireEvent.click(save); + const cookiesAfter = getCookies(Object.values(GdprConsentKey)); + expect(cookiesAfter.ilikecookies_marketing).not.toBeTruthy(); + }); + + it('should retain previous option if the item was disabled', async () => { + localStorage.setItem(GdprConsentKey.Marketing, 'disabled'); + renderComponent(); + const [, marketing] = screen.getAllByRole('checkbox', { + hidden: true, + }); + expect(marketing).not.toBeChecked(); + }); + + it('should NOT allow toggling the necessary option', async () => { + renderComponent(); + const clickable = await screen.findByText('Strictly necessary cookies'); + const [necessaryBefore] = screen.getAllByRole('checkbox', { hidden: true }); + expect(necessaryBefore).toBeChecked(); + + fireEvent.click(clickable); + + const [necessaryAfter] = screen.getAllByRole('checkbox', { hidden: true }); + expect(necessaryAfter).toBeChecked(); + }); + + it('should save everything when "Accept all" is clicked', async () => { + const cookies = getCookies(Object.values(GdprConsentKey)); + expect(cookies.ilikecookies).toBeUndefined(); + expect(cookies.ilikecookies_marketing).toBeUndefined(); + + renderComponent(); + + const options = screen.getAllByRole('checkbox', { hidden: true }); + const isEveryChecked = options.every( + (checkbox) => (checkbox as HTMLInputElement).checked, + ); + expect(isEveryChecked).toBeTruthy(); + const save = screen.getByText('Accept all'); + fireEvent.click(save); + const cookiesAfter = getCookies(Object.values(GdprConsentKey)); + expect(cookiesAfter.ilikecookies).toBeTruthy(); + expect(cookiesAfter.ilikecookies_marketing).toBeTruthy(); + }); + + it('should save the necessary only', async () => { + const cookies = getCookies(Object.values(GdprConsentKey)); + expect(cookies.ilikecookies).toBeUndefined(); + expect(cookies.ilikecookies_marketing).toBeUndefined(); + + renderComponent(); + + const options = screen.getAllByRole('checkbox', { hidden: true }); + const isEveryChecked = options.every( + (checkbox) => (checkbox as HTMLInputElement).checked, + ); + expect(isEveryChecked).toBeTruthy(); // while everything is checked, rejecting all should only save necessary + const save = screen.getByText('Reject all'); + fireEvent.click(save); + const cookiesAfter = getCookies(Object.values(GdprConsentKey)); + expect(cookiesAfter.ilikecookies).toBeTruthy(); + expect(cookiesAfter.ilikecookies_marketing).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/components/modals/user/CookieConsentModal.tsx b/packages/shared/src/components/modals/user/CookieConsentModal.tsx new file mode 100644 index 0000000000..1a09403c64 --- /dev/null +++ b/packages/shared/src/components/modals/user/CookieConsentModal.tsx @@ -0,0 +1,135 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { ModalProps } from '../common/Modal'; +import { Modal } from '../common/Modal'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { Divider } from '../../utilities'; +import { cookiePolicy } from '../../../lib/constants'; +import type { AcceptCookiesCallback } from '../../../hooks/useCookieConsent'; +import { + GdprConsentKey, + otherGdprConsents, +} from '../../../hooks/useCookieBanner'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { CookieConsentItem } from './CookieConsentItem'; + +interface CookieConsentModalProps extends ModalProps { + onAcceptCookies: AcceptCookiesCallback; +} + +const options = Object.values(GdprConsentKey).filter( + (key) => key !== GdprConsentKey.Necessary, +); + +export const CookieConsentModal = ({ + onAcceptCookies, + ...modalProps +}: CookieConsentModalProps): ReactElement => { + const { onRequestClose } = modalProps; + + const onAcceptPreferences = (e: React.FormEvent) => { + e.preventDefault(); + // get the form + const formData = new FormData((e.target as HTMLInputElement).form); + const formProps = Object.fromEntries(formData); + const keys = Object.keys(formProps); + const acceptedConsents = keys.filter( + (key) => + formProps[key] === 'on' && + otherGdprConsents.includes(key as GdprConsentKey), + ); + const rejectedConsents = options.filter( + (option) => !acceptedConsents.includes(option), + ); + onAcceptCookies(acceptedConsents, rejectedConsents); + + onRequestClose(null); + }; + + const onAcceptAll = (e: React.MouseEvent) => { + onAcceptCookies(otherGdprConsents); + onRequestClose(e); + }; + + const onRejectAll = (e: React.MouseEvent) => { + onAcceptCookies(); // this will accept just the necessary ones + onRequestClose(e); + }; + + return ( + + + + + We value your privacy + + + We use cookies to personalize content, improve performance, and + provide a better experience. Manage your preferences below. + + + Learn more about our Cookie Policy → + + + + + + + + + + ); +}; + +export default CookieConsentModal; diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index b0141a9139..0182d26b60 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -162,6 +162,9 @@ export function PostContentRaw({ title={title} videoId={post.videoId} className="mb-7" + image={post.image} + source={post.source} + onWatchVideo={onReadArticle} /> )} {post.summary && ( diff --git a/packages/shared/src/components/post/ShareYouTubeContent.tsx b/packages/shared/src/components/post/ShareYouTubeContent.tsx index 6834b55c20..71953ba38e 100644 --- a/packages/shared/src/components/post/ShareYouTubeContent.tsx +++ b/packages/shared/src/components/post/ShareYouTubeContent.tsx @@ -72,6 +72,9 @@ function ShareYouTubeContent({ diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index d09fac9e73..4779b185da 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -15,6 +15,7 @@ import { HammerIcon, AppIcon, DevPlusIcon, + PrivacyIcon, } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; import { @@ -41,7 +42,7 @@ import { useConditionalFeature } from '../../hooks'; import { featureOnboardingAndroid } from '../../lib/featureManagement'; const useMenuItems = (): NavItemProps[] => { - const { logout, isAndroidApp } = useAuthContext(); + const { logout, isAndroidApp, isGdprCovered } = useAuthContext(); const { openModal } = useLazyModal(); const { showPrompt } = usePrompt(); const { showPlusSubscription, isPlus, logSubscriptionEvent } = @@ -92,7 +93,7 @@ const useMenuItems = (): NavItemProps[] => { } : undefined; - return [ + const items = [ { label: 'Profile', isHeader: true, @@ -105,6 +106,14 @@ const useMenuItems = (): NavItemProps[] => { href: '/account/invite', }, { label: 'Devcard', icon: , href: '/devcard' }, + ]; + + if (isGdprCovered) { + items.push({ label: 'Privacy', icon: , href: '/privacy' }); + } + + return [ + ...items, { label: 'Logout', icon: , @@ -185,6 +194,7 @@ const useMenuItems = (): NavItemProps[] => { ].filter(Boolean); }, [ isPlus, + isGdprCovered, logSubscriptionEvent, onLogout, openModal, diff --git a/packages/shared/src/components/typography/Typography.tsx b/packages/shared/src/components/typography/Typography.tsx index 4ffbe6ccc3..e1c9160ced 100644 --- a/packages/shared/src/components/typography/Typography.tsx +++ b/packages/shared/src/components/typography/Typography.tsx @@ -64,6 +64,10 @@ export type TypographyProps = { } & HTMLAttributes & JSX.IntrinsicElements[Tag]; +const tagToColor = { + [TypographyTag.Link]: TypographyColor.Link, +}; + export function Typography({ tag = TypographyTag.P, type, @@ -78,7 +82,7 @@ export function Typography({ className, type, { 'font-bold': bold }, - color, + color ?? tagToColor[tag], truncate && truncateTextClassNames, ); const Tag = classed(tag, classes); diff --git a/packages/shared/src/components/video/YoutubeVideo.spec.tsx b/packages/shared/src/components/video/YoutubeVideo.spec.tsx index 1c5337921f..65495c117e 100644 --- a/packages/shared/src/components/video/YoutubeVideo.spec.tsx +++ b/packages/shared/src/components/video/YoutubeVideo.spec.tsx @@ -2,15 +2,24 @@ import type { RenderResult } from '@testing-library/react'; import { render, screen } from '@testing-library/react'; import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; import YoutubeVideo from './YoutubeVideo'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { sharePost } from '../../../__tests__/fixture/post'; const renderComponent = (): RenderResult => { + const client = new QueryClient(); + return render( - , + + + , ); }; diff --git a/packages/shared/src/components/video/YoutubeVideo.tsx b/packages/shared/src/components/video/YoutubeVideo.tsx index 0c10badc95..f87808f484 100644 --- a/packages/shared/src/components/video/YoutubeVideo.tsx +++ b/packages/shared/src/components/video/YoutubeVideo.tsx @@ -1,34 +1,67 @@ import type { ReactElement } from 'react'; import React from 'react'; -import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { GdprConsentKey } from '../../hooks/useCookieBanner'; +import type { Source } from '../../graphql/sources'; +import { YoutubeVideoWithoutConsent } from './YoutubeVideoWithoutConsent'; +import { YoutubeVideoBackground, YoutubeVideoContainer } from './common'; +import { useConsentCookie } from '../../hooks/useCookieConsent'; interface YoutubeVideoProps { videoId: string; className?: string; title: string; + image: string; + source: Source; + onWatchVideo?: () => void; } const YoutubeVideo = ({ videoId, className, title, + image, + source, + onWatchVideo, ...props -}: YoutubeVideoProps): ReactElement => ( -
-