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,