diff --git a/src/app/components/FrostedGlassPromo/index.test.tsx b/src/app/components/FrostedGlassPromo/index.test.tsx
index f7cfbdd48c2..83e6c53109d 100644
--- a/src/app/components/FrostedGlassPromo/index.test.tsx
+++ b/src/app/components/FrostedGlassPromo/index.test.tsx
@@ -1,13 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PropsWithChildren } from 'react';
-
import { ToggleContextProvider } from '../../contexts/ToggleContext';
import { RequestContextProvider } from '../../contexts/RequestContext';
import { ServiceContextProvider } from '../../contexts/ServiceContext';
-
import { STORY_PAGE } from '../../routes/utils/pageTypes';
import makeRelativeUrlPath from '../../lib/utilities/makeRelativeUrlPath';
-import * as clickTracking from '../../hooks/useClickTrackerHandler';
import { render } from '../react-testing-library-with-providers';
import { Services, Variants } from '../../models/types/global';
@@ -185,18 +182,8 @@ describe('Frosted Glass Promo', () => {
expect(container).toBeEmptyDOMElement();
});
- // Only expecting clicks to be emitted from here - view tracking is handled at
+ // Expects view tracking to be handled at
// the list level - eg containers/CpsFeatureAnalysis
- it('should track clicks', () => {
- const clickTrackerSpy = jest.spyOn(clickTracking, 'default');
- render();
-
- expect(clickTrackerSpy).toHaveBeenCalledWith({
- componentName: 'features',
- url: cpsPromoFixture.item.locators.assetUri,
- });
- });
-
it('should render lazyload component for frosted glass section', () => {
const { getByTestId } = render(
,
diff --git a/src/app/components/FrostedGlassPromo/index.tsx b/src/app/components/FrostedGlassPromo/index.tsx
index 4a6e4fae8c1..ed2eea6ac21 100644
--- a/src/app/components/FrostedGlassPromo/index.tsx
+++ b/src/app/components/FrostedGlassPromo/index.tsx
@@ -4,18 +4,15 @@ import type { ReactNode } from 'react';
import { PropsWithChildren, use } from 'react';
import pick from 'ramda/src/pick';
import Lazyload from 'react-lazyload';
-
import IMAGE from '../Image';
import makeRelativeUrlPath from '../../lib/utilities/makeRelativeUrlPath';
-import useClickTrackerHandler from '../../hooks/useClickTrackerHandler';
import { RequestContext } from '../../contexts/RequestContext';
-
import FrostedGlassPanel from './FrostedGlassPanel';
import withData from './withData';
-
import styles from './styles';
import { EventTrackingBlock } from '../../models/types/eventTracking';
import { PromoProps } from './types';
+import Link from '../Link';
const PANEL_OFFSET = 250;
@@ -54,15 +51,15 @@ const FrostedGlassPromo = ({
const isCanonical = !isAmp;
const relativeUrl = makeRelativeUrlPath(url);
- const clickTracker = useClickTrackerHandler({
+ const eventTrackingInfo = {
...(eventTrackingData || {}),
url: relativeUrl,
- });
+ };
const promoText = (
<>
-
diff --git a/src/app/components/InPictureVideo/README.md b/src/app/components/InPictureVideo/README.md
new file mode 100644
index 00000000000..d9af3b4948a
--- /dev/null
+++ b/src/app/components/InPictureVideo/README.md
@@ -0,0 +1,15 @@
+## Description
+
+**Next.js only component** - In picture video maintains video playback as the user browses through our Next.js single page applications.
+
+## Props
+
+| Name | type | Description |
+| -------- | -------- | ---------------------------------------------------------------------------------------- |
+| | |
+
+### Example
+
+```javascript
+
+```
diff --git a/src/app/components/InPictureVideo/index.stories.tsx b/src/app/components/InPictureVideo/index.stories.tsx
new file mode 100644
index 00000000000..be839d53563
--- /dev/null
+++ b/src/app/components/InPictureVideo/index.stories.tsx
@@ -0,0 +1,22 @@
+import InPictureVideo from '.'
+import readme from './README.md'
+import metadata from './metadata.json'
+
+const Component = () => (
+
+);
+
+export const Example = () => (
+
+);
+
+export default {
+ title: 'Components/InPictureVideo',
+ Component,
+ parameters: {
+ docs: {
+ readme,
+ metadata,
+ },
+ },
+};
diff --git a/src/app/components/InPictureVideo/index.style.tsx b/src/app/components/InPictureVideo/index.style.tsx
new file mode 100644
index 00000000000..b9abb726a93
--- /dev/null
+++ b/src/app/components/InPictureVideo/index.style.tsx
@@ -0,0 +1,18 @@
+import { Theme, css } from '@emotion/react';
+
+export default {
+ container: ({ spacings }: Theme) =>
+ css({
+ position: 'fixed',
+ display: 'block',
+ top: `${spacings.FULL}rem`,
+ insetInlineEnd: `${spacings.FULL}rem`,
+ zIndex: 10,
+ }),
+ video: () =>
+ css({
+ width: '240px',
+ height: '240px',
+ borderRadius: '10%',
+ }),
+};
diff --git a/src/app/components/InPictureVideo/index.test.tsx b/src/app/components/InPictureVideo/index.test.tsx
new file mode 100644
index 00000000000..9b95247bf86
--- /dev/null
+++ b/src/app/components/InPictureVideo/index.test.tsx
@@ -0,0 +1,9 @@
+import { render } from '../react-testing-library-with-providers';
+import InPicture from '.';
+
+describe('InPictureVideo', () => {
+ it('should track clicks', () => {
+ render(
);
+ expect(true).toBe(true);
+ });
+});
diff --git a/src/app/components/InPictureVideo/index.tsx b/src/app/components/InPictureVideo/index.tsx
new file mode 100644
index 00000000000..536e2b4f602
--- /dev/null
+++ b/src/app/components/InPictureVideo/index.tsx
@@ -0,0 +1,15 @@
+import style from './index.style';
+
+export default () => {
+ return (
+
+ );
+};
diff --git a/src/app/components/InPictureVideo/metadata.json b/src/app/components/InPictureVideo/metadata.json
new file mode 100644
index 00000000000..c55e51aace0
--- /dev/null
+++ b/src/app/components/InPictureVideo/metadata.json
@@ -0,0 +1,29 @@
+{
+ "alpha": false,
+ "lastUpdated": {
+ "day": 26,
+ "month": "December",
+ "year": 2025
+ },
+ "uxAccessibilityDoc": {
+ "done": false,
+ "reference": {
+ "url": "",
+ "label": "Screen Reader UX"
+ }
+ },
+ "acceptanceCriteria": {
+ "done": false,
+ "reference": {
+ "url": "",
+ "label": "Accessibility Acceptance Criteria"
+ }
+ },
+ "swarm": {
+ "done": false,
+ "reference": {
+ "url": "",
+ "label": "Accessibility Swarm Notes"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/components/Link/index.test.tsx b/src/app/components/Link/index.test.tsx
new file mode 100644
index 00000000000..9883d851788
--- /dev/null
+++ b/src/app/components/Link/index.test.tsx
@@ -0,0 +1,52 @@
+import * as clickTracking from '#app/hooks/useClickTrackerHandler';
+import { render } from '../react-testing-library-with-providers';
+import Link from '.';
+
+describe('Link', () => {
+ it.each([
+ {
+ type: 'html',
+ spaLink: false,
+ },
+ {
+ type: 'Next',
+ spaLink: true,
+ },
+ ])(
+ 'should render a valid anchor element for a $type type link',
+ ({ spaLink }) => {
+ const href = '/forwarding_link';
+ const title = 'Some Title';
+
+ const { container } = render(
+
+ {title}
+ ,
+ );
+ const anchor = container.querySelector('a');
+
+ expect(anchor?.href).toBe(`http://localhost${href}`);
+ expect(anchor?.innerHTML).toBe(title);
+ },
+ );
+
+ it('should track clicks', () => {
+ const href = '/forwarding_link';
+ const title = 'Some Title';
+ const isSpaLink = true;
+ const sampleEventData = {
+ url: href,
+ block: {
+ componentName: title,
+ },
+ };
+ const clickTrackerSpy = jest.spyOn(clickTracking, 'default');
+
+ render(
+
+ {title}
+ ,
+ );
+ expect(clickTrackerSpy).toHaveBeenCalledWith(sampleEventData, isSpaLink);
+ });
+});
diff --git a/src/app/components/Link/index.tsx b/src/app/components/Link/index.tsx
new file mode 100644
index 00000000000..b77fd9e83ac
--- /dev/null
+++ b/src/app/components/Link/index.tsx
@@ -0,0 +1,46 @@
+import { PropsWithChildren, use } from 'react';
+import Link from 'next/link';
+import useClickTrackerHandler from '#app/hooks/useClickTrackerHandler';
+import { RequestContext } from '#app/contexts/RequestContext';
+
+type Props = {
+ href: string;
+ className?: string;
+ spaLink?: boolean;
+ tabIndex?: number;
+ eventTrackingData?: {
+ url: string;
+ block?: { componentName: string } | undefined;
+ };
+};
+
+export default ({
+ spaLink = true,
+ children,
+ href,
+ className,
+ tabIndex,
+ eventTrackingData,
+ ...props
+}: PropsWithChildren
) => {
+ const { isLite, isAmp } = use(RequestContext);
+ const NextLink = Link;
+ const Anchor = 'a' as React.ElementType;
+ const isSpaLink = spaLink && !isLite && !isAmp;
+ const Component = isSpaLink ? NextLink : Anchor;
+
+ const clickTracker = useClickTrackerHandler(eventTrackingData, isSpaLink);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/app/hooks/useClickTrackerHandler/index.jsx b/src/app/hooks/useClickTrackerHandler/index.jsx
index e2d56300031..cd59af1b845 100644
--- a/src/app/hooks/useClickTrackerHandler/index.jsx
+++ b/src/app/hooks/useClickTrackerHandler/index.jsx
@@ -14,7 +14,7 @@ import { sendEventBeacon } from '../../components/ATIAnalytics/beacon/index';
import { ServiceContext } from '../../contexts/ServiceContext';
import { isValidClick } from './clickTypes';
-const useClickTrackerHandler = (eventTrackingData = {}) => {
+const useClickTrackerHandler = (eventTrackingData = {}, spaLink = false) => {
const {
pageIdentifier,
producerId,
@@ -67,8 +67,10 @@ const useClickTrackerHandler = (eventTrackingData = {}) => {
statsDestination,
].every(Boolean);
if (shouldSendEvent) {
- event.stopPropagation();
- event.preventDefault();
+ if (!spaLink) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
if (
optimizely &&
@@ -114,7 +116,9 @@ const useClickTrackerHandler = (eventTrackingData = {}) => {
if (optimizely) {
optimizely.close();
}
- window.location.assign(nextPageUrl);
+ if (!spaLink) {
+ window.location.assign(nextPageUrl);
+ }
}
}
}
@@ -131,6 +135,7 @@ const useClickTrackerHandler = (eventTrackingData = {}) => {
producerName,
service,
statsDestination,
+ spaLink,
optimizely,
experimentVariant,
sendOptimizelyEvents,
@@ -147,11 +152,11 @@ const useClickTrackerHandler = (eventTrackingData = {}) => {
);
};
-export default (eventTrackingData = {}) => {
+export default (eventTrackingData = {}, spaLink = false) => {
const { isAmp } = use(RequestContext);
const isHydrated = useHydrationDetection();
- const clickTracker = useClickTrackerHandler(eventTrackingData);
+ const clickTracker = useClickTrackerHandler(eventTrackingData, spaLink);
const enableStaticTracking = !isHydrated && !isAmp;
const reverbStaticUrl = constructReverbUrl({
diff --git a/webpack.config.client.js b/webpack.config.client.js
index 9bee3a009e7..9c7f209f656 100644
--- a/webpack.config.client.js
+++ b/webpack.config.client.js
@@ -200,14 +200,17 @@ module.exports = ({
// Display full duplicates information? (Default: `false`)
verbose: true,
exclude({ name, path }) {
- return (
+ const babelCriteria =
name === '@babel/runtime' &&
[
'./~/@emotion/react/~/@babel/runtime',
'./~/@loadable/component/~/@babel/runtime',
'./~/react-router-dom/~/@babel/runtime',
- ].includes(path)
- );
+ ].includes(path);
+ // Adding this one as next-js and react-router uses their own isolated version of this package.
+ // Since we're moving to next-js anyhow, it wouldn't be worth the dev effort of trying to resolve and maintain this duplicate dependency.
+ const pathToRegexCriteria = name === 'path-to-regexp';
+ return pathToRegexCriteria || babelCriteria;
},
}),
/*
diff --git a/ws-nextjs-app/next.config.js b/ws-nextjs-app/next.config.js
index 8ec3473b96a..c9fa08f78ea 100644
--- a/ws-nextjs-app/next.config.js
+++ b/ws-nextjs-app/next.config.js
@@ -5,14 +5,21 @@ const { getClientEnvVars } = require('../src/clientEnvVars');
const DOT_ENV_CONFIG = dotenv.config({ quiet: true });
-const assetPrefix =
- process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN +
- process.env.SIMORGH_PUBLIC_STATIC_ASSETS_PATH;
+// TODO: Commenting this out for dev purposes
+// const assetPrefix =
+// process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN +
+// process.env.SIMORGH_PUBLIC_STATIC_ASSETS_PATH;
+
+const assetPrefix = '';
const isLocal = process.env.SIMORGH_APP_ENV === 'local';
/** @type {import('next').NextConfig} */
module.exports = {
+ async generateBuildId() {
+ const generateSomeNumber = '1234';
+ return `ws_next_build_version_${generateSomeNumber}`;
+ },
async headers() {
return [
{
diff --git a/ws-nextjs-app/package.json b/ws-nextjs-app/package.json
index 8802f5dc62e..dab11f1eeb2 100644
--- a/ws-nextjs-app/package.json
+++ b/ws-nextjs-app/package.json
@@ -12,6 +12,7 @@
"build:test": "cp ../envConfig/test.env .env && NODE_ENV=production && next build",
"build:live": "cp ../envConfig/live.env .env && NODE_ENV=production && next build",
"build": "yarn build:local",
+ "build:start:spaPOC": "yarn build:live && yarn next start -p 7081",
"dev": "yarn setupDevEnv && next dev -p 7081",
"dev-https": "yarn setupDevEnv && next dev -p 7081 --experimental-https",
"start": "NODE_ENV=production HOSTNAME=127.0.0.1 PORT=7081 node build/standalone/ws-nextjs-app/server.js",
@@ -49,4 +50,4 @@
"next": "15.5.7",
"sharp": "0.34.4"
}
-}
+}
\ No newline at end of file
diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx
index f7a0032b2fd..48170309c11 100644
--- a/ws-nextjs-app/pages/_app.page.tsx
+++ b/ws-nextjs-app/pages/_app.page.tsx
@@ -23,6 +23,7 @@ import cspHeaderResponse, {
CspHeaderResponseProps,
} from '#nextjs/utilities/cspHeaderResponse';
import getPathExtension from '#app/utilities/getPathExtension';
+import InPictureVideo from '#app/components/InPictureVideo';
interface Props extends AppProps {
pageProps: {
@@ -125,6 +126,7 @@ export default function App({ Component, pageProps }: Props) {
{RenderChildrenOrError}
+