diff --git a/cypress/e2e/footer.cy.ts b/cypress/e2e/footer.cy.ts index cd2c1b40..2034d986 100644 --- a/cypress/e2e/footer.cy.ts +++ b/cypress/e2e/footer.cy.ts @@ -3,20 +3,35 @@ import { HOME_PAGE } from '../support/common'; it('renders footer and error reporting', () => { cy.visit(HOME_PAGE).dataCy('error-reporting').should('exist'); - // Disallow error reporting - cy.dataCy('error-reporting').find('input').click(); - cy.findByText('Done').click(); + // Enable error report and analytics + cy.dataCy('error-reporting') + .findByRole('button', { name: /accept all/i }) + .click(); cy.dataCy('error-reporting') .should('not.exist') - .should(() => { - expect(localStorage.getItem('reportErrors')).to.equal('false'); + .then(() => { + expect(localStorage.getItem('allow-error-reporting')).to.equal('true'); + expect(localStorage.getItem('allow-analytics')).to.equal('true'); }); - // On subsequent page visit there is no notice - cy.reload().should(() => { - expect(localStorage.getItem('reportErrors')).to.equal('false'); + // On subsequent page visit the error reporting notice should not be shown + cy.reload().then(() => { + expect(cy.dataCy('error-reporting').should('not.exist')); }); + // Open the privacy settings modal from the footer and disable error reporting and analytics + cy.findByRole('button', { name: /error reporting/i }).click(); + cy.findByRole('button', { name: /manage settings/i }).click(); + cy.findByRole('checkbox', { name: /allow error reporting/i }).click(); + cy.findByRole('checkbox', { name: /allow analytics cookies/i }).click(); + cy.findByRole('button', { name: /save settings/i }).click(); + cy.dataCy('error-reporting') + .should('not.exist') + .then(() => { + expect(localStorage.getItem('allow-error-reporting')).to.equal('false'); + expect(localStorage.getItem('allow-analytics')).to.equal('false'); + }); + // Footer links should open the pages they link to in a new tab cy.findByText('Github').should('have.attr', 'target', '_blank'); }); diff --git a/cypress/support/common.ts b/cypress/support/common.ts index de3970c9..290a939a 100644 --- a/cypress/support/common.ts +++ b/cypress/support/common.ts @@ -33,8 +33,10 @@ export const abbrStr = (str: string) => { export const EPOCH_LENGTH = 7 * 60 * 60 * 24; // in seconds export const closeErrorReportingNotice = () => { - cy.dataCy('error-reporting').findByText('Done').click(); - cy.findByText('Done').should('not.exist'); + cy.dataCy('error-reporting') + .findByRole('button', { name: /accept all/i }) + .click(); + cy.dataCy('error-reporting').should('not.exist'); }; export const HOME_PAGE = 'http://localhost:3000/#/'; diff --git a/public/api-icon.svg b/public/api-icon.svg deleted file mode 100644 index ffe969c9..00000000 --- a/public/api-icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/components/button/button.module.scss b/src/components/button/button.module.scss index d9a8cd08..10b1648a 100644 --- a/src/components/button/button.module.scss +++ b/src/components/button/button.module.scss @@ -5,6 +5,7 @@ @import './variants/secondary-neutral.module.scss'; @import './variants/tertiary-color.module.scss'; @import './variants/link-blue.module.scss'; +@import './variants/link-gray.module.scss'; @import './variants/menu-link-secondary.module.scss'; @import './variants/text-blue.module.scss'; @@ -43,6 +44,10 @@ @include link-blue; } + &.link-gray { + @include link-gray; + } + &.menu-link-secondary { @include menu-link-secondary; } diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 1d4d5883..6855e725 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -19,7 +19,8 @@ export interface Props extends BreakpointsProps { | 'text' | 'text-blue' | 'menu-link-secondary' - | 'link-blue'; + | 'link-blue' + | 'link-gray'; size?: Size; disabled?: boolean; href?: string; diff --git a/src/components/button/variants/link-gray.module.scss b/src/components/button/variants/link-gray.module.scss new file mode 100644 index 00000000..929fad52 --- /dev/null +++ b/src/components/button/variants/link-gray.module.scss @@ -0,0 +1,36 @@ +@import '../../../styles/fonts.module.scss'; + +@mixin link-gray { + color: $color-gray-700; + height: 20px; + border: none; + + &.xs { + @include font-overline-2; + } + + &.sm { + @include font-link-3; + } + + &.md { + @include font-link-2; + } + + &.lg { + height: 24px; + @include font-link-1; + } + + &:hover { + color: $color-green-800; + } + + &:active { + color: $color-green-700; + } + + &:disabled { + color: $color-gray-400; + } +} diff --git a/src/components/checkbox/checkbox.module.scss b/src/components/checkbox/checkbox.module.scss new file mode 100644 index 00000000..fc8ebaf2 --- /dev/null +++ b/src/components/checkbox/checkbox.module.scss @@ -0,0 +1,123 @@ +@import '../../styles/variables.module.scss'; +@import '../../styles/fonts.module.scss'; + +.checkbox { + display: flex; + align-items: flex-start; + gap: 8px; + + &, + * { + cursor: pointer; + transition: all 0.1s; + } + + .checkmark { + display: flex; + align-items: center; + justify-content: center; + min-height: 16px; + min-width: 16px; + height: 16px; + width: 16px; + background-color: $color-gray-50; + border: 1px solid $color-dark-blue-50; + border-radius: 2px; + margin-top: 2px; + + svg { + margin: auto; + width: 12px; + height: 12px; + color: $color-gray-50; + } + } + + .checkboxTextBlock { + display: flex; + flex-direction: column; + @include font-body-12; + + label { + color: $color-dark-blue-800; + } + + .description { + color: $color-gray-500; + } + } + + &[aria-checked='true'] { + .checkmark { + background-color: $color-dark-blue-400; + border-color: $color-gray-50; + } + } + + &:hover:not([aria-disabled='true']) { + .checkmark { + background-color: $color-base-light; + border-color: $color-dark-blue-400; + + svg { + color: $color-dark-blue-400; + } + } + + .checkboxTextBlock { + label { + color: $color-gray-900; + } + + .description { + color: $color-gray-600; + } + } + } + + &[aria-disabled='true'] { + pointer-events: none; + + .checkmark { + background-color: transparent; + border-color: $color-gray-200; + + svg { + color: $color-gray-200; + } + } + + .checkboxTextBlock { + label { + color: $color-gray-400; + } + + .description { + color: $color-gray-200; + } + } + } +} + +@media (min-width: $sm) { + .checkbox { + gap: 12px; + + .checkmark { + min-height: 20px; + min-width: 20px; + height: 20px; + width: 20px; + margin-top: 2px; + + svg { + width: 16px; + height: 16px; + } + } + + .checkboxTextBlock { + @include font-body-9; + } + } +} diff --git a/src/components/checkbox/checkbox.tsx b/src/components/checkbox/checkbox.tsx new file mode 100644 index 00000000..eaa2e309 --- /dev/null +++ b/src/components/checkbox/checkbox.tsx @@ -0,0 +1,44 @@ +import { KeyboardEvent, ReactNode } from 'react'; +import { CheckIcon } from '../icons'; +import styles from './checkbox.module.scss'; + +interface Props { + label?: string; + checked: boolean; + children?: ReactNode; + disabled?: boolean; + onChange: (checked: boolean) => void; +} + +const CheckBox = ({ label, checked, children, disabled, onChange }: Props) => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onChange(!checked); + } + }; + + return ( + + ); +}; + +export default CheckBox; diff --git a/src/components/checkbox/index.ts b/src/components/checkbox/index.ts new file mode 100644 index 00000000..3f0e0071 --- /dev/null +++ b/src/components/checkbox/index.ts @@ -0,0 +1,2 @@ +export { default } from './checkbox'; +export * from './checkbox'; diff --git a/src/components/external-link/external-link.tsx b/src/components/external-link/external-link.tsx index 73b27ade..f9e7b281 100644 --- a/src/components/external-link/external-link.tsx +++ b/src/components/external-link/external-link.tsx @@ -1,32 +1,37 @@ -import { ReactNode, useEffect } from 'react'; +import { ComponentPropsWithoutRef, useEffect } from 'react'; -interface Props { - className?: string; +interface Props extends Omit, 'target' | 'rel'> { href: string; - children: ReactNode; } const ExternalLink = (props: Props) => { - const { className, children } = props; + const { children, href: incomingHref, ...rest } = props; - let href = props.href.trim(); - const urlRegex = /^https?:\/\//i; // Starts with https:// or http:// (case insensitive) - if (!urlRegex.test(href)) { - href = 'about:blank'; - } + const href = cleanHref(incomingHref); useEffect(() => { if (process.env.NODE_ENV === 'development' && href === 'about:blank') { // eslint-disable-next-line no-console - console.warn(`An invalid URL has been provided: "${props.href}". Only https:// or http:// URLs are allowed.`); + console.warn(`An invalid URL has been provided: "${incomingHref}". Only https:// or http:// URLs are allowed.`); } - }, [href, props.href]); + }, [href, incomingHref]); return ( - + {children} ); }; +const cleanHref = (href: string) => { + const urlRegex = /^https?:\/\//i; // Starts with https:// or http:// (case insensitive) + const trimmedHref = href.trim(); + + if (!urlRegex.test(trimmedHref)) { + return 'about:blank'; + } + + return trimmedHref; +}; + export default ExternalLink; diff --git a/src/components/icons/check.tsx b/src/components/icons/check.tsx new file mode 100644 index 00000000..8e66fe51 --- /dev/null +++ b/src/components/icons/check.tsx @@ -0,0 +1,15 @@ +import { ComponentProps } from 'react'; + +export const CheckIcon = (props: ComponentProps<'svg'>) => { + return ( + + + + ); +}; diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index b59c6200..815f1d5b 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -1,6 +1,7 @@ import { CheckCircleIcon } from './check-circle'; import { CheckCircleFillIcon } from './check-circle-fill'; import { CheckboxRadioIcon } from './checkbox-radio'; +import { CheckIcon } from './check'; import { CloseIcon } from './close'; import { CrossIcon } from './cross'; import { ErrorCircleIcon } from './error-circle'; @@ -11,6 +12,7 @@ import { InfoCircleIcon } from './info-circle'; export { CheckCircleIcon, CheckCircleFillIcon, + CheckIcon, CheckboxRadioIcon, CloseIcon, CrossIcon, diff --git a/src/components/layout/error-reporting-notice/error-reporting-notice.module.scss b/src/components/layout/error-reporting-notice/error-reporting-notice.module.scss index 8fe8b2d9..88dec47f 100644 --- a/src/components/layout/error-reporting-notice/error-reporting-notice.module.scss +++ b/src/components/layout/error-reporting-notice/error-reporting-notice.module.scss @@ -10,14 +10,9 @@ position: fixed; width: 100%; bottom: 0; - height: 221px; background: $color-dark-blue-700; align-items: center; - @media (min-width: $md) { - height: 92px; - } - &::before { content: ''; position: absolute; @@ -29,25 +24,12 @@ } } -.closeButton { - z-index: 1; - height: 24px; - align-self: flex-end; - margin: 20px 24px; - position: absolute; - color: $color-base-light; - cursor: pointer; - - &:hover { - color: $color-blue-25; - } -} - .content { padding: 64px 24px 32px 24px; display: flex; align-items: center; justify-content: space-around; + gap: 24px; flex: 1; flex-direction: column; @include font-body-15; @@ -62,62 +44,59 @@ } } -.buttons { - display: flex; +.externalLinkIcon { + margin-left: 4px; } -.checkboxWrapper { - min-width: 160px; // Make sure the checkbox and its label are on a single line - display: flex; - align-items: center; +.notice { + max-width: 550px; - label { - cursor: pointer; + a { + text-decoration: none; + @include link-blue; } +} - input { - $size: 16px; +.buttons { + display: flex; + justify-content: space-between; + width: 100%; - width: $size; - height: $size; - margin-right: $space-xs; - appearance: none; - border: none; - border-radius: 4px; - outline: none; - transition-duration: 0.3s; - background-color: $secondary-black-color; - cursor: pointer; + .manageSettingsButton { + padding-left: 0 !important; + padding-right: 0 !important; } - input:checked { - background-color: $green-color; + @media (min-width: $md) { + width: auto; + gap: 32px; } +} - input:checked::before { - content: '\2713'; - text-align: center; - color: $secondary-black-color; - display: flex; - justify-content: center; - align-items: center; - height: 100%; - } +.closeButton { + z-index: 1; + align-self: flex-end; + margin: 20px 24px; + position: absolute; + color: $color-base-light; + cursor: pointer; + padding: 0; + border: none; + background: none; - input:active { - border: 2px solid $secondary-black-color; + svg { + width: 24px; + height: 24px; } -} -.notice { - max-width: 550px; - - a { - text-decoration: none; - @include link-blue; + &:hover { + color: $color-blue-25; } -} -.externalLinkIcon { - margin-left: 4px; + @media (min-width: $md) { + svg { + width: 28px; + height: 28px; + } + } } diff --git a/src/components/layout/error-reporting-notice/error-reporting-notice.tsx b/src/components/layout/error-reporting-notice/error-reporting-notice.tsx index 756e53d8..b4065c25 100644 --- a/src/components/layout/error-reporting-notice/error-reporting-notice.tsx +++ b/src/components/layout/error-reporting-notice/error-reporting-notice.tsx @@ -1,66 +1,74 @@ -import { useRef, useState } from 'react'; -import { ERROR_REPORTING_CONSENT_KEY_NAME, images, isErrorReportingAllowed } from '../../../utils'; +import { useState } from 'react'; +import { images } from '../../../utils'; import Button from '../../button'; -import { triggerOnEnter } from '../../modal'; import styles from './error-reporting-notice.module.scss'; import ExternalLink from '../../external-link'; +import { links } from '../../../utils/links'; +import { ALLOW_ANALYTICS, ALLOW_ERROR_REPORTING, initAnalytics } from '../../../utils/analytics'; +import PrivacySettingsModal from '../privacy-settings-modal'; +import { CrossIcon } from '../../icons'; +import { initSentry } from '../../../utils/error-reporting'; interface WelcomeModalContentProps { - onClose: () => void; + onShowNotice: (showNotice: boolean) => void; } const ErrorReportingNotice = (props: WelcomeModalContentProps) => { - const { onClose } = props; - const defaultReportingValue = localStorage.getItem(ERROR_REPORTING_CONSENT_KEY_NAME); - const [errorReportingEnabled, setErrorReportingEnabled] = useState( - defaultReportingValue === null || isErrorReportingAllowed(defaultReportingValue) - ); - const onErrorReportingNoticeConfirm = () => { - localStorage.setItem(ERROR_REPORTING_CONSENT_KEY_NAME, errorReportingEnabled.toString()); - onClose(); + const { onShowNotice } = props; + + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleSubmit = (allowAnalytics: boolean, allowReporting: boolean) => { + localStorage.setItem(ALLOW_ERROR_REPORTING, allowReporting.toString()); + localStorage.setItem(ALLOW_ANALYTICS, allowAnalytics.toString()); + + if (allowAnalytics) { + initAnalytics(); + (window as any).clarity?.('consent'); + } + + if (allowReporting) { + initSentry(); + } + + onShowNotice(false); + setIsModalOpen(false); }; - const errorReportingRef = useRef(null); - const toggleCheckbox = () => setErrorReportingEnabled((checked) => !checked); return ( - // NOTE: Not using focus lock, because that would prevent user from signing in <> -
+ setIsModalOpen(false)} onSubmit={handleSubmit} /> + +
In order to provide the best services for you, we collect anonymized error data through{' '} - + Sentry - . We do not gather IP address or - user agent information. + and use analytics cookies to + improve our products.
-
- - -
- +
- confirm and close icon + +
); diff --git a/src/components/layout/layout.tsx b/src/components/layout/layout.tsx index 103b9763..c9710295 100644 --- a/src/components/layout/layout.tsx +++ b/src/components/layout/layout.tsx @@ -2,12 +2,12 @@ import { ReactNode, useState } from 'react'; import { Helmet } from 'react-helmet-async'; import Navigation from '../navigation'; import Header from '../header'; -import { ERROR_REPORTING_CONSENT_KEY_NAME } from '../../utils'; import styles from './layout.module.scss'; import Button from '../button'; import ErrorReportingNotice from './error-reporting-notice'; import { DesktopMenu } from '../menu'; import ExternalLink from '../external-link'; +import { ALLOW_ANALYTICS, ALLOW_ERROR_REPORTING } from '../../utils/analytics'; type Props = { children: ReactNode; @@ -30,13 +30,13 @@ interface BaseLayoutProps { } export const BaseLayout = ({ children, subtitle }: BaseLayoutProps) => { - const [errorReportingNoticeOpen, setErrorReportingNoticeOpen] = useState( - localStorage.getItem(ERROR_REPORTING_CONSENT_KEY_NAME) === null + const [showNotice, setShowNotice] = useState( + () => localStorage.getItem(ALLOW_ERROR_REPORTING) === null || localStorage.getItem(ALLOW_ANALYTICS) === null ); const footerLinks = [ { text: 'About API3', href: 'https://api3.org/' }, - { text: 'Error Reporting', onClick: () => setErrorReportingNoticeOpen(true) }, + { text: 'Error Reporting', onClick: () => setShowNotice(true) }, { text: 'Github', href: 'https://github.com/api3dao/api3-dao-dashboard' }, ]; @@ -53,8 +53,8 @@ export const BaseLayout = ({ children, subtitle }: BaseLayoutProps) => {
{children}