Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ed6827e
Add React web reference implementation scaffolding
Lotfi-Arif Feb 17, 2026
6e423c5
Add Contentful client, env config, and types
Lotfi-Arif Feb 19, 2026
5747be1
feat: Add createOptimization utility for Optimization SDK integration
Lotfi-Arif Feb 19, 2026
21efd7c
feat: Add OptimizationProvider context for optimization SDK
Lotfi-Arif Feb 19, 2026
bdcd5ed
feat: Add useOptimization hook for optimization context
Lotfi-Arif Feb 19, 2026
db63126
feat: Add useOptimizationState hook for optimization state management
Lotfi-Arif Feb 19, 2026
1c69cfa
refactor: App to load and render Contentful entries
Lotfi-Arif Feb 20, 2026
5c0a7aa
feat: Add usePersonalization hook for entry personalization logic
Lotfi-Arif Feb 20, 2026
fdf7226
feat: Add content entry and analytics components with hooks
Lotfi-Arif Feb 20, 2026
9d07279
feat: Update Contentful client and env config, add contentful dep
Lotfi-Arif Feb 20, 2026
58a1168
feat: Add Playwright e2e tests and restructure entry display
Lotfi-Arif Feb 22, 2026
6a2a421
refactor: format code for readability and fix import order issues
Lotfi-Arif Feb 23, 2026
e572489
Add Contentful rich text dependencies and update e2e test script
Lotfi-Arif Feb 24, 2026
64e192c
refactor: Contentful types and client usage for clarity
Lotfi-Arif Feb 24, 2026
5c13552
refactor: RichTextRenderer to use Contentful renderer package
Lotfi-Arif Feb 24, 2026
40b6192
feat: remove env config module and use import.meta.env with defaults
Lotfi-Arif Feb 24, 2026
a410c0f
feat: add React Router v7 SPA navigation and page event tracking
Lotfi-Arif Feb 24, 2026
b2cc5af
Refactor web-react: minor formatting and type improvements
Lotfi-Arif Feb 24, 2026
c2b878c
feat: simplify embedded entry node condition in extractTextContent
Lotfi-Arif Feb 24, 2026
f8a29ea
feat: Add OptimizationInitializationError for better error handling
Lotfi-Arif Feb 24, 2026
2577683
test: reload and verify profile reset button in e2e test
Lotfi-Arif Feb 24, 2026
bf1fb77
fix: formatting of error handling in createOptimization
Lotfi-Arif Feb 24, 2026
fa938e9
feat(test): Add tests for consent gating and unidentified state
Lotfi-Arif Feb 24, 2026
45a055a
refactor: type guards, improve env var checks, and update docs
Lotfi-Arif Feb 24, 2026
6784439
feat: Add E2E Web React job to CI pipeline
Lotfi-Arif Feb 24, 2026
9cd094d
feat: add steps to start, stop, and log Web React app in CI
Lotfi-Arif Feb 25, 2026
dfa1e45
feat update Web React app startup and logging in CI workflow
Lotfi-Arif Feb 25, 2026
1623ce3
Merge branch 'main' into NT-2570-create-a-react-web-vanilla-reference…
Lotfi-Arif Feb 25, 2026
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
111 changes: 111 additions & 0 deletions .github/workflows/main-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
e2e_node_ssr_only: ${{ steps.filter.outputs.e2e_node_ssr_only }}
e2e_node_ssr_web_vanilla: ${{ steps.filter.outputs.e2e_node_ssr_web_vanilla }}
e2e_web: ${{ steps.filter.outputs.e2e_web }}
e2e_web_react: ${{ steps.filter.outputs.e2e_web_react }}
e2e_react_native_android: ${{ steps.filter.outputs.e2e_react_native_android }}
steps:
- uses: namespacelabs/nscloud-checkout-action@v8
Expand Down Expand Up @@ -70,6 +71,14 @@
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/main-pipeline.yaml'
e2e_web_react:
- 'implementations/web-react/**'
- 'lib/**'
- 'platforms/**'
- 'universal/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/workflows/main-pipeline.yaml'
e2e_react_native_android:
- 'implementations/react-native/**'
- 'platforms/javascript/react-native/**'
Expand Down Expand Up @@ -468,6 +477,108 @@
./implementations/web-vanilla/test-results/
retention-days: 1

e2e-web-react:
name: 🖥️ E2E Web React
runs-on: namespace-profile-linux-8-vcpu-16-gb-ram-optimal
timeout-minutes: 15
needs: [setup, changes, build]
if: needs.changes.outputs.e2e_web_react == 'true'
steps:
- uses: namespacelabs/nscloud-checkout-action@v8

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium

Unpinned 3rd party Action 'Main Pipeline' step
Uses Step
uses 'namespacelabs/nscloud-checkout-action' with ref 'v8', not a pinned commit hash

- name: Create .env and copy to implementations
run: |
cat > .env << 'EOF'
DOTENV_CONFIG_QUIET=true
PUBLIC_NINETAILED_CLIENT_ID=${{secrets.NINETAILED_CLIENT_ID}}
PUBLIC_NINETAILED_ENVIRONMENT=${{secrets.NINETAILED_ENVIRONMENT}}
PUBLIC_EXPERIENCE_API_BASE_URL=http://localhost:8000/experience/
PUBLIC_INSIGHTS_API_BASE_URL=http://localhost:8000/insights/
PUBLIC_CONTENTFUL_TOKEN=${{secrets.CONTENTFUL_TOKEN}}
PUBLIC_CONTENTFUL_PREVIEW_TOKEN=${{secrets.CONTENTFUL_PREVIEW_TOKEN}}
PUBLIC_CONTENTFUL_ENVIRONMENT=${{secrets.CONTENTFUL_ENVIRONMENT}}
PUBLIC_CONTENTFUL_SPACE_ID=${{secrets.CONTENTFUL_SPACE_ID}}
PUBLIC_CONTENTFUL_CDA_HOST=localhost:8000
PUBLIC_CONTENTFUL_BASE_PATH=contentful
EOF
cp .env implementations/node-ssr-only/
cp .env implementations/web-react/

- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
package-manager-cache: false

- uses: pnpm/action-setup@v4

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium

Unpinned 3rd party Action 'Main Pipeline' step
Uses Step
uses 'pnpm/action-setup' with ref 'v4', not a pinned commit hash

- name: Set up caches (Namespace)
uses: namespacelabs/nscloud-cache-action@v1

Check warning

Code scanning / CodeQL

Unpinned tag for a non-immutable Action in workflow Medium

Unpinned 3rd party Action 'Main Pipeline' step
Uses Step
uses 'namespacelabs/nscloud-cache-action' with ref 'v1', not a pinned commit hash
with:
cache: |
pnpm
playwright
apt

- run: pnpm install --prefer-offline --frozen-lockfile
- uses: actions/download-artifact@v6
with:
name: sdk-package-tarballs
path: pkgs
- run: pnpm store prune
- run: pnpm run implementation:web-react -- implementation:install -- --no-frozen-lockfile
- run: pnpm run implementation:web-react -- implementation:playwright:install -- --with-deps
- name: Start Web React app and mocks
run: |
pnpm run implementation:web-react -- serve:mocks > /tmp/web-react-mocks.log 2>&1
pnpm run implementation:web-react -- build > /tmp/web-react-build.log 2>&1
pnpm --dir implementations/web-react exec rsbuild preview --host localhost --port 3001 > /tmp/web-react-app.log 2>&1 &
echo $! > /tmp/web-react-app.pid

for i in $(seq 1 90); do
# Use a plain HTTP probe (no --fail): we only need to know the server is listening.
if curl -sS http://localhost:3001 >/dev/null; then
echo "Web React app is ready"
exit 0
fi
echo "Waiting for Web React app... ($i/90)"
sleep 1
done

echo "Web React app failed to start in time"
echo "=== web-react build logs ==="
cat /tmp/web-react-build.log || true
echo "=== web-react app logs ==="
cat /tmp/web-react-app.log || true
echo "=== web-react mocks logs ==="
cat /tmp/web-react-mocks.log || true
exit 1
- run: pnpm run implementation:web-react -- implementation:test:e2e:run
- name: Dump Web React logs on failure
if: ${{ failure() }}
run: |
echo "=== web-react build logs ==="
cat /tmp/web-react-build.log || true
echo "=== web-react app logs ==="
cat /tmp/web-react-app.log || true
echo "=== web-react mocks logs ==="
cat /tmp/web-react-mocks.log || true
echo "=== pm2 list ==="
pm2 list || true
- name: Stop Web React app and mocks
if: ${{ always() }}
run: |
kill "$(cat /tmp/web-react-app.pid)" 2>/dev/null || true
pnpm run implementation:web-react -- serve:stop || true

- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: ci-results-web-react
path: |
./implementations/web-react/playwright-report/
./implementations/web-react/test-results/
retention-days: 1

e2e-react-native-android:
name: 📱 E2E React Native Android
runs-on: namespace-profile-linux-16-vcpu-32-gb-ram-optimal
Expand Down
15 changes: 15 additions & 0 deletions implementations/web-react/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
DOTENV_CONFIG_QUIET=true

PUBLIC_NINETAILED_CLIENT_ID="mock-client-id"
PUBLIC_NINETAILED_ENVIRONMENT="main"

PUBLIC_EXPERIENCE_API_BASE_URL="http://localhost:8000/experience/"
PUBLIC_INSIGHTS_API_BASE_URL="http://localhost:8000/insights/"

PUBLIC_CONTENTFUL_TOKEN="mock-token"
PUBLIC_CONTENTFUL_PREVIEW_TOKEN="mock-preview-token"
PUBLIC_CONTENTFUL_ENVIRONMENT="master"
PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id"

PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000"
PUBLIC_CONTENTFUL_BASE_PATH="contentful"
1 change: 1 addition & 0 deletions implementations/web-react/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
shared-workspace-lockfile=false
174 changes: 174 additions & 0 deletions implementations/web-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Web React Reference Implementation

Reference implementation demonstrating `@contentful/optimization-web` usage in a React web
application.

> **Note:** This implementation uses [Rsbuild](https://rsbuild.dev/) for consistency with the SDK
> build tooling. If you're creating your own React application, you can use any build tool you
> prefer (Vite, Create React App, Next.js, etc.) — the SDK integration patterns demonstrated here
> will work the same way.

## Overview

This implementation provides a thin React adapter layer over `@contentful/optimization-web`,
demonstrating:

- `OptimizationProvider` context for SDK state management
- React hooks for SDK state subscriptions
- Personalization resolution and variant rendering
- Rich Text rendering via `@contentful/rich-text-react-renderer`
- Analytics event tracking
- Live updates behavior
- SPA navigation tracking with React Router v7
- Offline queue/recovery handling

## Prerequisites

- Node.js >= 16.20.0
- pnpm 10.x

## Setup

From the **repository root**:

```bash
# Build SDK packages (required for local development)
pnpm build:pkgs

# Install implementation dependencies
pnpm run implementation:run -- web-react implementation:install
```

## Development

From the **repository root**:

```bash
# Start development server
pnpm run implementation:web-react dev

# Build for production
pnpm run implementation:web-react build

# Preview production build
pnpm run implementation:web-react preview

# Type checking
pnpm run implementation:web-react typecheck
```

Or from the **implementation directory** (`implementations/web-react`):

```bash
pnpm dev
pnpm build
pnpm preview
pnpm typecheck
```

## Testing

### E2E Tests

```bash
# In terminal 1: start mocks + app preview
pnpm run implementation:web-react serve

# In terminal 2: run Playwright tests
pnpm run implementation:web-react test:e2e

# Interactive Playwright UI
pnpm run implementation:web-react test:e2e:ui

# Generate tests with Playwright codegen
pnpm run implementation:web-react test:e2e:codegen
```

## Environment Variables

Copy `.env.example` to `.env` and configure:

```bash
cp .env.example .env
```

See `.env.example` for available configuration options. The implementation reads from
`import.meta.env` directly and falls back to local mock-safe defaults, so it can run without extra
env wiring. To use local mock Contentful endpoints, set `PUBLIC_CONTENTFUL_CDA_HOST=localhost:8000`
and `PUBLIC_CONTENTFUL_BASE_PATH=contentful`.

## Project Structure

```
web-react/
├── src/
│ ├── main.tsx # Application entry point
│ ├── App.tsx # Root component
│ ├── optimization/ # SDK React adapter
│ │ ├── OptimizationProvider.tsx
│ │ ├── hooks/
│ │ └── components/
│ ├── pages/ # Route pages
│ └── components/ # UI components
├── e2e/ # Playwright E2E tests
├── public/ # Static assets
├── index.html # HTML template
├── rsbuild.config.ts # Rsbuild configuration
├── tsconfig.json # TypeScript configuration
└── package.json
```

## SDK Integration Patterns

This implementation demonstrates how to build a React adapter for `@contentful/optimization-web`.
Key patterns include:

### Provider Setup

```tsx
import { OptimizationProvider } from './optimization'

function App() {
return (
<OptimizationProvider>
<YourApp />
</OptimizationProvider>
)
}
```

### Using Hooks

```tsx
import { usePersonalization, useOptimization } from './optimization'

function MyComponent() {
const { sdk, isReady } = useOptimization()
const { resolveEntry } = usePersonalization()
const resolved = resolveEntry(baseEntry)

// ...
}
```

### Analytics Tracking

```tsx
import { useAnalytics } from './optimization'

function TrackedComponent() {
const { trackView } = useAnalytics()

useEffect(() => {
void trackView({ componentId: 'component-id' })
}, [])

// ...
}
```

## Related

- [React Native Implementation](../react-native/) - Reference implementation for React Native
- [Web Vanilla Implementation](../web-vanilla/) - Reference implementation for vanilla JavaScript
- [@contentful/optimization-web](../../../platforms/javascript/web/) - Web SDK package
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'

test.describe('identified user', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()

await page.getByRole('button', { name: 'Identify' }).click()
await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible()

await page.reload()
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Reset Profile' })).toBeVisible()
})

test('renders identified variants', async ({ page }) => {
await expect(
page.getByText('This is a variant content entry for identified users.'),
).toBeVisible()
await expect(page.getByText('This is a level 0 nested variant entry.')).toBeVisible()
})

test('reset persists unidentified state across reload', async ({ page }) => {
await page.getByRole('button', { name: 'Reset Profile' }).click()
await expect(page.getByRole('button', { name: 'Identify' })).toBeVisible()

await page.reload()
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()

await expect(page.getByRole('button', { name: 'Identify' })).toBeVisible()
await expect(
page.getByText('This is a baseline content entry for all identified or unidentified users.'),
).toBeVisible()
await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible()
await expect(
page.getByText('This is a variant content entry for identified users.'),
).toHaveCount(0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test'

test.describe('unidentified user', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('domcontentloaded')
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
})

test('renders utility panel and entries', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Auto Observed Entries' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Manually Observed Entries' })).toBeVisible()

await expect(
page.getByText(
'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.',
),
).toBeVisible()

await expect(page.getByText('This is a level 0 nested baseline entry.')).toBeVisible()
await expect(page.getByText('This is a variant content entry for new visitors.')).toBeVisible()
})
})
Loading