diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 08f9d983..acaa7f5c 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -41,7 +41,7 @@ jobs: while IFS= read -r line; do PACKAGE_PATHS+=("$line") done < <(mise exec -- pnpm list -r --depth -1 --json | jq -r '.[] | select(.private != true) | .path') - output="$(mise exec -- pnpx pkg-pr-new publish --packageManager=pnpm,yarn "${PACKAGE_PATHS[@]}" 2>&1)" + output="$(mise exec -- pnpx pkg-pr-new publish --pnpm --packageManager=pnpm,yarn "${PACKAGE_PATHS[@]}" 2>&1)" status=$? set -e diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f363377..661504ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,10 @@ */ "prettier.enable": false, "eslint.enable": false, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/.turbo": true + } } diff --git a/package.json b/package.json index 2c5a39df..c11f25c9 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lint:fix:root": "oxlint --fix .", "format:root": "oxfmt --write .", "format:check:root": "oxfmt --check .", - "check:deps:root": "syncpack lint --source \"package.json\" --source \"packages/*/package.json\" --source \"packages/mcp/*/package.json\"", + "check:deps:root": "syncpack lint --source \"package.json\" --source \"packages/*/package.json\" --source \"packages/airgap-react/*/package.json\" --source \"packages/mcp/*/package.json\"", "prepare": "husky" }, "devDependencies": { diff --git a/packages/airgap-react/airgap-nextjs/README.md b/packages/airgap-react/airgap-nextjs/README.md new file mode 100644 index 00000000..3654a585 --- /dev/null +++ b/packages/airgap-react/airgap-nextjs/README.md @@ -0,0 +1,241 @@ +# @transcend-io/airgap-nextjs + +Next.js helpers for loading Airgap and gating trackers. This package has the +same public features as `@transcend-io/airgap-react`, but uses `next/script` +for script rendering. + +Not using Next.js? Use +[`@transcend-io/airgap-react`](https://www.npmjs.com/package/@transcend-io/airgap-react) +instead. It provides the same public APIs without depending on `next/script`. + +## API Overview + +1. `useConsentManager()` hook: access the loaded Airgap APIs and re-render when + airgap.js is ready. + + ```tsx + const { airgap, transcend } = useConsentManager(); + ``` + +2. `` component: a `next/script` wrapper that waits for airgap.js + or any other promise before loading. + + ```tsx + + ``` + +3. `` component: like a + [``](https://react.dev/reference/react/Suspense) boundary, but it + waits until consent is granted before mounting children. See the + [ConsentBoundary demo](https://docs.transcend.io/docs/articles/consent-management/reference/react-snippets#add-a-consentboundary-react-component). + + ```tsx + + + + ``` + +## Asynchronously loading airgap.js + +> [!WARNING] +> If you load airgap.js asynchronously, Airgap can only regulate network traffic +> after it has loaded and is ready. Make sure no trackers load before airgap.js +> is ready. Replace tracking script elements with +> [``](#trackingscript), or condition script loads on the hook: +> +> ```tsx +> function TrackerLoader() { +> const { airgap } = useConsentManager(); +> +> useEffect(() => { +> if (!airgap) return; +> +> loadTrackers(); +> }, [airgap]); +> +> return null; +> } +> ``` + +## `useConsentManager()` hook + +`useConsentManager()` is a React hook that returns the loaded `airgap` and +`transcend` APIs. The hook re-renders when either API becomes ready. `airgap` and +`transcend` are `undefined` until each API is loaded and ready. + +Use the hook for component-level conditions, like enabling UI or loading trackers +from an effect once `airgap` is loaded and ready to regulate network traffic: + +```tsx +import { useEffect } from 'react'; +import { useConsentManager } from '@transcend-io/airgap-nextjs'; + +function TrackerLoader() { + const { airgap } = useConsentManager(); + + useEffect(() => { + if (!airgap) return; + + loadTrackers(); + }, [airgap]); + + return null; +} +``` + +If your app already loads airgap.js, call the hook directly and it will observe +the existing `self.airgap` and `self.transcend` globals: + +```tsx +import { useConsentManager } from '@transcend-io/airgap-nextjs'; + +export function PrivacyChoicesButton() { + const { transcend } = useConsentManager(); + + return ( + + ); +} +``` + +Use `ConsentProvider` when you want this package to load +[airgap.js **asynchronously**](#asynchronously-loading-airgapjs), using +`next/script`: + +```tsx +import { ConsentProvider, useConsentManager } from '@transcend-io/airgap-nextjs'; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function PrivacyChoicesButton() { + const { transcend } = useConsentManager(); + + return ( + + ); +} +``` + +## `` component + +`` is a React component around +[`next/script`](https://nextjs.org/docs/pages/api-reference/components/script). +It renders nothing until its `loadAfter` promise resolves, then renders the +underlying `; +} diff --git a/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.test.tsx b/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.test.tsx new file mode 100644 index 00000000..08fa11a3 --- /dev/null +++ b/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.test.tsx @@ -0,0 +1,38 @@ +import { ConsentProvider as ReactConsentProvider } from '@transcend-io/airgap-react'; +import type { ReactElement } from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { ConsentProvider } from './use-consent-manager.js'; + +vi.mock('next/script', () => ({ + default: function MockScript(): null { + return null; + }, +})); + +describe('ConsentProvider', () => { + test('renders Script with the provided airgap source', () => { + const result = ConsentProvider({ + airgapSrc: 'https://transcend-cdn.com/cm/example/airgap.js', + children: 'children', + scriptProps: { id: 'airgap', strategy: 'afterInteractive' }, + }) as ReactElement<{ children: ReactElement>[] }>; + const [scriptElement] = result.props.children; + + expect(scriptElement?.props.src).toBe('https://transcend-cdn.com/cm/example/airgap.js'); + expect(scriptElement?.props.id).toBe('airgap'); + expect(scriptElement?.props.strategy).toBe('afterInteractive'); + }); + + test('wraps children with the React consent provider', () => { + const result = ConsentProvider({ + airgapSrc: 'https://transcend-cdn.com/cm/example/airgap.js', + children: 'children', + }) as ReactElement<{ children: ReactElement>[] }>; + const [, reactProviderElement] = result.props.children; + + expect(reactProviderElement?.type).toBe(ReactConsentProvider); + expect(reactProviderElement?.props.children).toBe('children'); + expect(reactProviderElement?.props.airgapSrc).toBeUndefined(); + }); +}); diff --git a/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.tsx b/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.tsx new file mode 100644 index 00000000..f559466f --- /dev/null +++ b/packages/airgap-react/airgap-nextjs/src/next/use-consent-manager.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { + ConsentProvider as ReactConsentProvider, + type ConsentProviderProps as ReactConsentProviderProps, +} from '@transcend-io/airgap-react'; +import Script, { type ScriptProps } from 'next/script'; +import { type ReactElement } from 'react'; + +export interface ConsentProviderProps extends Pick { + /** airgap.js script URL from Transcend's developer settings. */ + airgapSrc: string; + /** Additional props forwarded to `next/script`. */ + scriptProps?: Omit; +} + +/** + * Provides loaded `airgap` and `transcend` APIs to React children while loading + * airgap.js through `next/script`. + */ +export function ConsentProvider({ + airgapSrc, + children, + scriptProps, +}: ConsentProviderProps): ReactElement { + return ( + <> +