Skip to content
Merged
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 website/app/videos/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export default async function VideoPage({ params }: PageProps) {
className="gap-2 border border-transparent"
>
<BranchIcon />
Example source
Source code
</SolidButton>
) : null}
<OutlineButton
Expand Down
4 changes: 2 additions & 2 deletions website/src/components/AnalyticsScripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
* the denied-by-default consent state exists before any tag can fire. Cookiebot
* runs in `manual` blocking mode and loads `afterInteractive`: every
* cookie-setting script on this site is consent-gated at the load level (GTM
* below, YouTube via facades on youtube-nocookie.com that load the player on
* click, or on video pages once marketing consent is given), so
* below, YouTube via facades on youtube-nocookie.com that load the player only
* with marketing consent, asked for on click if missing), so
* Cookiebot's `auto` mode script-rewriting is redundant and its
* `beforeInteractive` placement (a high-priority preload of ~141 KB uc.js +
* cc.js) only contends with CSS, fonts, and the LCP image on slow connections.
Expand Down
74 changes: 62 additions & 12 deletions website/src/components/VideoFacade.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { sendAnalyticsEvent } from "@/src/helpers/analytics";
import {
canLoadMarketingEmbeds,
grantMarketingConsent,
sendAnalyticsEvent,
} from "@/src/helpers/analytics";
import { YouTubePlayButton } from "./YouTubePlayButton";

type VideoFacadeProps = {
Expand All @@ -18,13 +22,13 @@ type VideoFacadeProps = {
children: ReactNode;
};

type PlayerState = "poster" | "loaded" | "playing";
type PlayerState = "poster" | "consent" | "loaded" | "playing";

/**
* Click-to-load YouTube facade: shows the poster (passed as children) behind a
* play button and only mounts the embed iframe once clicked (or, with
* `autoload`, once marketing consent is given), so no YouTube player code loads
* during the initial render. Internal to <YouTubeVideo>.
* play button and only mounts the embed iframe once marketing consent is given:
* on click, or without a click with `autoload`. A click without consent asks
* for it first. Internal to <YouTubeVideo>.
*/
export function VideoFacade({
videoId,
Expand All @@ -36,14 +40,16 @@ export function VideoFacade({
const frameRef = useRef<HTMLIFrameElement>(null);

useEffect(() => {
if (!autoload) {
return;
}

function loadWithConsent() {
if (window.Cookiebot?.consent?.marketing === true) {
setPlayer((current) => (current === "poster" ? "loaded" : current));
if (window.Cookiebot?.consent?.marketing !== true) {
return;
}
setPlayer((current) => {
if (current === "consent") {
return "playing";
}
return autoload && current === "poster" ? "loaded" : current;
});
}

loadWithConsent();
Expand Down Expand Up @@ -71,7 +77,7 @@ export function VideoFacade({
return () => window.removeEventListener("blur", trackFirstInteraction);
}, [player, videoId]);

if (player !== "poster") {
if (player === "loaded" || player === "playing") {
const autoplay = player === "playing" ? "&autoplay=1" : "";
const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?rel=0${autoplay}`;
return (
Expand All @@ -89,10 +95,54 @@ export function VideoFacade({
);
}

if (player === "consent") {
return (
<div className="bg-cc-black relative aspect-video w-full overflow-hidden">
{children}
<div
role="group"
aria-label={playlabel}
className="bg-cc-black/85 text-cc-white absolute inset-0 flex flex-col items-center justify-center gap-3 p-4 text-center"
>
<p className="m-0 max-w-md text-xs leading-relaxed sm:text-sm">
This video is played by YouTube, which may set cookies. Playing it
requires your consent to marketing cookies.
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<button
type="button"
onClick={() => {
if (grantMarketingConsent() && canLoadMarketingEmbeds()) {
sendVideoStart(videoId);
setPlayer("playing");
}
}}
className="bg-cc-youtube text-cc-white cursor-pointer rounded-full border-0 px-4 py-1.5 text-xs font-medium sm:text-sm"
>
Accept and play
</button>
<a
href={`https://www.youtube.com/watch?v=${videoId}`}
target="_blank"
rel="noopener noreferrer"
className="bg-cc-white/10 hover:bg-cc-white/15 text-cc-white rounded-full px-4 py-1.5 text-xs font-medium no-underline transition-colors sm:text-sm"
>
Watch on YouTube
</a>
</div>
</div>
</div>
);
}

return (
<button
type="button"
onClick={() => {
if (!canLoadMarketingEmbeds()) {
setPlayer("consent");
return;
}
sendVideoStart(videoId);
setPlayer("playing");
}}
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/YouTubeVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ type YouTubeVideoProps = {
* Embeds a YouTube video as a click-to-load facade. Resolves the self-hosted,
* optimized poster (AVIF/WebP) for the id at build time, renders it here, and
* hands it to the client <VideoFacade>, which only loads the player iframe once
* clicked (or, with `autoload`, once marketing consent is given).
* the visitor has given marketing consent (on click, or with `autoload`
* without a click).
*/
export function YouTubeVideo({
videoId,
Expand Down
30 changes: 9 additions & 21 deletions website/src/components/videos/VideoStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,7 @@ export function VideoStage({
</span>
),
}}
headerRight={
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-cc-heading text-cc-surface hover:bg-cc-white rounded-full px-3 py-1 text-xs font-medium no-underline transition-colors"
>
Subscribe
</a>
}
headerClassName="flex items-center justify-between px-4 py-2.5"
headerClassName="flex items-center px-4 py-2.5"
shadow="soft"
>
<div className="grid sm:grid-cols-[minmax(0,1fr)_16rem]">
Expand Down Expand Up @@ -105,16 +95,14 @@ export function VideoStage({
</span>
</Link>
) : null}
{current.example ? (
<a
href={current.example}
target="_blank"
rel="noopener noreferrer"
className="bg-cc-white/10 hover:bg-cc-white/15 text-cc-heading inline-flex items-center rounded-full px-3 py-1.5 text-xs font-medium no-underline transition-colors"
>
Example source
</a>
) : null}
<a
href={channelUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-cc-heading text-cc-surface hover:bg-cc-white inline-flex items-center rounded-full px-3 py-1.5 text-xs font-medium no-underline transition-colors"
>
Subscribe
</a>
</div>

<div className="bg-cc-white/5 mt-3 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
Expand Down
5 changes: 3 additions & 2 deletions website/src/design-system/Picture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ export function Picture({
return (
// `contents` makes <picture> generate no box so the <img> participates in
// layout as if it were a direct child of the caller's container (preserves
// `object-cover`, `h-full`, flex/grid sizing on the image).
<picture className="contents">
// `object-cover`, `h-full`, flex/grid sizing on the image). The <source>
// elements are hidden so they do not become empty flex or grid items.
<picture className="contents [&>source]:hidden">
{opt.formats.avif && (
<source
type="image/avif"
Expand Down
34 changes: 34 additions & 0 deletions website/src/helpers/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,50 @@ declare global {
interface Window {
Cookiebot?: {
readonly consent?: {
readonly preferences?: boolean;
readonly statistics?: boolean;
readonly marketing?: boolean;
};
submitCustomConsent?: (
preferences: boolean,
statistics: boolean,
marketing: boolean,
) => void;
};
__gtmLoaded?: boolean;
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
}
}

const CONSENT_REQUIRED = Boolean(process.env.NEXT_PUBLIC_COOKIEBOT_CBID);

/**
* True when third-party embeds that set marketing cookies may load: the visitor
* granted Cookiebot marketing consent, or no consent manager is configured.
*/
export function canLoadMarketingEmbeds(): boolean {
return !CONSENT_REQUIRED || window.Cookiebot?.consent?.marketing === true;
}

/**
* Grants Cookiebot marketing consent and keeps the visitor's other choices.
* Returns false when Cookiebot has not loaded.
*/
export function grantMarketingConsent(): boolean {
const cookiebot = window.Cookiebot;
if (!cookiebot?.submitCustomConsent) {
return false;
}

cookiebot.submitCustomConsent(
cookiebot.consent?.preferences === true,
cookiebot.consent?.statistics === true,
true,
);
return true;
}

/** True only after the visitor has granted analytics consent and GTM started. */
export function canSendAnalytics(): boolean {
return (
Expand Down
8 changes: 7 additions & 1 deletion website/src/helpers/videoStructuredData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ function isoDuration(seconds: number): string {
return `PT${hours}H${minutes}M${seconds % 60}S`;
}

/** The privacy-enhanced YouTube player URL for the video. */
function videoEmbedUrl(video: VideoSummary): string {
return `https://www.youtube-nocookie.com/embed/${video.youtubeId}`;
}

export function videoObjectId(video: VideoSummary): string {
return schemaId(video.href, "video");
}
Expand All @@ -33,7 +38,7 @@ export function createVideoObjectNode(video: VideoSummary): JsonLdNode {
thumbnailUrl: [toAbsoluteUrl(videoPosterUrl(video.youtubeId))],
uploadDate: video.uploadDate,
duration: isoDuration(video.duration),
embedUrl: `https://www.youtube-nocookie.com/embed/${video.youtubeId}`,
embedUrl: videoEmbedUrl(video),
publisher: schemaRef(ORGANIZATION_ID),
...(video.author
? { author: schemaRef(authorPersonId(video.author)) }
Expand Down Expand Up @@ -68,6 +73,7 @@ export function createVideoItemListNode(
thumbnailUrl: [toAbsoluteUrl(videoPosterUrl(video.youtubeId))],
uploadDate: video.uploadDate,
duration: isoDuration(video.duration),
embedUrl: videoEmbedUrl(video),
},
})),
};
Expand Down
Loading