diff --git a/apps/nextjs-15/src/app/blog/[slug]/page.tsx b/apps/nextjs-15/src/app/blog/[slug]/page.tsx index 4cd80c9..430ab22 100644 --- a/apps/nextjs-15/src/app/blog/[slug]/page.tsx +++ b/apps/nextjs-15/src/app/blog/[slug]/page.tsx @@ -1,4 +1,5 @@ import Link from 'next/link'; +import styles from '../layout.module.css'; export default async function BlogPage({ params, @@ -7,10 +8,15 @@ export default async function BlogPage({ }) { const { slug } = await params; return ( -
-

{slug}

- - Back to blog -
+ <> + Blog +

{slug.replace(/-/g, ' ')}

+

+ This is the post content for “{slug}”. +

+ + All posts + + ); } diff --git a/apps/nextjs-15/src/app/blog/layout.module.css b/apps/nextjs-15/src/app/blog/layout.module.css new file mode 100644 index 0000000..4e5e43e --- /dev/null +++ b/apps/nextjs-15/src/app/blog/layout.module.css @@ -0,0 +1,136 @@ +.page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100svh; + padding: 80px; + font-family: var(--font-geist-sans); +} + +.main { + display: flex; + flex-direction: column; + gap: 32px; + max-width: 480px; + width: 100%; +} + +.back { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: rgba(0, 0, 0, 0.4); + text-decoration: none; + transition: color 0.15s; +} + +.back::before { + content: "←"; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.4); +} + +.badge::before { + content: ""; + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #3b82f6; +} + +.main h1 { + font-size: 36px; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.15; + margin: 0; +} + +.posts { + display: flex; + flex-direction: column; + gap: 12px; +} + +.post { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + font-size: 15px; + font-weight: 500; + transition: + background 0.15s, + border-color 0.15s; +} + +.post::after { + content: "→"; + opacity: 0.4; +} + +.description { + font-size: 15px; + line-height: 1.6; + color: rgba(0, 0, 0, 0.5); + margin: 0; +} + +@media (hover: hover) and (pointer: fine) { + .back:hover { + color: var(--foreground); + } + + .post:hover { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.15); + } + + .post:hover::after { + opacity: 1; + } +} + +@media (prefers-color-scheme: dark) { + .back { + color: rgba(255, 255, 255, 0.4); + } + + .badge { + color: rgba(255, 255, 255, 0.4); + } + + .post { + border-color: rgba(255, 255, 255, 0.1); + } + + .post:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + } + + .description { + color: rgba(255, 255, 255, 0.5); + } +} + +@media (max-width: 600px) { + .page { + padding: 32px; + align-items: flex-start; + padding-top: 64px; + } +} diff --git a/apps/nextjs-15/src/app/blog/layout.tsx b/apps/nextjs-15/src/app/blog/layout.tsx index cb532b0..9a670d2 100644 --- a/apps/nextjs-15/src/app/blog/layout.tsx +++ b/apps/nextjs-15/src/app/blog/layout.tsx @@ -1,12 +1,15 @@ import Link from 'next/link'; +import styles from './layout.module.css'; export default function Layout({ children }: { children: React.ReactNode }) { return ( -
- My first blog post  - Feature just got released -
-
{children}
+
+
+ + Home + + {children} +
); } diff --git a/apps/nextjs-15/src/app/blog/page.tsx b/apps/nextjs-15/src/app/blog/page.tsx index 1278d9f..bcb07fa 100644 --- a/apps/nextjs-15/src/app/blog/page.tsx +++ b/apps/nextjs-15/src/app/blog/page.tsx @@ -1,3 +1,19 @@ +import Link from 'next/link'; +import styles from './layout.module.css'; + export default function Blog() { - return
Welcome to the blog
; + return ( + <> + Blog +

Posts

+ + + ); } diff --git a/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx b/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx new file mode 100644 index 0000000..55e3bdd --- /dev/null +++ b/apps/nextjs-15/src/app/experiment/exposure-tracker.tsx @@ -0,0 +1,24 @@ +'use client'; +import { track } from '@vercel/analytics/next'; +import { useEffect } from 'react'; +import styles from './page.module.css'; + +let tracked = false; + +export function ExposureTracker() { + useEffect(() => { + if (tracked) return; + tracked = true; + track('exposure'); + }, []); + + return ( + + ); +} diff --git a/apps/nextjs-15/src/app/experiment/page.module.css b/apps/nextjs-15/src/app/experiment/page.module.css new file mode 100644 index 0000000..c999e3d --- /dev/null +++ b/apps/nextjs-15/src/app/experiment/page.module.css @@ -0,0 +1,113 @@ +.page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100svh; + padding: 80px; + font-family: var(--font-geist-sans); +} + +.main { + display: flex; + flex-direction: column; + gap: 32px; + max-width: 480px; + width: 100%; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.4); +} + +.badge::before { + content: ""; + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #22c55e; +} + +.main h1 { + font-size: 36px; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.15; + margin: 0; +} + +.description { + font-size: 15px; + line-height: 1.6; + color: rgba(0, 0, 0, 0.5); + margin: 0; +} + +.button { + align-self: flex-start; + appearance: none; + background: var(--foreground); + color: var(--background); + border: none; + border-radius: 10px; + padding: 12px 20px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} + +@media (hover: hover) and (pointer: fine) { + .button:hover { + opacity: 0.8; + } +} + +.back { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: rgba(0, 0, 0, 0.4); + text-decoration: none; + transition: color 0.15s; +} + +.back::before { + content: "←"; +} + +@media (hover: hover) and (pointer: fine) { + .back:hover { + color: var(--foreground); + } +} + +@media (prefers-color-scheme: dark) { + .badge { + color: rgba(255, 255, 255, 0.4); + } + + .description { + color: rgba(255, 255, 255, 0.5); + } + + .back { + color: rgba(255, 255, 255, 0.4); + } +} + +@media (max-width: 600px) { + .page { + padding: 32px; + align-items: flex-start; + padding-top: 64px; + } +} diff --git a/apps/nextjs-15/src/app/experiment/page.tsx b/apps/nextjs-15/src/app/experiment/page.tsx new file mode 100644 index 0000000..7a86f1e --- /dev/null +++ b/apps/nextjs-15/src/app/experiment/page.tsx @@ -0,0 +1,23 @@ +import Link from 'next/link'; +import { ExposureTracker } from './exposure-tracker'; +import styles from './page.module.css'; + +export default function ExperimentPage() { + return ( +
+
+ + Home + + Experiment +

See what we're testing

+

+ This page is part of an active experiment. Your visit has been + recorded as an exposure, and you can interact below to track + additional events. +

+ +
+
+ ); +} diff --git a/apps/nextjs-15/src/app/page.module.css b/apps/nextjs-15/src/app/page.module.css index ee9b8e6..3b52366 100644 --- a/apps/nextjs-15/src/app/page.module.css +++ b/apps/nextjs-15/src/app/page.module.css @@ -1,168 +1,78 @@ .page { - --gray-rgb: 0, 0, 0; - --gray-alpha-200: rgba(var(--gray-rgb), 0.08); - --gray-alpha-100: rgba(var(--gray-rgb), 0.05); - - --button-primary-hover: #383838; - --button-secondary-hover: #f2f2f2; - - display: grid; - grid-template-rows: 20px 1fr 20px; + display: flex; align-items: center; - justify-items: center; + justify-content: center; min-height: 100svh; padding: 80px; - gap: 64px; font-family: var(--font-geist-sans); } -@media (prefers-color-scheme: dark) { - .page { - --gray-rgb: 255, 255, 255; - --gray-alpha-200: rgba(var(--gray-rgb), 0.145); - --gray-alpha-100: rgba(var(--gray-rgb), 0.06); - - --button-primary-hover: #ccc; - --button-secondary-hover: #1a1a1a; - } -} - .main { display: flex; flex-direction: column; - gap: 32px; - grid-row-start: 2; + gap: 40px; + max-width: 480px; + width: 100%; } -.main ol { - font-family: var(--font-geist-mono); - padding-left: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: -0.01em; - list-style-position: inside; -} - -.main li:not(:last-of-type) { - margin-bottom: 8px; -} - -.main code { - font-family: inherit; - background: var(--gray-alpha-100); - padding: 2px 4px; - border-radius: 4px; +.main h1 { + font-size: 32px; font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.2; } -.ctas { +.nav { display: flex; - gap: 16px; + flex-direction: column; + gap: 12px; } -.ctas a { - appearance: none; - border-radius: 128px; - height: 48px; - padding: 0 20px; - border: none; - border: 1px solid transparent; - transition: - background 0.2s, - color 0.2s, - border-color 0.2s; - cursor: pointer; +.link { display: flex; align-items: center; - justify-content: center; - font-size: 16px; - line-height: 20px; + justify-content: space-between; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + font-size: 15px; font-weight: 500; + transition: + background 0.15s, + border-color 0.15s; } -a.primary { - background: var(--foreground); - color: var(--background); - gap: 8px; -} - -a.secondary { - border-color: var(--gray-alpha-200); - min-width: 180px; -} - -.footer { - grid-row-start: 3; - display: flex; - gap: 24px; -} - -.footer a { - display: flex; - align-items: center; - gap: 8px; -} - -.footer img { - flex-shrink: 0; +.link::after { + content: "→"; + opacity: 0.4; } -/* Enable hover only on non-touch devices */ @media (hover: hover) and (pointer: fine) { - a.primary:hover { - background: var(--button-primary-hover); - border-color: transparent; - } - - a.secondary:hover { - background: var(--button-secondary-hover); - border-color: transparent; + .link:hover { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.15); } - .footer a:hover { - text-decoration: underline; - text-underline-offset: 4px; + .link:hover::after { + opacity: 1; } } -@media (max-width: 600px) { - .page { - padding: 32px; - padding-bottom: 80px; - } - - .main { - align-items: center; - } - - .main ol { - text-align: center; - } - - .ctas { - flex-direction: column; - } - - .ctas a { - font-size: 14px; - height: 40px; - padding: 0 16px; - } - - a.secondary { - min-width: auto; +@media (prefers-color-scheme: dark) { + .link { + border-color: rgba(255, 255, 255, 0.1); } - .footer { - flex-wrap: wrap; - align-items: center; - justify-content: center; + .link:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); } } -@media (prefers-color-scheme: dark) { - .logo { - filter: invert(); +@media (max-width: 600px) { + .page { + padding: 32px; + align-items: flex-start; + padding-top: 64px; } } diff --git a/apps/nextjs-15/src/app/page.tsx b/apps/nextjs-15/src/app/page.tsx index 8c170c1..b611270 100644 --- a/apps/nextjs-15/src/app/page.tsx +++ b/apps/nextjs-15/src/app/page.tsx @@ -1,95 +1,20 @@ -import Image from 'next/image'; +import Link from 'next/link'; import styles from './page.module.css'; export default function Home() { return (
- Next.js logo -
    -
  1. - Get started by editing src/app/page.tsx. -
  2. -
  3. Save and see your changes instantly.
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - -
+

Next.js 15 Analytics Demo

+
-
); } diff --git a/apps/nextjs/e2e/development/beforeSend.spec.ts b/apps/nextjs/e2e/development/beforeSend.spec.ts index 5d59d45..f161720 100644 --- a/apps/nextjs/e2e/development/beforeSend.spec.ts +++ b/apps/nextjs/e2e/development/beforeSend.spec.ts @@ -35,13 +35,13 @@ test.describe('beforeSend', () => { expect( messages.find((m) => - m.includes('[pageview] http://localhost:3000/before-send/first'), + m.includes('[view] http://localhost:3000/before-send/first'), ), ).toBeDefined(); expect( messages.find((m) => m.includes( - '[pageview] http://localhost:3000/before-send/second?secret=REDACTED', + '[view] http://localhost:3000/before-send/second?secret=REDACTED', ), ), ).toBeDefined(); diff --git a/apps/nextjs/e2e/development/pageview.spec.ts b/apps/nextjs/e2e/development/pageview.spec.ts index 5ac69d8..0368e32 100644 --- a/apps/nextjs/e2e/development/pageview.spec.ts +++ b/apps/nextjs/e2e/development/pageview.spec.ts @@ -35,12 +35,12 @@ test.describe('pageview', () => { expect( messages.find((m) => - m.includes('[pageview] http://localhost:3000/navigation/first'), + m.includes('[view] http://localhost:3000/navigation/first'), ), ).toBeDefined(); expect( messages.find((m) => - m.includes('[pageview] http://localhost:3000/navigation/second'), + m.includes('[view] http://localhost:3000/navigation/second'), ), ).toBeDefined(); }); diff --git a/apps/nextjs/e2e/utils.ts b/apps/nextjs/e2e/utils.ts index 36044ed..b5c337d 100644 --- a/apps/nextjs/e2e/utils.ts +++ b/apps/nextjs/e2e/utils.ts @@ -10,15 +10,13 @@ export async function useMockForProductionScript(props: { "Object.defineProperty(navigator, 'webdriver', { get() { return undefined }})", }); - await props.page.route('**/_vercel/insights/script.js', async (route, _) => { - return route.fulfill({ - status: 301, - headers: { - location: props.debug - ? 'https://cdn.vercel-insights.com/v1/script.debug.js' - : 'https://cdn.vercel-insights.com/v1/script.js', - }, + await props.page.route('**/_vercel/insights/script.js', async (route) => { + const response = await route.fetch({ + url: props.debug + ? 'https://va.vercel-scripts.com/v1/script.debug.js' + : 'https://va.vercel-scripts.com/v1/script.js', }); + return route.fulfill({ response }); }); await props.page.route('**/_vercel/insights/view', async (route, request) => { diff --git a/packages/web/src/generic.test.ts b/packages/web/src/generic.test.ts index 5cfd915..072c60d 100644 --- a/packages/web/src/generic.test.ts +++ b/packages/web/src/generic.test.ts @@ -138,6 +138,22 @@ describe.each([ }); }); + describe('track before inject', () => { + beforeEach(() => { + // simulate a fresh page load where inject hasn't run yet + window.va = undefined; + window.vaq = undefined; + }); + + it('initializes the queue and buffers events with properties', () => { + track('exposure', { variant: 'A' }); + expect(window.vaq?.[0]).toEqual([ + 'event', + { name: 'exposure', data: { variant: 'A' } }, + ]); + }); + }); + describe('track custom events', () => { beforeEach(() => { // reset the internal queue before every test diff --git a/packages/web/src/generic.ts b/packages/web/src/generic.ts index d2bce5e..6df4f27 100644 --- a/packages/web/src/generic.ts +++ b/packages/web/src/generic.ts @@ -89,6 +89,10 @@ function track( return; } + // in case the track function is invoked even before inject + // (can happen because react renders children before their parents) + // make sure we do not miss them. + initQueue(); if (!properties) { window.va?.('event', { name, options }); diff --git a/packages/web/src/nextjs/index.tsx b/packages/web/src/nextjs/index.tsx index f9c1480..2ebd284 100644 --- a/packages/web/src/nextjs/index.tsx +++ b/packages/web/src/nextjs/index.tsx @@ -1,5 +1,6 @@ 'use client'; import React, { type ReactNode, Suspense } from 'react'; +import { track } from '../generic'; import { Analytics as AnalyticsScript } from '../react'; import type { AnalyticsProps, BeforeSend, BeforeSendEvent } from '../types'; import { getBasePath, getConfigString, useRoute } from './utils'; @@ -29,4 +30,5 @@ export function Analytics(props: Props): null { ) as never; } +export { track }; export type { AnalyticsProps, BeforeSend, BeforeSendEvent }; diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 8f0d7a2..1569798 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -1,22 +1,35 @@ +export type Mode = 'auto' | 'development' | 'production'; +export type AllowedPropertyValues = + | string + | number + | boolean + | null + | undefined; + +export type PlainFlags = Record; +export type FlagsDataInput = (string | PlainFlags)[] | PlainFlags; + +export type TrackEventPayload = { + name: string; + data?: Record; + options?: { + flags?: FlagsDataInput; + }; +}; + interface PageViewEvent { type: 'pageview'; url: string; } + interface CustomEvent { type: 'event'; url: string; + payload: TrackEventPayload; } export type BeforeSendEvent = PageViewEvent | CustomEvent; -export type Mode = 'auto' | 'development' | 'production'; -export type AllowedPropertyValues = - | string - | number - | boolean - | null - | undefined; - export type BeforeSend = (event: BeforeSendEvent) => BeforeSendEvent | null; export interface AnalyticsProps { @@ -53,6 +66,3 @@ declare global { webAnalyticsBeforeSend?: BeforeSend; } } - -export type PlainFlags = Record; -export type FlagsDataInput = (string | PlainFlags)[] | PlainFlags;