diff --git a/envConfig/live.env b/envConfig/live.env
index c089109531c..188d26fe203 100644
--- a/envConfig/live.env
+++ b/envConfig/live.env
@@ -35,4 +35,4 @@ SIMORGH_OPTIMIZELY_SDK_KEY=4Rje1JY7YY1FhaiHJ88Zi
NEXT_TELEMETRY_DISABLED=1
-UAS_PUBLIC_API_KEY=b6imedcp98usf
\ No newline at end of file
+SIMORGH_UAS_PUBLIC_API_KEY=b6imedcp98usf
\ No newline at end of file
diff --git a/envConfig/local.env b/envConfig/local.env
index 8322bdc11da..efe8bd296bd 100644
--- a/envConfig/local.env
+++ b/envConfig/local.env
@@ -37,4 +37,4 @@ SIMORGH_OPTIMIZELY_SDK_KEY=LptPKDnHyAFu9V12s5xCz
NEXT_TELEMETRY_DISABLED=1
-UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
+SIMORGH_UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
diff --git a/envConfig/preview1.env b/envConfig/preview1.env
index ee2872e8d8a..788a704b833 100644
--- a/envConfig/preview1.env
+++ b/envConfig/preview1.env
@@ -36,4 +36,4 @@ SIMORGH_OPTIMIZELY_SDK_KEY=LptPKDnHyAFu9V12s5xCz
NEXT_TELEMETRY_DISABLED=1
-UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
+SIMORGH_UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
diff --git a/envConfig/preview2.env b/envConfig/preview2.env
index c25da376a20..643fcda5fed 100644
--- a/envConfig/preview2.env
+++ b/envConfig/preview2.env
@@ -36,4 +36,4 @@ SIMORGH_OPTIMIZELY_SDK_KEY=LptPKDnHyAFu9V12s5xCz
NEXT_TELEMETRY_DISABLED=1
-UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
+SIMORGH_UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
diff --git a/envConfig/test.env b/envConfig/test.env
index d6475e7eebe..a5b3a42c7a7 100644
--- a/envConfig/test.env
+++ b/envConfig/test.env
@@ -36,4 +36,4 @@ SIMORGH_OPTIMIZELY_SDK_KEY=LptPKDnHyAFu9V12s5xCz
NEXT_TELEMETRY_DISABLED=1
-UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
+SIMORGH_UAS_PUBLIC_API_KEY=d50mlibdoklnk
\ No newline at end of file
diff --git a/src/app/components/SaveArticleButton/index.styles.ts b/src/app/components/SaveArticleButton/index.styles.ts
new file mode 100644
index 00000000000..9f610b6f269
--- /dev/null
+++ b/src/app/components/SaveArticleButton/index.styles.ts
@@ -0,0 +1,17 @@
+import { css, Theme } from '@emotion/react';
+/** Temporary css styling until UX work is complete */
+const styles = {
+ buttonWrapper: ({ mq }: Theme) =>
+ css({
+ display: 'flex',
+ marginTop: '1rem',
+ marginBottom: '1rem',
+ padding: '1rem',
+ marginLeft: '0.5rem',
+ [mq.GROUP_4_MIN_WIDTH]: {
+ marginLeft: 0,
+ },
+ }),
+};
+
+export default styles;
diff --git a/src/app/components/SaveArticleButton/index.test.tsx b/src/app/components/SaveArticleButton/index.test.tsx
new file mode 100644
index 00000000000..709b17cd8e7
--- /dev/null
+++ b/src/app/components/SaveArticleButton/index.test.tsx
@@ -0,0 +1,65 @@
+import useUASButton from '#app/hooks/useUASButton';
+import { render, screen } from '../react-testing-library-with-providers';
+import SaveArticleButton from './index';
+
+jest.mock('#app/hooks/useUASButton');
+
+const mockedUseUASButton = useUASButton as jest.Mock;
+
+describe('SaveArticleButton', () => {
+ const defaultProps = {
+ articleId: '123',
+ service: 'hindi',
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('does not render button when showButton is false', () => {
+ mockedUseUASButton.mockReturnValue({
+ showButton: false,
+ isSaved: false,
+ isLoading: false,
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders "Save for later" when not saved', () => {
+ mockedUseUASButton.mockReturnValue({
+ showButton: true,
+ isSaved: false,
+ isLoading: false,
+ });
+
+ render();
+ expect(screen.getByRole('button')).toHaveTextContent('Save for later');
+ });
+
+ test('renders "Remove from saved" when saved', () => {
+ mockedUseUASButton.mockReturnValue({
+ showButton: true,
+ isSaved: true,
+ isLoading: false,
+ });
+
+ render();
+ expect(screen.getByRole('button')).toHaveTextContent('Remove from saved');
+ });
+
+ test('renders loading state and disables button', () => {
+ mockedUseUASButton.mockReturnValue({
+ showButton: true,
+ isSaved: false,
+ isLoading: true,
+ });
+
+ render();
+ const button = screen.getByRole('button');
+
+ expect(button).toHaveTextContent('Loading...');
+ expect(button).toBeDisabled();
+ });
+});
diff --git a/src/app/components/SaveArticleButton/index.tsx b/src/app/components/SaveArticleButton/index.tsx
new file mode 100644
index 00000000000..2587917a198
--- /dev/null
+++ b/src/app/components/SaveArticleButton/index.tsx
@@ -0,0 +1,55 @@
+import useUASButton from '#app/hooks/useUASButton';
+import styles from './index.styles';
+
+interface SaveArticleButtonProps {
+ articleId: string;
+ service: string;
+}
+/** A button component that allows users to save an article for later reading,
+ * showing the button based on user sign in status and feature toggles,
+ * and displaying the saved status, loading state, and handling errors from the UAS API.
+ * FUTURE TODO : Implement button click handler to toggle saved state */
+
+const SaveArticleButton = ({ articleId, service }: SaveArticleButtonProps) => {
+ const { showButton, isSaved, isLoading, error } = useUASButton({
+ articleId,
+ service,
+ });
+
+ if (!showButton) {
+ return null;
+ }
+ // TODO : Labels and text will be updated in a future PR to support translations and figma designs
+ const buttonLabel = isSaved ? 'Remove from saved' : 'Save for later';
+
+ const getButtonText = () => {
+ if (isLoading) return 'Loading...';
+ return isSaved ? 'Remove from saved' : 'Save for later';
+ };
+
+ // TODO : Will modify based on future error handling implementation,
+ // currently just hides the button if there is an error fetching save status
+ if (error) {
+ // Logging until we have proper error handling in place
+ // eslint-disable-next-line no-console
+ console.log('Error fetching saved status for article:', {
+ articleId,
+ error,
+ });
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default SaveArticleButton;
diff --git a/src/app/contexts/AccountContext/index.tsx b/src/app/contexts/AccountContext/index.tsx
index 53ba0b01f03..c422c22f469 100644
--- a/src/app/contexts/AccountContext/index.tsx
+++ b/src/app/contexts/AccountContext/index.tsx
@@ -9,6 +9,7 @@ import {
import { AccountContextProps, IdctaConfig } from '#app/models/types/account';
import appendCtaQueryParams from '#app/lib/idcta/appendCtaQueryParams';
import { ServiceContext } from '#app/contexts/ServiceContext';
+import { RequestContext } from '#app/contexts/RequestContext';
import onClient from '#app/lib/utilities/onClient';
import Cookie from 'js-cookie';
@@ -29,13 +30,20 @@ export const AccountProvider = ({
initialConfig,
}: PropsWithChildren) => {
const { locale } = use(ServiceContext);
+ const { isAmp = false, isApp = false, isLite = false } = use(RequestContext);
const [pageToReturnTo, setPageToReturnTo] = useState(null);
useEffect(() => {
setPageToReturnTo(window.location.href);
}, []);
- const isIdctaAvailable = initialConfig?.['id-availability'] === 'GREEN';
+ // IDCTA / UAS is not available on AMP, Lite or App platforms — ensure provider
+ // centralises this logic so individual components don't need to check platform.
+ const isIdctaAvailable =
+ initialConfig?.['id-availability'] === 'GREEN' &&
+ !isAmp &&
+ !isLite &&
+ !isApp;
const buildAccountUrl = (url?: string) => {
return isIdctaAvailable && url
diff --git a/src/app/hooks/useUASButton/index.test.tsx b/src/app/hooks/useUASButton/index.test.tsx
new file mode 100644
index 00000000000..c2371968beb
--- /dev/null
+++ b/src/app/hooks/useUASButton/index.test.tsx
@@ -0,0 +1,125 @@
+import { use } from 'react';
+import { renderHook } from '#app/components/react-testing-library-with-providers';
+import useUASFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
+import isLocal from '#app/lib/utilities/isLocal';
+import useUASButton from './index';
+
+import useToggle from '../useToggle';
+
+jest.mock('#app/hooks/useUASFetchSaveStatus');
+jest.mock('../useToggle');
+jest.mock('#app/lib/utilities/isLocal');
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ use: jest.fn(),
+}));
+
+const mockuseUASFetchSaveStatus = useUASFetchSaveStatus as jest.Mock;
+const mockUseToggle = useToggle as jest.Mock;
+const mockIsLocal = isLocal as jest.Mock;
+describe('useUASButton', () => {
+ const defaultProps = {
+ articleId: '123',
+ service: 'hindi',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockuseUASFetchSaveStatus.mockReturnValue({
+ isSaved: false,
+ isLoading: false,
+ error: null,
+ });
+
+ (use as jest.Mock).mockReturnValue({
+ isSignedIn: false,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('returns showButton = false when feature toggle is off', () => {
+ mockUseToggle.mockReturnValue({ enabled: false });
+ mockIsLocal.mockReturnValue(false);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: true });
+
+ const { result } = renderHook(() => useUASButton(defaultProps));
+
+ expect(result.current.showButton).toBe(false);
+ });
+
+ test('returns showButton = false when user is not signed in', () => {
+ mockUseToggle.mockReturnValue({ enabled: true });
+ mockIsLocal.mockReturnValue(false);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: false });
+
+ const { result } = renderHook(() => useUASButton({ ...defaultProps }));
+
+ expect(result.current.showButton).toBe(false);
+ });
+
+ test('returns showButton = true when feature enabled and signed in', () => {
+ mockUseToggle.mockReturnValue({ enabled: true });
+ mockIsLocal.mockReturnValue(false);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: true });
+
+ mockuseUASFetchSaveStatus.mockReturnValue({
+ isSaved: true,
+ isLoading: false,
+ error: null,
+ });
+
+ const { result } = renderHook(() => useUASButton(defaultProps));
+
+ expect(result.current.showButton).toBe(true);
+ });
+
+ test('passes articleId to useUASFetchSaveStatus when showButton is true', () => {
+ mockUseToggle.mockReturnValue({ enabled: true });
+ mockIsLocal.mockReturnValue(false);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: true });
+
+ renderHook(() => useUASButton(defaultProps));
+
+ expect(mockuseUASFetchSaveStatus).toHaveBeenCalledWith('123');
+ });
+
+ test('passes empty string when showButton is false', () => {
+ mockUseToggle.mockReturnValue({ enabled: false });
+ mockIsLocal.mockReturnValue(false);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: false });
+
+ renderHook(() => useUASButton(defaultProps));
+
+ expect(mockuseUASFetchSaveStatus).toHaveBeenCalledWith('');
+ });
+
+ test('respects local environment service filtering', () => {
+ mockUseToggle.mockReturnValue({
+ enabled: true,
+ value: 'hindi|sport',
+ });
+ mockIsLocal.mockReturnValue(true);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: true });
+
+ const { result } = renderHook(() => useUASButton(defaultProps));
+
+ expect(result.current.showButton).toBe(true);
+ });
+
+ test('hides button if service not in toggle value in local', () => {
+ mockUseToggle.mockReturnValue({
+ enabled: true,
+ value: 'mundo',
+ });
+ mockIsLocal.mockReturnValue(true);
+ (use as jest.Mock).mockReturnValue({ isSignedIn: true });
+
+ const { result } = renderHook(() => useUASButton(defaultProps));
+
+ expect(result.current.showButton).toBe(false);
+ });
+});
diff --git a/src/app/hooks/useUASButton/index.ts b/src/app/hooks/useUASButton/index.ts
new file mode 100644
index 00000000000..24c1ffc256d
--- /dev/null
+++ b/src/app/hooks/useUASButton/index.ts
@@ -0,0 +1,50 @@
+import { use } from 'react';
+import useUASFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
+import { AccountContext } from '#app/contexts/AccountContext';
+import isLocal from '#app/lib/utilities/isLocal';
+import useToggle from '../useToggle';
+
+/** A hook that fetches an article’s saved status and controls showing the save UAS button
+ * based on feature toggles and sign in status,
+ * with room to later expand for toggling the save state based on user actions. */
+
+interface UseUASButtonProps {
+ articleId: string;
+ service: string;
+}
+
+interface UseUASButtonReturn {
+ showButton: boolean;
+ isSaved: boolean;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+const useUASButton = ({
+ service,
+ articleId,
+}: UseUASButtonProps): UseUASButtonReturn => {
+ const { isSignedIn } = use(AccountContext);
+ const { enabled: featureToggleOn = false, value: accountService = '' } =
+ useToggle('uasPersonalization');
+
+ const isUASEnabled =
+ featureToggleOn &&
+ (isLocal()
+ ? accountService?.toString().split('|').includes(service)
+ : true);
+
+ const showButton = isUASEnabled && isSignedIn;
+
+ const { isSaved, isLoading, error } = useUASFetchSaveStatus(
+ showButton ? articleId : '',
+ );
+ return {
+ showButton,
+ isSaved,
+ isLoading,
+ error,
+ };
+};
+
+export default useUASButton;
diff --git a/src/app/hooks/useUASFetchSaveStatus/index.test.tsx b/src/app/hooks/useUASFetchSaveStatus/index.test.tsx
new file mode 100644
index 00000000000..8fbc9ee3843
--- /dev/null
+++ b/src/app/hooks/useUASFetchSaveStatus/index.test.tsx
@@ -0,0 +1,73 @@
+import { renderHook } from '#app/components/react-testing-library-with-providers';
+import { waitFor } from '@testing-library/react';
+import uasApiRequest from '#app/lib/uasApi';
+import { buildGlobalId, ACTIVITY_TYPE } from '#app/lib/uasApi/uasUtility';
+import useUASFetchSaveStatus from './index';
+
+jest.mock('#app/lib/uasApi');
+jest.mock('#app/lib/uasApi/uasUtility');
+
+const mockUasApiRequest = uasApiRequest as jest.Mock;
+const mockBuildGlobalId = buildGlobalId as jest.Mock;
+
+describe('useUASFetchSaveStatus', () => {
+ const defaultArticleId = '123';
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('returns isSaved = true when API returns 200', async () => {
+ mockBuildGlobalId.mockReturnValue('global-123');
+ mockUasApiRequest.mockResolvedValue({ ok: true, status: 200 });
+
+ const { result } = renderHook(() =>
+ useUASFetchSaveStatus(defaultArticleId),
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isSaved).toBe(true);
+ expect(result.current.error).toBeNull();
+ expect(mockUasApiRequest).toHaveBeenCalledWith(
+ 'GET',
+ ACTIVITY_TYPE,
+ expect.objectContaining({ globalId: 'global-123' }),
+ );
+ });
+
+ test('returns isSaved = false when API returns 204', async () => {
+ mockBuildGlobalId.mockReturnValue('global-123');
+ mockUasApiRequest.mockResolvedValue({ ok: true, status: 204 });
+
+ const { result } = renderHook(() =>
+ useUASFetchSaveStatus(defaultArticleId),
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isSaved).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ test('returns error and isSaved = false when API fails', async () => {
+ mockBuildGlobalId.mockReturnValue('global-123');
+ const apiError = new Error('API failed');
+ mockUasApiRequest.mockRejectedValue(apiError);
+
+ const { result } = renderHook(() =>
+ useUASFetchSaveStatus(defaultArticleId),
+ );
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.isSaved).toBe(false);
+ expect(result.current.error).toBe(apiError);
+ });
+
+ test('does not call API when articleId is empty', () => {
+ renderHook(() => useUASFetchSaveStatus(''));
+
+ expect(mockUasApiRequest).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/hooks/useUASFetchSaveStatus/index.ts b/src/app/hooks/useUASFetchSaveStatus/index.ts
new file mode 100644
index 00000000000..ea87a17fa77
--- /dev/null
+++ b/src/app/hooks/useUASFetchSaveStatus/index.ts
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react';
+import uasApiRequest from '#app/lib/uasApi';
+import { buildGlobalId, ACTIVITY_TYPE } from '#app/lib/uasApi/uasUtility';
+import { HTTP_NO_CONTENT } from '#app/lib/statusCodes.const';
+
+/** A hook that fetches an article’s saved status from the UAS API,
+ * returning the saved status, loading state, and any error encountered. */
+
+interface UseUASFetchSaveStatusReturn {
+ isSaved: boolean;
+ isLoading: boolean;
+ error: Error | null;
+}
+
+const useUASFetchSaveStatus = (
+ articleId: string,
+): UseUASFetchSaveStatusReturn => {
+ const [isSaved, setIsSaved] = useState(false);
+ const [isLoading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!articleId) return;
+ const abortController = new AbortController();
+
+ const fetchArticleSaveStatus = async (): Promise => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const globalId = buildGlobalId(articleId);
+ const response = await uasApiRequest('GET', ACTIVITY_TYPE, {
+ globalId,
+ signal: abortController.signal,
+ });
+
+ // If response is successful and not 204 (No Content), article is saved
+ // 204 means no content found - article not saved
+ const articleIsSaved =
+ response.ok && response.status !== HTTP_NO_CONTENT;
+ setIsSaved(articleIsSaved);
+ } catch (err) {
+ // If the request was aborted, don't update state
+ const isAbort = (err as { name?: string })?.name === 'AbortError';
+ if (isAbort) return;
+
+ // If API call fails or returns error, treat as not saved
+ setError(err instanceof Error ? err : new Error('Unknown error'));
+ setIsSaved(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchArticleSaveStatus();
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ abortController.abort();
+ };
+ }, [articleId]);
+
+ return { isSaved, isLoading, error };
+};
+
+export default useUASFetchSaveStatus;
diff --git a/src/app/lib/config/toggles/__snapshots__/index.test.js.snap b/src/app/lib/config/toggles/__snapshots__/index.test.js.snap
index fc32bb334fb..894990c2fc4 100644
--- a/src/app/lib/config/toggles/__snapshots__/index.test.js.snap
+++ b/src/app/lib/config/toggles/__snapshots__/index.test.js.snap
@@ -85,6 +85,9 @@ exports[`Toggles Config when application environment is live should contain corr
"topBarOJs": {
"enabled": true,
},
+ "uasPersonalization": {
+ "enabled": false,
+ },
"variantCookie": {
"enabled": true,
},
@@ -180,6 +183,10 @@ exports[`Toggles Config when application environment is local should contain cor
"topBarOJs": {
"enabled": true,
},
+ "uasPersonalization": {
+ "enabled": true,
+ "value": "hindi",
+ },
"variantCookie": {
"enabled": true,
},
@@ -274,6 +281,9 @@ exports[`Toggles Config when application environment is test should contain corr
"topBarOJs": {
"enabled": true,
},
+ "uasPersonalization": {
+ "enabled": false,
+ },
"variantCookie": {
"enabled": true,
},
diff --git a/src/app/lib/config/toggles/liveConfig.js b/src/app/lib/config/toggles/liveConfig.js
index 6930ead9057..79847cd8182 100644
--- a/src/app/lib/config/toggles/liveConfig.js
+++ b/src/app/lib/config/toggles/liveConfig.js
@@ -85,6 +85,9 @@ export default {
variantCookie: {
enabled: true,
},
+ uasPersonalization: {
+ enabled: false,
+ },
webVitalsMonitoring: {
enabled: true,
},
diff --git a/src/app/lib/config/toggles/localConfig.js b/src/app/lib/config/toggles/localConfig.js
index 43f8e086dd9..ef4b521695b 100644
--- a/src/app/lib/config/toggles/localConfig.js
+++ b/src/app/lib/config/toggles/localConfig.js
@@ -86,6 +86,10 @@ export default {
variantCookie: {
enabled: true,
},
+ uasPersonalization: {
+ enabled: true,
+ value: 'hindi',
+ },
webVitalsMonitoring: {
enabled: true,
},
diff --git a/src/app/lib/config/toggles/testConfig.js b/src/app/lib/config/toggles/testConfig.js
index e5fe94a1c74..764d602aba7 100644
--- a/src/app/lib/config/toggles/testConfig.js
+++ b/src/app/lib/config/toggles/testConfig.js
@@ -85,6 +85,9 @@ export default {
variantCookie: {
enabled: true,
},
+ uasPersonalization: {
+ enabled: false,
+ },
webVitalsMonitoring: {
enabled: true,
},
diff --git a/src/app/lib/statusCodes.const.js b/src/app/lib/statusCodes.const.js
index b58226d022d..fa8455a9a67 100644
--- a/src/app/lib/statusCodes.const.js
+++ b/src/app/lib/statusCodes.const.js
@@ -3,4 +3,5 @@ export const BAD_GATEWAY = 502;
export const BAD_REQUEST = 400;
export const INTERNAL_SERVER_ERROR = 500;
export const NOT_FOUND = 404;
+export const HTTP_NO_CONTENT = 204;
export const UPSTREAM_CODES_TO_PROPAGATE_IN_SIMORGH = [OK, NOT_FOUND];
diff --git a/src/app/lib/uasApi/activityTypes.ts b/src/app/lib/uasApi/activityTypes.ts
deleted file mode 100644
index bebbbd699fe..00000000000
--- a/src/app/lib/uasApi/activityTypes.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-const activityTypes = ['favourites'];
-
-export default activityTypes;
diff --git a/src/app/lib/uasApi/getAuthHeader.ts b/src/app/lib/uasApi/getAuthHeader.ts
index 1c3aea18150..dc98fd2ddbf 100644
--- a/src/app/lib/uasApi/getAuthHeader.ts
+++ b/src/app/lib/uasApi/getAuthHeader.ts
@@ -1,8 +1,10 @@
import Cookie from 'js-cookie';
+import onClient from '#app/lib/utilities/onClient';
import { getEnvConfig } from '../utilities/getEnvConfig';
const getAuthHeaders = (): Record => {
- const cknsAtkn = Cookie.get('ckns_atkn');
+ const cknsAtkn = onClient() ? Cookie.get('ckns_atkn') : undefined;
+
const apiKey = getEnvConfig().SIMORGH_UAS_PUBLIC_API_KEY;
if (!cknsAtkn || !apiKey) {
diff --git a/src/app/lib/uasApi/index.ts b/src/app/lib/uasApi/index.ts
index 60fec23deed..fe54cb289c4 100644
--- a/src/app/lib/uasApi/index.ts
+++ b/src/app/lib/uasApi/index.ts
@@ -1,6 +1,6 @@
import isLive from '#app/lib/utilities/isLive';
import getAuthHeaders from './getAuthHeader';
-import activityTypes from './activityTypes';
+import { activityTypes } from './uasUtility';
export type UasMethod = 'POST' | 'DELETE' | 'GET';
@@ -18,6 +18,7 @@ export interface UasApiRequestBody {
interface UasRequestOptions {
body?: UasApiRequestBody;
globalId?: string;
+ signal?: AbortSignal;
}
const getUasHost = () =>
@@ -52,7 +53,7 @@ const validateRequest = (
const uasApiRequest = async (
method: UasMethod,
activityType: string,
- { body, globalId }: UasRequestOptions = {},
+ { body, globalId, signal }: UasRequestOptions = {},
): Promise => {
validateRequest(method, { body, globalId }, activityType);
@@ -71,6 +72,8 @@ const uasApiRequest = async (
headers,
credentials: 'include',
body: method === 'POST' ? JSON.stringify(body) : undefined,
+ // Allow callers to abort the request
+ ...(signal ? { signal } : {}),
});
if (!response.ok) {
diff --git a/src/app/lib/uasApi/uasUtility.ts b/src/app/lib/uasApi/uasUtility.ts
new file mode 100644
index 00000000000..6dabe2fa88b
--- /dev/null
+++ b/src/app/lib/uasApi/uasUtility.ts
@@ -0,0 +1,22 @@
+const activityTypes = ['favourites'];
+const RESOURCE_DOMAIN = 'articles';
+const RESOURCE_TYPE = 'article';
+const ACTIVITY_TYPE = 'favourites';
+const ACTIVITY_FAVOURITE_ACTION = 'favourited';
+
+const buildGlobalId = (articleId: string): string =>
+ `urn:bbc:${RESOURCE_DOMAIN}:${RESOURCE_TYPE}:${articleId}`;
+
+const parseArticleID = (articleId: string): string => {
+ return articleId.split(':').pop() || '';
+};
+
+export {
+ activityTypes,
+ buildGlobalId,
+ ACTIVITY_TYPE,
+ RESOURCE_DOMAIN,
+ RESOURCE_TYPE,
+ ACTIVITY_FAVOURITE_ACTION,
+ parseArticleID,
+};
diff --git a/src/app/lib/utilities/getEnvConfig/index.ts b/src/app/lib/utilities/getEnvConfig/index.ts
index b5177f55cdd..f3b263c5c08 100644
--- a/src/app/lib/utilities/getEnvConfig/index.ts
+++ b/src/app/lib/utilities/getEnvConfig/index.ts
@@ -28,7 +28,7 @@ export const getProcessEnvAppVariables = () => ({
process.env.SIMORGH_WEBVITALS_REPORTING_ENDPOINT,
SIMORGH_WEBVITALS_DEFAULT_SAMPLING_RATE:
process.env.SIMORGH_WEBVITALS_DEFAULT_SAMPLING_RATE,
- SIMORGH_UAS_PUBLIC_API_KEY: process.env.UAS_PUBLIC_API_KEY,
+ SIMORGH_UAS_PUBLIC_API_KEY: process.env.SIMORGH_UAS_PUBLIC_API_KEY,
});
export function getEnvConfig(): EnvConfig {
diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx
index 4ea8540124d..9420af331a0 100644
--- a/src/app/pages/ArticlePage/ArticlePage.tsx
+++ b/src/app/pages/ArticlePage/ArticlePage.tsx
@@ -58,6 +58,8 @@ import PWAPromotionalBanner from '#app/components/PWAPromotionalBanner';
import ContinueReadingButton, {
ContinueReadingButtonProps,
} from '#app/components/ContinueReadingButton';
+import SaveArticleButton from '#app/components/SaveArticleButton';
+import { parseArticleID } from '#app/lib/uasApi/uasUtility';
import ElectionBanner from './ElectionBanner';
import ImageWithCaption from '../../components/ImageWithCaption';
import AdContainer from '../../components/Ad';
@@ -105,32 +107,43 @@ const getTimestampComponent =
lastPublished: string,
readTimeValue: number | undefined,
readTimeTranslations: Translations['readTime'],
+ articleId: string,
+ service: string,
) =>
(props: ComponentToRenderProps & TimeStampProps) => {
const shouldDisplayReadTime = !!(readTimeTranslations && readTimeValue);
- return hasByline ? (
-
-
- {shouldDisplayReadTime && (
-
- )}
-
- ) : (
+ return (
<>
-
- {shouldDisplayReadTime && (
-
+ {hasByline ? (
+
+
+ {shouldDisplayReadTime && (
+
+ )}
+
+ ) : (
+ <>
+
+ {shouldDisplayReadTime && (
+
+ )}
+ >
)}
+ {/* Temporary SaveArticleButton */}
+
>
);
};
@@ -203,6 +216,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
showRelatedTopics,
brandName,
translations,
+ service,
} = use(ServiceContext);
const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage');
@@ -259,6 +273,7 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
const firstPublished = getFirstPublished(pageData);
const lastPublished = getLastPublished(pageData);
const aboutTags = getAboutTags(pageData);
+ const articleId = getArticleId(pageData) ?? '';
const topics = pageData?.metadata?.topics ?? [];
const blocks = pageData?.content?.model?.blocks ?? [];
const mediaCurationContent = pageData?.secondaryColumn?.mediaCuration;
@@ -345,6 +360,8 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
lastPublished,
readTimeValue,
translations.readTime,
+ articleId,
+ service,
),
social: SocialEmbedContainer,
embed: UnsupportedEmbed,