React Snippets for Airgap

This documentation site uses React and Next.js. Here are some snippets and recipes for how use airgap.js in this codebase:

lib/hooks/useConsentManager.tsx:

import { createContext, useContext, useEffect, useState } from 'react';
import Script from 'next/script';
import type {
  AirgapAPI,
  PreInitTranscendAPI,
  TranscendAPI,
} from '@transcend-io/airgap.js-types';

/** A stub for `window.airgap` to be used before airgap is actually loaded */
type PreInitAirgapAPI = Required<Pick<AirgapAPI, 'readyQueue' | 'ready'>>;

/**
 * Add `window.airgap` and `window.transcend` to the global namespace
 */
declare global {
  interface Window {
    /** airgap.js interface */
    airgap?: PreInitAirgapAPI | AirgapAPI;
    /** Transcend consent manager interface */
    transcend?: PreInitTranscendAPI | TranscendAPI;
  }
}

/**
 * The `airgap` and `transcend` consent management APIs
 *
 * @example const { airgap, transcend } = useConsentManager();
 */
interface ConsentAPI {
  /** The `airgap` consent management instance */
  airgap?: AirgapAPI;
  /** The `transcend` consent manager UI instance */
  transcend?: TranscendAPI;
}

interface ConsentProviderProps {
  /** The children of this context provider */
  children: React.ReactNode;
  /** The src URL from the Transcend CDN */
  airgapSrc: string;
}

/** The React context by the `useConsentManager()` hook */
export const ConsentContext = createContext<ConsentAPI>({});

/**
 * React context provider for `window.airgap` and `window.transcend`
 * @see https://docs.transcend.io/docs/consent/faq
 */
export const ConsentProvider: React.FC<ConsentProviderProps> = ({
  children,
  airgapSrc,
}) => {
  const [airgap, setAirgap] = useState<AirgapAPI | undefined>();
  const [transcend, setTranscend] = useState<TranscendAPI | undefined>();

  // `useEffect` ensures this is only executed in browser
  useEffect(() => {
    // Stub transcend with PreInit API
    if (!self.transcend?.ready) {
      const preInitTranscend: PreInitTranscendAPI = {
        readyQueue: [],
        ready(callback) {
          this.readyQueue.push(callback);
        },
        ...self.transcend,
      };
      self.transcend = preInitTranscend;
    }

    // Stub airgap with PreInit API
    if (!self.airgap?.ready) {
      const preInitAirgap: PreInitAirgapAPI = {
        readyQueue: [],
        ready(callback) {
          this.readyQueue.push(callback);
        },
        ...self.airgap,
      };
      self.airgap = preInitAirgap;
    }

    // Wait for consent manager UI to load, and set it in the React state
    if (!transcend) {
      self.transcend.ready((transcend) => {
        setTranscend(transcend);
      });
    }

    // Wait for airgap.js core to load, and set it in the React state
    if (!airgap) {
      self.airgap.ready((airgap) => {
        setAirgap(airgap);
      });
    }
  }, [airgap, transcend]);

  return (
    <>
      <Script src={airgapSrc} />
      <ConsentContext.Provider value={{ airgap, transcend }}>
        {children}
      </ConsentContext.Provider>
    </>
  );
};

export const useConsentManager = (): ConsentAPI => useContext(ConsentContext);

And wrap your application in the consent provider.

pages/_app.tsx:

import { ConsentProvider } from '../lib/hooks/useConsentManager';

export default function MyApp({ Component, pageProps }) {
  return (
    // Replace `airgapSrc` with your bundle URL
    <ConsentProvider airgapSrc="https://transcend-cdn.com/cm/f8d8c73f-3189-4ab4-c138-fa9922f76812/airgap.js">
      <Component {...pageProps} />;
    </ConsentProvider>
  );
}

Now your components can interact with window.airgap and the consent manager UI (window.transcend) by calling const { transcend, airgap } = useConsentManager();. See the next snippet for an example!

Beware: you are now importing airgap.js asynchronously. Since airgap.js can only regulate network traffic after it is loaded, you are now responsible for ensuring no trackers load before airgap.js! Here is an example of how you could asynchronously load @segment/analytics-next using your new useConsentManager hook:

import { AnalyticsBrowser } from '@segment/analytics-next';

const analytics = new AnalyticsBrowser();

export const AnalyticsProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const { airgap } = useConsentManager();

  // Load analytics.js
  useEffect(() => {
    // Make sure Airgap is enabled before attempting the import
    if (airgap) {
      const writeKey = process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY;
      // Load Segment analytics
      analytics.load({ writeKey });
    }
  }, [airgap]);

  // ...
};

This snippet is based on the Next.js analytics-next example.

Add California's CCPA/CPRA "Your Privacy Choices" opt-out icon to your website footer (replacing the "Do Not Sell or Share My Personal Information" link).

Your Privacy Choices CPRA opt out icon

components/YourPrivacyChoices.tsx:

import React, { useEffect, useState } from 'react';

import { useConsentManager } from '../lib/hooks/consent-manager';
/**
 * This import assumes you are using `@svgr/webpack` to load SVGs as React components
 * @see https://www.npmjs.com/package/@svgr/webpack
 * @see https://react-svgr.com/
 */
import PrivacyChoicesIcon from './privacy-choices-icon.svg';

export interface YourPrivacyChoicesProps {}

export const YourPrivacyChoicesInterior: React.FC<
  JSX.IntrinsicElements['button']
> = ({ onClick }) => {
  return (
    <>
      <button onClick={onClick} className="privacy-choices-button">
        <div className="privacy-choices-button-layout">
          <PrivacyChoicesIcon className="privacy-choices-icon" />
          <span>Your Privacy Choices</span>
        </div>
      </button>
      <style jsx>{`
        .privacy-choices-button-layout {
          display: flex;
          align-items: center;
        }

        .privacy-choices-button {
          all: unset;
        }

        .privacy-choices-button {
          cursor: pointer;
          height: var(--lineHeight);
        }

        .privacy-choices-button-layout :global(.privacy-choices-icon) {
          padding-right: 0.4em;
          height: 0.9em;
        }
      `}</style>
    </>
  );
};

/**
 * CPRA Your Privacy Choices button
 *
 * @see https://cppa.ca.gov/regulations/pdf/20221102_mod_text.pdf
 * @see https://oag.ca.gov/privacy/ccpa/icons-download
 */
export const YourPrivacyChoices: React.FC<YourPrivacyChoicesProps> = ({}) => {
  const { transcend, airgap } = useConsentManager();
  const [consentEnabled, setConsentEnabled] = useState<boolean>(false);

  useEffect(() => {
    // Show the 'Your Privacy Choices' button based on `airgap.getRegimes`
    function getConsentEnabled(): boolean {
      if (!airgap) {
        return false;
      }
      const regimes = airgap.getRegimes();

      // We display this button if CPRA is present in the detected regimes
      const consentEnabled =
        typeof regimes !== 'undefined' && regimes.has('CPRA');

      return consentEnabled;
    }
    setConsentEnabled(getConsentEnabled());
  }, [airgap]);

  if (!consentEnabled) {
    return null;
  }

  return (
    <YourPrivacyChoicesInterior
      onClick={() =>
        transcend?.showConsentManager({ viewState: 'CompleteOptions' })
      }
    />
  );
};

And lastly, here is a download link for a compressed + optimized SVG of the CPRA opt out icon (imported above as './privacy-choices-icon.svg')

Similar to a <Suspense> boundary, this <ConsentBoundary> component lets you display a fallback component until a user has given consent. This is a good option for components which connect to services that require consent.

Usage:

<ConsentBoundary
  urlsRequiredForRender={[props.videoUrl]}
  fallback={({ missingConsentPurposes, onConsentGiven }) => (
    <div>
      <p>
        This media requires consent for {[...missingConsentPurposes].join(', ')}
      </p>
      <button onClick={onConsentGiven}>Opt in</button>
    </div>
  )}
>
  <ReactPlayer url={props.videoUrl} />
</ConsentBoundary>

Here's an example of that ConsentBoundary component. It uses Next.js and TailwindCSS, but you can make it your own.

components/ConsentBoundary.tsx:

import React, { MouseEventHandler, useEffect, useState } from 'react';
import Link from 'next/link';
import { AirgapAPI, TrackingConsent } from '@transcend-io/airgap.js-types';

import { useConsentManager } from '@/lib/consent-manager'; // see `useConsentManager` snippet

import { Button } from './Button';
import { TranscendLogo } from './TranscendLogo';

export interface FallbackProps {
  /** A set of consent purposes that are required but missing for the `urlsRequiredForRender` */
  missingConsentPurposes: Set<string>;
  /** The onClick event if the user opts in, which sets airgap.setConsent(event.nativeEvent, targetTrackingConsent) */
  onConsentGiven: MouseEventHandler;
}

/**
 * For a given set of URLs, get the purposes that are missing consent to connect to those URLs.
 */
async function getMissingConsentPurposesForUrls(
  airgap: AirgapAPI,
  urls: string[],
): Promise<FallbackProps['missingConsentPurposes']> {
  // A set of missing consent purposes for the current user.
  const missingConsentPurposes = new Set<string>();
  await Promise.all(
    urls.map(async (url) => {
      // @ts-expect-error this method isn't on the published types
      const requestIsAllowed: boolean = await airgap.isAllowed(url);
      if (requestIsAllowed) return;

      // @ts-expect-error this method isn't on the published types
      const purposesRequired: Set<string> = await airgap.getPurposes(url);

      // The purposes that are required but missing for the current user
      const purposesMissing = [...purposesRequired].filter(
        (purpose) => !airgap.isConsented(new Set([purpose]),
      );

      // Special case: If the purpose is just Unknown, and the request is blocked
      if (purposesMissing.includes('Unknown')) {
        // Require consent for all purposes in the regime
        const allPurposes = airgap.getRegimePurposes();
        for (const purpose of allPurposes) {
          if (!airgap.isConsented(new Set([purpose]))) {
            missingConsentPurposes.add(purpose);
          }
        }
      } else {
        // Add the missing purposes to the set
        for (const purpose of purposesMissing) {
          missingConsentPurposes.add(purpose);
        }
      }
    }),
  );

  return missingConsentPurposes;
}

function formatWithOxfordComma(
  items: string[],
  conjunction: 'and' | 'or',
): string {
  if (items.length === 0) return '';
  if (items.length === 1) return items[0];
  if (items.length === 2) return `${items[0]} ${conjunction} ${items[1]}`;
  return `${items.slice(0, -1).join(', ')}, ${conjunction} ${items[items.length - 1]}`;
}

/**
 * A fallback component to render when the user's consent preferences prevent rendering the wrapped component.
 */
const DefaultFallback: React.FC<FallbackProps> = ({
  missingConsentPurposes,
  onConsentGiven,
}) => {
  const formattedMissingPurposes = formatWithOxfordComma(
    [...missingConsentPurposes].map((purpose) => purpose.toLowerCase()),
    'or',
  );

  return (
    <div className="flex w-full h-full items-center justify-center flex-col gap-x-10 m:gap-x-15 p-24 m:p-75 text-center">
      <p className="text-16 font-[440] leading-116 text-grayscale-06 m:text-18">
        This content requires your consent to load.
      </p>
      <p className="text-12 m:text-16">
        The technology that displays this content may collect data
        {formattedMissingPurposes.length > 0
          ? ` for ${formattedMissingPurposes} purposes`
          : ' from your device'}
        .
      </p>

      <Button
        className="my-12 m:my-24"
        variant="secondary"
        onClick={onConsentGiven}
      >
        Opt in
      </Button>

      <span className="text-12 m:text-14 text-grayscale-05 inline">
        Powered by{' '}
        <Link
          href="/platform/consent-management"
          target="_blank"
          className="hyperlink"
        >
          Transcend Consent Management
        </Link>
      </span>
      <TranscendLogo iconOnly className="w-18 mt-10 hidden m:block" />
    </div>
  );
};

/**
 * If the user has not consented to URLs that would break rendering the children, render a fallback component.
 */
export const ConsentBoundary: React.FC<{
  /** The children are rendered when `urlsRequiredForRender` satisfy the user's consent preferences. */
  children: React.ReactNode;
  /** The URLs that would break rendering of the `children`. */
  urlsRequiredForRender: string[];
  /**
   * A callback function which renders a fallback component.
   * This is rendered when the `urlsRequiredForRender` do not satisfy the user's consent preferences.
   * The callback passes information about the missing consent purposes, should an opt-in button be rendered in this fallback.
   * Defaults to a basic fallback message with an opt-in button.
   */
  fallback?: (fallbackProps: FallbackProps) => React.ReactNode;
}> = ({
  children,
  urlsRequiredForRender,
  fallback = (fallbackProps) => {
    return <DefaultFallback {...fallbackProps} />;
  },
}) => {
  const { airgap } = useConsentManager();

  // A list of consent purposes that must to be opted into for the children to render
  const [missingConsentPurposes, setMissingConsentPurposes] = useState<
    FallbackProps['missingConsentPurposes']
  >(new Set());

  // Set the missing consent purposes for the URLs required for rendering
  useEffect(() => {
    if (airgap) {
      getMissingConsentPurposesForUrls(airgap, urlsRequiredForRender).then(
        setMissingConsentPurposes,
      );
    }
  }, [airgap, urlsRequiredForRender]);

  // Whether to show the fallback component
  const [showFallback, setShowFallback] = useState(false);

  // Show the fallback component if there are missing consent purposes
  useEffect(() => {
    if (missingConsentPurposes.size > 0) {
      setShowFallback(true);
    }
  }, [missingConsentPurposes]);

  /**
   * Event handler for the fallback component to call when the user clicks the "Opt in" button via `<button onClick={onConsentGiven}>`
   * Triggers re-render of ConsentBoundary
   */
  const onConsentGiven: MouseEventHandler = async (event) => {
    if (!airgap) {
      throw new Error(
        'airgap.js is not available! Is `onConsentGiven` called outside the fallback component?',
      );
    }

    // The object to pass to airgap.setConsent(event.nativeEvent, targetTrackingConsent), if the user opts in
    const targetTrackingConsent: TrackingConsent = airgap.getConsent().purposes;
    for (const purpose of missingConsentPurposes) {
      targetTrackingConsent[purpose] = true;
    }

    // Set consent and re-render the children
    await airgap.setConsent(event.nativeEvent, targetTrackingConsent);
    setShowFallback(false);
  };

  if (showFallback) {
    // Render the fallback component by calling the implementor-supplied callback
    return (
      <>
        {fallback({ missingConsentPurposes, onConsentGiven })}
        <div hidden>{children}</div>
      </>
    );
  }

  return children;
};