diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1dbfd72 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals", "prettier"], + "rules": { + "@next/next/no-html-link-for-pages": ["off"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e6c585 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# outpu +*.log +.DS_Store +node_modules +.cache +dist +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..293cd94 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +*.log +.DS_Store +node_modules +.cache +*.lock \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b96d651 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Marco Lipparini + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2da86f --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# Next-Iubenda + +Do you use the [Iubenda](https://www.iubenda.com/)'s Cookie Solution, but have trouble integrating it properly into your Next.js projects? Then this is the tool for you. + +Our goal is to create a simple, yet powerful tool to do the following: + +- Display Iubenda's consent banner, with the ability to customize every single configuration option +- Make sure we can prevent code execution of components that may require specific consent if it's not granted, as well as have a fallback widget that provides users with a call to action to change their preferences +- Take advantage of support for server-side and client-side components to improve performance +- Provide developers with a good DX thanks to features like + - Type safety for configuration options + - Templates for basic needs and hooks for advanced use cases + - reasonable styling defaults, but full customization options + +This package gives you these features with minimal effort and an extremely small footprint on your codebase. + +# Missing features + +- **Support to regulations other than the European GDPR** + Iubenda does a great job of supporting many regulations and helping developers make sure their websites/apps can be compliant worldwide. But this tool is currently limited to the European GDPR because that's what we know best. **Any help in adding support for additional features is appreciated.** +- **Shipping the package as pre-compiled** + Currently, this package is shipped as plain TypeScript files. While this doesn't seem to be a problem and may allow Next.js to better optimize the code, it requires the "transpilePackages" option to be enabled for this package, which may not be ideal. **Any suggestions/contributions in this regard are appreciated.** + +# Project lifecycle, contribution and support policy + +Our policies are available on our [main oranization page](https://github.com/mep-agency#projects-lifecycle-contribution-and-support-policy). + +## Original authors + +- Marco Lipparini ([liarco](https://github.com/liarco)) +- Ivan Balmita ([ivanbalmita](https://github.com/ivanbalmita)) + +## Getting started + +Simply install the package using any package manager: + +```bash +# With Yarn +$ yarn add --dev @mep-agency/next-iubenda + +# With NPM +$ npm install --save-dev @mep-agency/next-iubenda +``` + +At the moment this package is distributed as plain TypeScript code so you have to make sure Next.js will transpile it: + +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['@mep-agency/next-iubenda'], +}; + +module.exports = nextConfig; +``` + +Update your main layout file to wrapp any part of the page which will contain consent-aware components. + +Here is an example with the App Router: + +```tsx +// ./app/layout.tsx + +import './globals.css'; +import { Inter } from 'next/font/google'; +import { + IubendaConsentProvider, + IubendaConsentSolutionBannerConfigInterface, + i18nDictionaries, +} from '@mep-agency/next-iubenda'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +const iubendaBannerConfig: IubendaConsentSolutionBannerConfigInterface = { + siteId: 0000000, // Your site ID + cookiePolicyId: 00000000, // Your cookie policy ID + lang: 'en', + + // See https://www.iubenda.com/en/help/1205-how-to-configure-your-cookie-solution-advanced-guide +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + + + // Any component at this level will have access to useIubendaConsent() + {children} + + + + + ); +} +``` + +Now you can simply wrap any components with the ``: + +```tsx +// ./app/page.tsx + +import { ConsentAwareWrapper } from '@mep-agency/next-iubenda'; + +// ... +export default function Home() { + return ( +
+ {/* ... */} + + {/* Anything at this level won't be rendered unless the required permissions have been granted */} + Both "experience" and "marketing" cookies can be used! + + {/* // ... */} +
+ ); +} +// ... +``` + +If you need more flexibility then you can use the `useIubendaConsent()` hook: + +```tsx +'use client'; + +import { useIubendaConsent } from '@mep-agency/next-iubenda'; + +const ConsentAwareComponent = () => { + const { + consent, // The latest available consent data + showCookiePolicy, // Displays the cookie policy popup + openPreferences, // Opens the preferences panel + showTcfVendors, // Opens the TCF vendors panel + resetCookies, // Resets all cookies managed by Iubenda + + /* + * The following exposed entries are meant for internal use only and should + * not be used in your projects. + */ + dispatchConsent, // Update the consent data across the app + i18nDictionary, // Contains the translations for the built-in components + } = useIubendaConsent(); + + return ( +
+ Consent status:{' '} + + {consent.hasBeenLoaded ? 'LOADED' : 'LOADING...'} + +
+ ); +}; + +export default ConsentAwareComponent; +``` diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..7b7aa2c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/package.json b/package.json new file mode 100644 index 0000000..910412d --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@mep-agency/next-iubenda", + "version": "1.0.0-alpha0", + "private": false, + "description": "A React library for integrating Iubenda's cookie solution into Next.js projects", + "source": "src/index.tsx", + "main": "src/index.tsx", + "sideEffects": false, + "scripts": { + "lint": "eslint \"**/*.{ts,tsx,js}\" && prettier --check \"**/*.{ts,tsx,md,scss,css,js}\"", + "format": "prettier --write \"**/*.{ts,tsx,md,scss,css,js}\"" + }, + "files": [ + "src", + "LICENCE", + "README.md" + ], + "exports": { + ".": "./src/index.tsx" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/mep-agency/next-iubenda.git" + }, + "keywords": [ + "next", + "next.js", + "react", + "redirect", + "link", + "router" + ], + "author": "Marco Lipparini ", + "license": "MIT", + "bugs": { + "url": "https://github.com/mep-agency/next-iubenda/issues" + }, + "peerDependencies": { + "next": ">= 13.0.0", + "react": ">= 18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.21", + "eslint": "8.43.0", + "eslint-config-next": "^13.4.7", + "eslint-config-prettier": "^8.8.0", + "next": "^13.4.7", + "prettier": "^2.8.8", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "^5.1.3" + } +} diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 0000000..d869e40 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,5 @@ +module.exports = { + printWidth: 125, + trailingComma: 'all', + singleQuote: true, +}; diff --git a/src/components/ConsentAwareWrapper/index.tsx b/src/components/ConsentAwareWrapper/index.tsx new file mode 100644 index 0000000..266f5a7 --- /dev/null +++ b/src/components/ConsentAwareWrapper/index.tsx @@ -0,0 +1,133 @@ +'use client'; + +import styles from './styles.module.scss'; + +import { CSSProperties, ReactNode, useEffect, useReducer, useState } from 'react'; + +import { IubendaConsentSolutionContext, useIubendaConsent } from '../../contexts/IubendaConsentSolutionContext'; + +const DEFAULT_WRAPPER_CLASS = 'mep-next-iubenda-wrapper'; + +type RequiredPurposes = (keyof IubendaConsentSolutionContext['consent']['purposes'])[]; + +interface Props { + className?: string; + useDefaultStyles?: boolean; + requiredPurposes: RequiredPurposes; + customLoadingNodes?: ReactNode; + customConsentNotGrantedNodes?: ReactNode; + style?: CSSProperties; + children: ReactNode; +} + +interface StateInterface { + isLoading: boolean; + isEnabled: boolean; + requiredPurposes: RequiredPurposes; +} + +interface UpdateRequiredPurposesActionInterface { + type: 'update_required_purposes'; + requiredPurposes: RequiredPurposes; +} + +interface UpdateConsentActionInterface { + type: 'update_consent'; + consent: IubendaConsentSolutionContext['consent']; +} + +type Action = UpdateRequiredPurposesActionInterface | UpdateConsentActionInterface; + +const initialState: StateInterface = { + isLoading: true, + isEnabled: false, + requiredPurposes: [], +}; + +const reducer = (state: StateInterface, action: Action): StateInterface => { + switch (action.type) { + case 'update_required_purposes': + return { + ...state, + requiredPurposes: action.requiredPurposes, + }; + case 'update_consent': + return { + ...state, + isLoading: false, + isEnabled: state.requiredPurposes.reduce( + (isEnabled, purposeName) => isEnabled && action.consent.purposes[purposeName], + true, + ), + }; + default: + return state; + } +}; + +const ConsentAwareWrapper = ({ + className, + useDefaultStyles, + requiredPurposes, + customLoadingNodes, + customConsentNotGrantedNodes, + style, + children, +}: Props) => { + const { consent, openPreferences, i18nDictionary: t } = useIubendaConsent(); + const [state, dispatch] = useReducer(reducer, initialState); + const [computedClassName, setComputedClassName] = useState(); + + useEffect(() => { + if (requiredPurposes.length < 1) { + throw new Error('Required purposes array cannot be empty!'); + } + + dispatch({ + type: 'update_required_purposes', + requiredPurposes, + }); + }, [requiredPurposes]); + + useEffect(() => { + if (consent.hasBeenLoaded === true) { + dispatch({ + type: 'update_consent', + consent, + }); + } + }, [consent, state.requiredPurposes]); + + useEffect(() => { + const classNames: string[] = [DEFAULT_WRAPPER_CLASS]; + + if (useDefaultStyles === undefined || useDefaultStyles === true) { + classNames.push(styles.wrapper); + } + + if (className !== undefined && className.length > 0) { + classNames.push(className); + } + + setComputedClassName(classNames.join(' ')); + }, [className, useDefaultStyles]); + + return computedClassName === undefined ? ( + <> + ) : ( +
+ {state.isEnabled === true + ? children + : state.isLoading === true + ? customLoadingNodes ??
{t.consentAwareWrapper.loading}
+ : customConsentNotGrantedNodes ?? ( +
+ {t.consentAwareWrapper.consentNotGranted} + +
+ )} +
+ ); +}; + +export default ConsentAwareWrapper; diff --git a/src/components/ConsentAwareWrapper/styles.module.scss b/src/components/ConsentAwareWrapper/styles.module.scss new file mode 100644 index 0000000..4b97c12 --- /dev/null +++ b/src/components/ConsentAwareWrapper/styles.module.scss @@ -0,0 +1,51 @@ +.wrapper { + @media (prefers-color-scheme: light) { + --box-border-color: #e5e7eb; + --color: #1f2937; + --bg-color: #f3f4f6; + --btn-color: #1f2937; + --btn-bg-color: #e5e7eb; + --btn-color-hover: #1f2937; + --btn-bg-color-hover: #d1d5db; + } + + @media (prefers-color-scheme: dark) { + --box-border: #111827; + --color: #f3f4f6; + --bg-color: #111827; + --btn-color: #f3f4f6; + --btn-bg-color: #1f2937; + --btn-color-hover: #f3f4f6; + --btn-bg-color-hover: #334155; + } + + & > :global(.mep-next-iubenda-loading), + & > :global(.mep-next-iubenda-consent-not-granted) { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1rem 2rem; + width: 100%; + + text-align: center; + color: var(--color); + border-radius: 0.375rem; + background-color: var(--bg-color); + border: 1px solid var(--box-border-color); + + button { + padding: 0.5rem 1rem; + + font-size: 0.9rem; + color: var(--btn-color); + border-radius: 0.375rem; + background-color: var(--btn-bg-color); + + &:hover { + color: var(--btn-color-hover); + background-color: var(--btn-bg-color-hover); + } + } + } +} diff --git a/src/components/IubendaConsentSolutionBanner.tsx b/src/components/IubendaConsentSolutionBanner.tsx new file mode 100644 index 0000000..0258d28 --- /dev/null +++ b/src/components/IubendaConsentSolutionBanner.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useEffect } from 'react'; +import Script from 'next/script'; + +import { useIubendaConsent as useIubendaConsentSolutionContext } from '../contexts/IubendaConsentSolutionContext'; + +type TcfPurposesKeys = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10'; +type HexColor = `#${string}`; + +export interface IubendaConsentSolutionBannerConfigInterface { + // See https://www.iubenda.com/en/help/1205-how-to-configure-your-cookie-solution-advanced-guide + + // Required + siteId: number; + cookiePolicyId: number; + lang: string; + + // GDPR + countryDetection?: boolean; + enableGdpr?: boolean; + gdprAppliesGlobally?: boolean; + gdprApplies?: boolean; + + // GDPR > Per-category consent + perPurposeConsent?: boolean; + purposes?: string; + + // US State laws + enableUspr?: boolean; + usprApplies?: boolean; + usprPurposes?: string; + showBannerForUS?: boolean; + noticeAtCollectionUrl?: string; + + // US State laws > CCPA + enableCcpa?: boolean; + ccpaApplies?: boolean; + ccpaNoticeDisplay?: boolean; + ccpaAcknowledgeOnDisplay?: boolean; + ccpaAcknowledgeOnLoad?: boolean; + ccpaLspa?: boolean; + + // LGPD + enableLgpd?: boolean; + lgpdAppliesGlobally?: boolean; + lgpdApplies?: boolean; + + // IAB Transparency and Consent Framework + enableTcf?: boolean; + googleAdditionalConsentMode?: boolean; + tcfPurposes?: { + [key in TcfPurposesKeys]?: 'consent_not_needed' | false | 'li_only' | 'consent_only'; + }; + askConsentIfCMPNotFound?: boolean; + newConsentAtVendorListUpdate?: number; + tcfPublisherCC?: string; + acceptTcfSpecialFeaturesWithAcceptBtn?: boolean; + + banner?: { + // GDPR > Buttons + acceptButtonDisplay?: boolean; + customizeButtonDisplay?: boolean; + rejectButtonDisplay?: boolean; + closeButtonDisplay?: boolean; + closeButtonRejects?: boolean; + explicitWithdrawal?: boolean; + + // GDPR > Per-category consent + listPurposes?: boolean; + showPurposesToggles?: boolean; + + // Style and text + + // Style and text > Format and Position + position?: + | 'top' + | 'bottom' + | 'float-top-left' + | 'float-top-right' + | 'float-bottom-left' + | 'float-bottom-right' + | 'float-top-center' + | 'float-bottom-center' + | 'float-center'; + backgroundOverlay?: boolean; + // Style and text > Theme + + // Style and text > Theme > Logo + logo?: string; + brandTextColor?: HexColor; + brandBackgroundColor?: HexColor; + + // Style and text > Theme > Banner colors + backgroundColor?: HexColor; + textColor?: HexColor; + + // Style and text > Theme > Buttons + acceptButtonColor?: HexColor; + acceptButtonCaptionColor?: HexColor; + customizeButtonColor?: HexColor; + customizeButtonCaptionColor?: HexColor; + rejectButtonColor?: HexColor; + rejectButtonCaptionColor?: HexColor; + continueWithoutAcceptingButtonColor?: HexColor; + continueWithoutAcceptingButtonCaptionColor?: HexColor; + + // Style and text > Theme > Advanced settings + applyStyles?: boolean; + zIndex?: number; + + // Style and text > Text + + // Style and text > Text > Font size + fontSize?: string; + fontSizeCloseButton?: string; + fontSizeBody?: string; + + // Style and text > Text > Banner copy + // See https://www.iubenda.com/en/help/1205-how-to-configure-your-cookie-solution-advanced-guide#text-banner-copy + content?: string; + acceptButtonCaption?: string; + customizeButtonCaption?: string; + rejectButtonCaption?: string; + closeButtonCaption?: string; + continueWithoutAcceptingButtonCaption?: boolean; + useThirdParties?: boolean; + + // Style and text > Text > Advanced settings + html?: string; + + // Style and text > Text > Footer + footer?: { + btnCaption?: string; + }; + + // Style and text > Text > i18n + // See the following: + // - https://cdn.iubenda.com/cs/i18n.json (Current channel) + // - https://cdn.iubenda.com/cs/beta/i18n.json (Beta channel) + // - https://cdn.iubenda.com/cs/stable/i18n.json (Stable channel) + i18n?: any; + + // Privacy and cookie policy + cookiePolicyLinkCaption?: string; + + // Advanced settings > Banner settings + slideDown?: boolean; + prependOnBody?: boolean; + }; + + // Style and text > Consent widget + floatingPreferencesButtonDisplay?: + | boolean + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'anchored-center-left' + | 'anchored-center-right' + | 'anchored-top-left' + | 'anchored-top-right' + | 'anchored-bottom-left' + | 'anchored-bottom-right'; + + // Style and text > Consent widget > Format and position + floatingPreferencesButtonCaption?: string; + floatingPreferencesButtonIcon?: boolean; + floatingPreferencesButtonHover?: boolean; + floatingPreferencesButtonRound?: boolean; + floatingPreferencesButtonZIndex?: number; + + // Style and text > Consent widget > Colors + floatingPreferencesButtonColor?: HexColor; + floatingPreferencesButtonCaptionColor?: HexColor; + + // Privacy and cookie policy + privacyPolicyUrl?: string; + cookiePolicyUrl?: string; + cookiePolicyInOtherWindow?: boolean; + + // Advanced settings + + // Advanced settings > Consent collection settings + reloadOnConsent?: boolean; + askConsentAtCookiePolicyUpdate?: boolean; + enableRemoteConsent?: boolean; + invalidateConsentWithoutLog?: boolean | `${number}-${number}-${number}`; + googleConsentMode?: boolean | 'template'; + + // Development + inlineDelay?: number; + consentOnScrollDelay?: number; + rebuildIframe?: boolean; + + // Development > Callbacks + // Custom callbacks are not supported by the IubendaConsentContext. + + // Development > Debugging + skipSaveConsent?: boolean; + logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'nolog'; + + preferenceCookie?: { + // Development > Cookie expiration + expireAfter?: number; + }; + + ccpaCookie?: { + // Development > Cookie expiration + expireAfter?: number; + }; + + // Development > Local consent domain and path + localConsentDomain?: string; + localConsentDomainExact?: boolean; + localConsentPath?: string; + + // Development > Further parameters + whitelabel?: boolean; + invalidateConsentBefore?: number | `${number}-${number}-${number}`; + maxCookieSize?: number; + maxCookieChunks?: number; + timeoutLoadConfiguration?: number; + startOnDomReady?: boolean; +} + +interface Props { + config: IubendaConsentSolutionBannerConfigInterface; +} + +const IubendaConsentSolutionBanner = ({ config }: Props) => { + const { dispatchConsent } = useIubendaConsentSolutionContext(); + + useEffect(() => { + (window as any)._iub = (window as any)._iub || []; + (window as any)._iub.csConfiguration = config; + (window as any)._iub.csConfiguration.callback = { + onPreferenceExpressedOrNotNeeded: function (preference: any) { + if (!preference) { + dispatchConsent({ type: 'not_needed' }); + + return; + } else { + dispatchConsent({ + type: 'update', + rawData: preference, + }); + } + }, + }; + }, [config, dispatchConsent]); + + return ( + <> +