diff --git a/implementations/web-react/.env.example b/implementations/web-react/.env.example index 75fcde25..9273d7d6 100644 --- a/implementations/web-react/.env.example +++ b/implementations/web-react/.env.example @@ -13,3 +13,4 @@ PUBLIC_CONTENTFUL_SPACE_ID="mock-space-id" PUBLIC_CONTENTFUL_CDA_HOST="localhost:8000" PUBLIC_CONTENTFUL_BASE_PATH="contentful" +PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL="false" diff --git a/implementations/web-react/README.md b/implementations/web-react/README.md index 2b6a5557..b9ed91c1 100644 --- a/implementations/web-react/README.md +++ b/implementations/web-react/README.md @@ -22,6 +22,9 @@ demonstrating: - SPA navigation tracking with React Router v7 - Offline queue/recovery handling +The live updates section demonstrates the same parity scenarios directly in-page (default, forced +on, and locked), while keeping the main entry rendering flow customer-oriented. + ## Prerequisites - Node.js >= 16.20.0 @@ -97,6 +100,9 @@ See `.env.example` for available configuration options. The implementation reads env wiring. To use local mock Contentful endpoints, set `PUBLIC_CONTENTFUL_CDA_HOST=localhost:8000` and `PUBLIC_CONTENTFUL_BASE_PATH=contentful`. +Preview panel attachment is gated behind `PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL`. Set it to +`true` for development demos that need preview panel behavior. + ## Project Structure ``` diff --git a/implementations/web-react/e2e/displays-identified-user-variants.spec.ts b/implementations/web-react/e2e/displays-identified-user-variants.spec.ts index 70c264d1..af40aa26 100644 --- a/implementations/web-react/e2e/displays-identified-user-variants.spec.ts +++ b/implementations/web-react/e2e/displays-identified-user-variants.spec.ts @@ -6,31 +6,75 @@ test.describe('identified user', () => { 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.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('live-updates-reset-button')).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() + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + }) + + test('displays common variants', async ({ page }) => { + await expect( + page.getByText( + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + ), + ).toBeVisible() + + const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') + await expect( + continentEntry + .getByText('This is a variant content entry for visitors from Europe.') + .or( + continentEntry.getByText( + 'This is a baseline content entry for visitors from any continent.', + ), + ), + ).toBeVisible() + + const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') + await expect( + deviceEntry + .getByText('This is a variant content entry for visitors using a desktop browser.') + .or( + deviceEntry.getByText( + 'This is a baseline content entry for all visitors using any device.', + ), + ), + ).toBeVisible() }) test('renders identified variants', async ({ page }) => { + await expect(page.getByText('This is a level 0 nested variant entry.')).toBeVisible() + await expect(page.getByText('This is a level 1 nested variant entry.')).toBeVisible() + await expect(page.getByText('This is a level 2 nested variant entry.')).toBeVisible() + + await expect( + page + .getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') + .getByText('This is a variant content entry for return visitors.'), + ).toBeVisible() + await expect( + page.getByText('This is a variant content entry for an A/B/C experiment: B'), + ).toBeVisible() + await expect( + page.getByText('This is a variant content entry for visitors with a custom event.'), + ).toBeVisible() 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.getByTestId('live-updates-reset-button').click() + await expect(page.getByTestId('live-updates-identify-button')).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.getByTestId('live-updates-identify-button')).toBeVisible() await expect( page.getByText('This is a baseline content entry for all identified or unidentified users.'), ).toBeVisible() diff --git a/implementations/web-react/e2e/displays-unidentified-user-variants.spec.ts b/implementations/web-react/e2e/displays-unidentified-user-variants.spec.ts index 463a964b..85390937 100644 --- a/implementations/web-react/e2e/displays-unidentified-user-variants.spec.ts +++ b/implementations/web-react/e2e/displays-unidentified-user-variants.spec.ts @@ -7,18 +7,61 @@ test.describe('unidentified user', () => { await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() }) - test('renders utility panel and entries', async ({ page }) => { + test('displays common variants', async ({ page }) => { + await expect( + page.getByText( + 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + ), + ).toBeVisible() + + const continentEntry = page.getByTestId('entry-text-4ib0hsHWoSOnCVdDkizE8d') + await expect( + continentEntry + .getByText('This is a variant content entry for visitors from Europe.') + .or( + continentEntry.getByText( + 'This is a baseline content entry for visitors from any continent.', + ), + ), + ).toBeVisible() + + const deviceEntry = page.getByTestId('entry-text-xFwgG3oNaOcjzWiGe4vXo') + await expect( + deviceEntry + .getByText('This is a variant content entry for visitors using a desktop browser.') + .or( + deviceEntry.getByText( + 'This is a baseline content entry for all visitors using any device.', + ), + ), + ).toBeVisible() + }) + + test('displays unidentified user variants', 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 level 0 nested baseline entry.')).toBeVisible() + await expect(page.getByText('This is a level 1 nested baseline entry.')).toBeVisible() + await expect(page.getByText('This is a level 2 nested baseline entry.')).toBeVisible() + + const visitorVariant = page.getByTestId('entry-text-2Z2WLOx07InSewC3LUB3eX') + await expect( + visitorVariant + .getByText('This is a variant content entry for new visitors.') + .or(visitorVariant.getByText('This is a variant content entry for return visitors.')), + ).toBeVisible() + await expect( + page.getByText('This is a variant content entry for an A/B/C experiment: B'), + ).toBeVisible() await expect( page.getByText( - 'This is a merge tag content entry that displays the visitor\'s continent "EU" embedded within the text.', + 'This is a baseline content entry for all visitors with or without a custom event.', ), ).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() + await expect( + page.getByText('This is a baseline content entry for all identified or unidentified users.'), + ).toBeVisible() }) }) diff --git a/implementations/web-react/e2e/live-updates.spec.ts b/implementations/web-react/e2e/live-updates.spec.ts new file mode 100644 index 00000000..86697432 --- /dev/null +++ b/implementations/web-react/e2e/live-updates.spec.ts @@ -0,0 +1,137 @@ +import { type Locator, type Page, expect, test } from '@playwright/test' + +const isPreviewPanelEnabled = process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true' + +async function getEntryId(locator: Locator): Promise { + const text = await locator.innerText() + return text.replace('Entry: ', '').trim() +} + +async function identify(page: Page): Promise { + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('identified-status')).toHaveText('Yes') +} + +test.describe('live updates behavior', () => { + test.beforeEach(async ({ page }) => { + await page.context().clearCookies() + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') + await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') + await expect(page.getByTestId('identified-status')).toHaveText('No') + if (isPreviewPanelEnabled) { + await expect + .poll(async () => await page.locator('ctfl-opt-preview-panel').count()) + .toBeGreaterThan(0) + } else { + await expect(page.locator('ctfl-opt-preview-panel')).toHaveCount(0) + } + await expect(page.getByTestId('live-updates-examples')).toBeVisible() + await expect + .poll(async () => { + const text = await page.getByTestId('personalizations-count').innerText() + const value = Number.parseInt(text.replace('Personalizations: ', ''), 10) + return Number.isNaN(value) ? 0 : value + }) + .toBeGreaterThan(0) + }) + + test('default behavior locks to first value when global live updates is OFF', async ({ + page, + }) => { + const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default')) + + await identify(page) + + await expect(page.getByTestId('entry-id-live-default')).toHaveText( + `Entry: ${initialDefaultEntryId}`, + ) + }) + + test('global live updates ON updates default component while locked component stays fixed', async ({ + page, + }) => { + await page.getByTestId('toggle-global-live-updates-button').click() + await expect(page.getByTestId('global-live-updates-status')).toHaveText('ON') + + const initialDefaultEntryId = await getEntryId(page.getByTestId('entry-id-live-default')) + const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-default'))) + .not.toBe(initialDefaultEntryId) + await expect(page.getByTestId('entry-id-live-locked')).toHaveText( + `Entry: ${initialLockedEntryId}`, + ) + }) + + test('per-component liveUpdates=true updates even when global live updates is OFF', async ({ + page, + }) => { + const initialLiveEntryId = await getEntryId(page.getByTestId('entry-id-live-enabled')) + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-enabled'))) + .not.toBe(initialLiveEntryId) + }) + + test('preview-panel override enables updates for locked components', async ({ page }) => { + test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + const initialLockedEntryId = await getEntryId(page.getByTestId('entry-id-live-locked')) + + await page.getByTestId('simulate-preview-panel-button').click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') + + await identify(page) + + await expect + .poll(async () => await getEntryId(page.getByTestId('entry-id-live-locked'))) + .not.toBe(initialLockedEntryId) + }) + + test('screen controls toggle global live updates and preview panel', async ({ page }) => { + test.skip(!isPreviewPanelEnabled, 'Preview panel is disabled for this build.') + await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') + await page.getByTestId('toggle-global-live-updates-button').click() + await expect(page.getByTestId('global-live-updates-status')).toHaveText('ON') + await page.getByTestId('toggle-global-live-updates-button').click() + await expect(page.getByTestId('global-live-updates-status')).toHaveText('OFF') + + await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') + await page.getByTestId('simulate-preview-panel-button').click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Open') + await page.getByTestId('simulate-preview-panel-button').click() + await expect(page.getByTestId('preview-panel-status')).toHaveText('Closed') + }) + + test('screen controls identify and reset user', async ({ page }) => { + await expect(page.getByTestId('identified-status')).toHaveText('No') + await page.getByTestId('live-updates-identify-button').click() + await expect(page.getByTestId('identified-status')).toHaveText('Yes') + await page.getByTestId('live-updates-reset-button').click() + await expect(page.getByTestId('identified-status')).toHaveText('No') + }) + + test('renders default, enabled, and locked examples', async ({ page }) => { + await expect(page.getByTestId('live-updates-default')).toBeVisible() + await expect(page.getByTestId('live-updates-enabled')).toBeVisible() + await expect(page.getByTestId('live-updates-locked')).toBeVisible() + + await expect(page.getByTestId('content-live-default')).toBeVisible() + await expect(page.getByTestId('content-live-enabled')).toBeVisible() + await expect(page.getByTestId('content-live-locked')).toBeVisible() + + await expect(page.getByTestId('entry-text-live-default')).toBeVisible() + await expect(page.getByTestId('entry-text-live-enabled')).toBeVisible() + await expect(page.getByTestId('entry-text-live-locked')).toBeVisible() + await expect(page.getByTestId('entry-id-live-default')).toBeVisible() + await expect(page.getByTestId('entry-id-live-enabled')).toBeVisible() + await expect(page.getByTestId('entry-id-live-locked')).toBeVisible() + }) +}) diff --git a/implementations/web-react/e2e/navigation-page-events.spec.ts b/implementations/web-react/e2e/navigation-page-events.spec.ts index 5f192762..9f9c36f6 100644 --- a/implementations/web-react/e2e/navigation-page-events.spec.ts +++ b/implementations/web-react/e2e/navigation-page-events.spec.ts @@ -1,4 +1,23 @@ -import { expect, test } from '@playwright/test' +import { type Page, expect, test } from '@playwright/test' + +async function getRecentPageEventUrls(page: Page): Promise { + const pageEvents = page.locator('[data-testid^="event-page-"]') + const count = await pageEvents.count() + const urls: string[] = [] + + for (let index = 0; index < count; index += 1) { + const text = await pageEvents.nth(index).innerText() + const marker = 'URL: ' + const markerIndex = text.indexOf(marker) + if (markerIndex === -1) { + continue + } + + urls.push(text.slice(markerIndex + marker.length).trim()) + } + + return urls +} test.describe('navigation page events', () => { test.beforeEach(async ({ page }) => { @@ -7,11 +26,11 @@ test.describe('navigation page events', () => { await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() }) - test('emits page events on route navigation', async ({ page }) => { + test('records ordered route sequence including revisits', async ({ page }) => { const pageEventLocator = page.locator('[data-testid^="event-page-"]') await expect(pageEventLocator.first()).toBeVisible() - const initialPageEventCount = await pageEventLocator.count() - + const initialUrls = await getRecentPageEventUrls(page) + const initialPageEventCount = initialUrls.length await page.getByTestId('link-page-two').click() await expect(page).toHaveURL(/\/page-two$/) await expect(page.getByTestId('page-two-view')).toBeVisible() @@ -20,12 +39,19 @@ test.describe('navigation page events', () => { .poll(async () => await pageEventLocator.count()) .toBeGreaterThan(initialPageEventCount) - const afterPageTwoCount = await pageEventLocator.count() - await page.getByTestId('link-back-home').click() await expect(page).toHaveURL(/\/$/) await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() - await expect.poll(async () => await pageEventLocator.count()).toBeGreaterThan(afterPageTwoCount) + await page.getByTestId('link-page-two').click() + await expect(page).toHaveURL(/\/page-two$/) + await expect(page.getByTestId('page-two-view')).toBeVisible() + + await expect + .poll(async () => { + const urls = await getRecentPageEventUrls(page) + return urls.slice(0, 3) + }) + .toEqual(['/page-two', '/', '/page-two']) }) }) diff --git a/implementations/web-react/e2e/offline-queue-recovery.spec.ts b/implementations/web-react/e2e/offline-queue-recovery.spec.ts new file mode 100644 index 00000000..2cf5a743 --- /dev/null +++ b/implementations/web-react/e2e/offline-queue-recovery.spec.ts @@ -0,0 +1,90 @@ +import { type BrowserContext, type Page, expect, test } from '@playwright/test' + +function parseEventsCount(text: string): number { + const match = /Events:\s*(\d+)/.exec(text) + return match?.[1] ? Number.parseInt(match[1], 10) : 0 +} + +async function getEventsCount(page: Page): Promise { + const text = await page.getByTestId('events-count').innerText() + return parseEventsCount(text) +} + +async function expectEventsToIncrease(page: Page, baselineCount: number): Promise { + await expect.poll(async () => await getEventsCount(page)).toBeGreaterThan(baselineCount) +} + +async function setOffline(context: BrowserContext, offline: boolean): Promise { + await context.setOffline(offline) +} + +async function waitForBaseUi(page: Page): Promise { + await expect(page.getByRole('heading', { name: 'Utilities' })).toBeVisible() + await expect(page.getByTestId('events-count')).toBeVisible() +} + +test.describe('offline queue and recovery', () => { + test.beforeEach(async ({ context, page }) => { + await context.clearCookies() + await setOffline(context, false) + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await waitForBaseUi(page) + }) + + test.afterEach(async ({ context }) => { + await setOffline(context, false) + }) + + test('continues tracking analytics events while offline', async ({ context, page }) => { + const baselineCount = await getEventsCount(page) + + await setOffline(context, true) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + await page.getByTestId('page-two-demo-cta').click() + await expectEventsToIncrease(page, baselineCount) + }) + + test('recovers gracefully when network is restored', async ({ context, page }) => { + await setOffline(context, true) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + + await setOffline(context, false) + await page.getByTestId('link-back-home').click() + await waitForBaseUi(page) + await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + }) + + test('remains stable across rapid network state changes', async ({ context, page }) => { + await setOffline(context, true) + await setOffline(context, false) + await setOffline(context, true) + await setOffline(context, false) + + await waitForBaseUi(page) + await expect(page.getByTestId('live-updates-identify-button')).toBeVisible() + const baselineCount = await getEventsCount(page) + await page.getByTestId('link-page-two').click() + await expect(page.getByTestId('page-two-view')).toBeVisible() + await page.getByTestId('page-two-demo-cta').click() + await expectEventsToIncrease(page, baselineCount) + }) + + test('queues identify event offline and flushes personalization state when online', async ({ + context, + page, + }) => { + const baselineCount = await getEventsCount(page) + + await setOffline(context, true) + await page.getByTestId('live-updates-identify-button').click() + await expectEventsToIncrease(page, baselineCount) + await expect(page.getByTestId('identified-status')).toHaveText('No') + + await setOffline(context, false) + await expect(page.getByTestId('live-updates-reset-button')).toBeVisible() + await expect(page.getByTestId('identified-status')).toHaveText('Yes') + }) +}) diff --git a/implementations/web-react/package.json b/implementations/web-react/package.json index d49b0951..071dcdbf 100644 --- a/implementations/web-react/package.json +++ b/implementations/web-react/package.json @@ -27,6 +27,7 @@ "@contentful/rich-text-react-renderer": "^16.1.6", "@contentful/rich-text-types": "^17.2.5", "@contentful/optimization-web": "0.0.0", + "@contentful/optimization-web-preview-panel": "0.0.0", "contentful": "^11.10.3", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -49,7 +50,8 @@ "@contentful/optimization-api-client": "file:../../pkgs/contentful-optimization-api-client-0.0.0.tgz", "@contentful/optimization-api-schemas": "file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz", "@contentful/optimization-core": "file:../../pkgs/contentful-optimization-core-0.0.0.tgz", - "@contentful/optimization-web": "file:../../pkgs/contentful-optimization-web-0.0.0.tgz" + "@contentful/optimization-web": "file:../../pkgs/contentful-optimization-web-0.0.0.tgz", + "@contentful/optimization-web-preview-panel": "file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz" } } } diff --git a/implementations/web-react/playwright.config.mjs b/implementations/web-react/playwright.config.mjs index 93f2153a..2e785bbf 100644 --- a/implementations/web-react/playwright.config.mjs +++ b/implementations/web-react/playwright.config.mjs @@ -25,5 +25,13 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, ], }) diff --git a/implementations/web-react/pnpm-lock.yaml b/implementations/web-react/pnpm-lock.yaml index 21892232..0f801a96 100644 --- a/implementations/web-react/pnpm-lock.yaml +++ b/implementations/web-react/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@contentful/optimization-api-schemas': file:../../pkgs/contentful-optimization-api-schemas-0.0.0.tgz '@contentful/optimization-core': file:../../pkgs/contentful-optimization-core-0.0.0.tgz '@contentful/optimization-web': file:../../pkgs/contentful-optimization-web-0.0.0.tgz + '@contentful/optimization-web-preview-panel': file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz importers: @@ -17,6 +18,9 @@ importers: '@contentful/optimization-web': specifier: file:../../pkgs/contentful-optimization-web-0.0.0.tgz version: file:../../pkgs/contentful-optimization-web-0.0.0.tgz + '@contentful/optimization-web-preview-panel': + specifier: file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz + version: file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz '@contentful/rich-text-react-renderer': specifier: ^16.1.6 version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -85,11 +89,15 @@ packages: version: 0.0.0 '@contentful/optimization-core@file:../../pkgs/contentful-optimization-core-0.0.0.tgz': - resolution: {integrity: sha512-OQ2V/cmKS/b+jwjV4HYrbUb3XfsNVs2A4sn2hGxZTJkiiUhmCEhobVbhtEM6dakhyhDrV+T/rfXVWx4wwOx3Pg==, tarball: file:../../pkgs/contentful-optimization-core-0.0.0.tgz} + resolution: {integrity: sha512-paR1XVmSUgDC8BoZqPRziMEq5eRH5uCuzglBjt9FYMp4inbDGhrJy8JnKntqn377AvAenZDMt4hyW70IvJBSzA==, tarball: file:../../pkgs/contentful-optimization-core-0.0.0.tgz} + version: 0.0.0 + + '@contentful/optimization-web-preview-panel@file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz': + resolution: {integrity: sha512-ACb4aT+ymp3YiW6xfT3zFUc0qF4NH7M1sTo9sJ6csz+hO7P9U/NnrkyI0Hu5TWs2d+Z09anp4LwvkWNokY4Wdw==, tarball: file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz} version: 0.0.0 '@contentful/optimization-web@file:../../pkgs/contentful-optimization-web-0.0.0.tgz': - resolution: {integrity: sha512-T1wJuQgqTlTdXX1ZvM/o0v0mur9ADSPLIbUjsu0KYlyRG46fsL3XNzRTJjNGj5ekw0779JflffLVh9eLMGx7Gw==, tarball: file:../../pkgs/contentful-optimization-web-0.0.0.tgz} + resolution: {integrity: sha512-3MmE9Xae3EWKKrgyHP5Iwt1RQJODuW/0KCAq5Zw/a2jjjq4FUx5FYCzDpmVPcEDArb0pSdQpJlnD5raGxP6Z6Q==, tarball: file:../../pkgs/contentful-optimization-web-0.0.0.tgz} version: 0.0.0 '@contentful/rich-text-react-renderer@16.1.6': @@ -136,6 +144,15 @@ packages: resolution: {integrity: sha512-2hj2PqHQU7hI+2+JiViOPmeTmms/8Xl1i/AWd59hwaf+lbqDFKc8CmeZNeAvrM+D7FeYgX1/mDoQvWAkLZNYjQ==} engines: {node: '>=20.0.0'} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + + '@lit/context@1.1.6': + resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@messageformat/parser@5.1.1': resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==} @@ -306,6 +323,9 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vercel/stega@0.1.2': resolution: {integrity: sha512-P7mafQXjkrsoyTRppnt0N21udKS9wUmLXHRyP9saLXLHw32j/FgUJ3FscSWgvSqRs4cj7wKZtwqJEvWJ2jbGmA==} @@ -728,6 +748,15 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.2: + resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==} + + lit@3.3.2: + resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -1124,6 +1153,16 @@ snapshots: transitivePeerDependencies: - debug + '@contentful/optimization-web-preview-panel@file:../../pkgs/contentful-optimization-web-preview-panel-0.0.0.tgz': + dependencies: + '@contentful/optimization-web': file:../../pkgs/contentful-optimization-web-0.0.0.tgz + '@lit/context': 1.1.6 + contentful: 11.10.3 + es-toolkit: 1.44.0 + lit: 3.3.2 + transitivePeerDependencies: + - debug + '@contentful/optimization-web@file:../../pkgs/contentful-optimization-web-0.0.0.tgz': dependencies: '@contentful/optimization-core': file:../../pkgs/contentful-optimization-core-0.0.0.tgz @@ -1181,6 +1220,16 @@ snapshots: '@messageformat/parser': 5.1.1 js-sha256: 0.10.1 + '@lit-labs/ssr-dom-shim@1.5.1': {} + + '@lit/context@1.1.6': + dependencies: + '@lit/reactive-element': 2.1.2 + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@messageformat/parser@5.1.1': dependencies: moo: 0.5.2 @@ -1382,6 +1431,8 @@ snapshots: '@types/retry@0.12.2': {} + '@types/trusted-types@2.0.7': {} + '@vercel/stega@0.1.2': {} agent-base@7.1.4: {} @@ -1774,6 +1825,22 @@ snapshots: json-stringify-safe@5.0.1: {} + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.5.1 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.2 + + lit-html@3.3.2: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.2: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.2 + lodash@4.17.23: {} lru-cache@11.2.6: {} diff --git a/implementations/web-react/rsbuild.config.ts b/implementations/web-react/rsbuild.config.ts index f301a22c..fadc1526 100644 --- a/implementations/web-react/rsbuild.config.ts +++ b/implementations/web-react/rsbuild.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ entry: { index: './src/main.tsx', }, + define: { + ENABLE_PREVIEW_PANEL: JSON.stringify( + process.env.PUBLIC_OPTIMIZATION_ENABLE_PREVIEW_PANEL === 'true', + ), + }, }, html: { template: './index.html', diff --git a/implementations/web-react/src/App.tsx b/implementations/web-react/src/App.tsx index f25cabb2..d41e0d6c 100644 --- a/implementations/web-react/src/App.tsx +++ b/implementations/web-react/src/App.tsx @@ -1,7 +1,13 @@ +import type { Profile } from '@contentful/optimization-web' import { type JSX, useEffect, useMemo, useState } from 'react' import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom' import { AnalyticsEventDisplay } from './components/AnalyticsEventDisplay' -import { ENTRY_IDS } from './config/entries' +import { + ENTRY_IDS, + LIVE_UPDATES_ENTRY_ID, + PAGE_TWO_AUTO_ENTRY_ID, + PAGE_TWO_MANUAL_ENTRY_ID, +} from './config/entries' import { HOME_PATH, PAGE_TWO_PATH } from './config/routes' import { useOptimization } from './optimization/hooks/useOptimization' import { useOptimizationState } from './optimization/hooks/useOptimizationState' @@ -10,17 +16,17 @@ import { PageTwoPage } from './pages/PageTwoPage' import { fetchEntries, getContentfulConfigError } from './services/contentfulClient' import type { ContentfulEntry } from './types/contentful' -function isIdentifiedProfile(profile: unknown): boolean { - if (typeof profile !== 'object' || profile === null) { - return false - } +interface AppProps { + globalLiveUpdates: boolean + onToggleGlobalLiveUpdates: () => void +} - const record = profile as { traits?: unknown } - if (typeof record.traits !== 'object' || record.traits === null) { +function isIdentifiedProfile(profile: Profile | undefined): boolean { + if (profile === undefined) { return false } - const traits = record.traits as { identified?: unknown } + const { traits } = profile return Boolean(traits.identified) } @@ -32,7 +38,10 @@ function toEntryMap(entries: ContentfulEntry[]): Map { return new Map(entries.map((entry) => [entry.sys.id, entry])) } -export default function App(): JSX.Element { +export default function App({ + globalLiveUpdates, + onToggleGlobalLiveUpdates, +}: AppProps): JSX.Element { const location = useLocation() const { sdk, isReady, error } = useOptimization() const { consent, profile, personalizations } = useOptimizationState(sdk?.states) @@ -81,6 +90,9 @@ export default function App(): JSX.Element { () => (Array.isArray(personalizations) ? personalizations.length : 0), [personalizations], ) + const liveUpdatesBaselineEntry = entriesById.get(LIVE_UPDATES_ENTRY_ID) + const hasPageTwoEntries = + entriesById.has(PAGE_TWO_AUTO_ENTRY_ID) && entriesById.has(PAGE_TWO_MANUAL_ENTRY_ID) const handleIdentify = (): void => { if (!isReady || sdk === undefined) { @@ -122,6 +134,14 @@ export default function App(): JSX.Element { return

Loading entries...

} + if (!liveUpdatesBaselineEntry) { + return

Live updates baseline entry is missing from fetched entries.

+ } + + if (!hasPageTwoEntries) { + return

Page Two demo entries are missing from fetched entries.

+ } + return (