Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
241 changes: 241 additions & 0 deletions packages/airgap-react/airgap-nextjs/README.md
Original file line number Diff line number Diff line change
@@ -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. `<TrackingScript>` component: a `next/script` wrapper that waits for airgap.js
or any other promise before loading.

```tsx
<TrackingScript
src="https://cdn.example.com/analytics.js"
strategy="afterInteractive"
loadAfter={loadAfter}
/>
```

3. `<ConsentBoundary>` component: like a
[`<Suspense>`](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
<ConsentBoundary urlsRequiredForRender={[videoUrl]} fallback={ConsentFallback}>
<VideoPlayer src={videoUrl} />
</ConsentBoundary>
```

## 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>`](#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 (
<button
type="button"
disabled={!transcend}
onClick={() => void transcend?.showConsentManager()}
>
Privacy choices
</button>
);
}
```

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 (
<ConsentProvider
airgapSrc="https://transcend-cdn.com/cm/<bundle-id>/airgap.js"
scriptProps={{ strategy: 'afterInteractive' }}
>
{children}
</ConsentProvider>
);
}

export function PrivacyChoicesButton() {
const { transcend } = useConsentManager();

return (
<button
type="button"
disabled={!transcend}
onClick={() => void transcend?.showConsentManager()}
>
Privacy choices
</button>
);
}
```

## `<TrackingScript>` component

`<TrackingScript>` 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 `<Script>`.

Create the promise outside render so React does not restart the gate effect on
every render.

```tsx
import { airgapReady, TrackingScript } from '@transcend-io/airgap-nextjs';

const loadAfter = airgapReady();

export function AnalyticsScript() {
return (
<TrackingScript
src="https://cdn.example.com/analytics.js"
strategy="afterInteractive"
loadAfter={loadAfter}
/>
);
}
```

`strategy="beforeInteractive"` is unsupported because `TrackingScript` gates
script injection after hydration.

### `airgapReady()` helper

`airgapReady()` is a helper function that returns a `Promise<AirgapAPI>` that
resolves when `self.airgap.ready(...)` fires. It is primarily intended for the
`loadAfter` prop on `<TrackingScript>`.

If airgap.js has not loaded yet, `airgapReady()` creates a ready-queue stub so
the callback is drained when airgap.js initializes. For component logic, prefer
`useConsentManager()` so React re-renders when `airgap` becomes available.

```tsx
import { airgapReady, TrackingScript } from '@transcend-io/airgap-nextjs';

const airgapSyncPromise = airgapReady().then((airgap) => {
return new Promise<void>((resolve) => {
airgap.addEventListener('sync', () => resolve(), { once: true });
});
});

<TrackingScript
src="https://cdn.example.com/analytics.js"
strategy="afterInteractive"
loadAfter={airgapSyncPromise}
/>;
```

## `<ConsentBoundary>` component

`<ConsentBoundary>` is a React component re-exported from
`@transcend-io/airgap-react` so it shares the same behavior in both packages. It
is similar to
[`<Suspense>`](https://react.dev/reference/react/Suspense): it displays a
fallback while work is pending, then reveals its children when they are ready.
Instead of waiting for code or data, it waits until Airgap allows the URLs needed
by the subtree.

This is a good fit for embeds, videos, analytics widgets, and other components
that connect to services requiring consent.

<video controls src="https://cdn.sanity.io/files/1ievmmav/production/40de95dcbe5e1c0406493c96b708590f505d5db8.webm"></video>

It uses `useConsentManager()`, so it works with `ConsentProvider` or with Airgap
globals loaded separately. The fallback receives the missing consent purposes
and an `onConsentGiven` handler that opts into those purposes.

```tsx
import { ConsentBoundary } from '@transcend-io/airgap-nextjs';

export function VideoBoundary({ videoUrl }: { videoUrl: string }) {
return (
<ConsentBoundary
urlsRequiredForRender={[videoUrl]}
fallback={({ missingConsentPurposes, onConsentGiven, status }) => {
if (status === 'pending') {
return <p>Checking consent...</p>;
}

return (
<button type="button" onClick={onConsentGiven}>
Allow {Array.from(missingConsentPurposes).join(', ')} trackers
</button>
);
}}
>
<VideoPlayer src={videoUrl} />
</ConsentBoundary>
);
}
```
61 changes: 61 additions & 0 deletions packages/airgap-react/airgap-nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "@transcend-io/airgap-nextjs",
"version": "0.0.0",
"description": "Next.js components for the Transcend Airgap library.",
"homepage": "https://github.com/transcend-io/tools/tree/main/packages/airgap-react/airgap-nextjs",
"license": "Apache-2.0",
"author": "Transcend Inc.",
"repository": {
"type": "git",
"url": "https://github.com/transcend-io/tools.git",
"directory": "packages/airgap-react/airgap-nextjs"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"types": "./dist/index.d.mts",
"exports": {
".": {
"@transcend-io/source": "./src/index.ts",
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check:exports": "attw --pack . --ignore-rules cjs-resolves-to-esm",
"check:publint": "publint --level warning --strict --pack pnpm"
},
"dependencies": {
"@transcend-io/airgap-react": "workspace:*",
"@transcend-io/airgap.js-types": "workspace:*"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"next": "catalog:",
"publint": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"engines": {
"node": ">=22.12.0"
}
}
12 changes: 12 additions & 0 deletions packages/airgap-react/airgap-nextjs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';

export { airgapReady, ConsentBoundary, useConsentManager } from '@transcend-io/airgap-react';
export type {
ConsentAPI,
ConsentBoundaryFallbackProps,
ConsentBoundaryProps,
} from '@transcend-io/airgap-react';
export { ConsentProvider } from './next/use-consent-manager.js';
export type { ConsentProviderProps } from './next/use-consent-manager.js';
export { TrackingScript } from './next/tracking-script.js';
export type { TrackingScriptProps } from './next/tracking-script.js';
Loading
Loading