setIsModalOpen(false)}
+ signInUrl={signInUrl}
+ registerUrl={registerUrl}
+ />,
+ document.body,
+ )}
+ >
+ );
+};
+
+export default FollowTopicButtonGuest;
diff --git a/src/app/components/FollowTopicButton/index.styles.ts b/src/app/components/FollowTopicButton/index.styles.ts
new file mode 100644
index 00000000000..ffe47aefaf2
--- /dev/null
+++ b/src/app/components/FollowTopicButton/index.styles.ts
@@ -0,0 +1,15 @@
+import pixelsToRem from '#app/utilities/pixelsToRem';
+import { css, Theme } from '@emotion/react';
+
+const styles = {
+ buttonWrapper: ({ spacings, mq }: Theme) =>
+ css({
+ marginBlock: `${spacings.DOUBLE}rem`,
+
+ [mq.GROUP_3_MIN_WIDTH]: {
+ width: `${pixelsToRem(280)}rem`,
+ },
+ }),
+};
+
+export default styles;
diff --git a/src/app/components/FollowTopicButton/index.tsx b/src/app/components/FollowTopicButton/index.tsx
new file mode 100644
index 00000000000..64c88efda1a
--- /dev/null
+++ b/src/app/components/FollowTopicButton/index.tsx
@@ -0,0 +1,36 @@
+import { use } from 'react';
+import { AccountContext } from '#contexts/AccountContext';
+import type { TopicFollowData } from '#app/lib/uasApi/uasUtility';
+import styles from './index.styles';
+import FollowTopicButtonAuthenticated from './FollowTopicButtonAuthenticated/lazy';
+import FollowTopicButtonGuest from './FollowTopicButtonGuest';
+
+export interface FollowTopicButtonProps {
+ topicData: TopicFollowData;
+}
+
+const FOLLOW_TOPIC_BUTTON_ID = 'follow-topic-button';
+
+const FollowTopicButton = ({ topicData }: FollowTopicButtonProps) => {
+ const { isPersonalizationAvailable, isPersonalizationEnabled } =
+ use(AccountContext);
+
+ if (!isPersonalizationAvailable) return null;
+
+ return (
+ <>
+
+
+ {isPersonalizationEnabled ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default FollowTopicButton;
diff --git a/src/app/components/SaveButton/index.tsx b/src/app/components/SaveButton/index.tsx
index 1efa24872e1..c5a47b32cea 100644
--- a/src/app/components/SaveButton/index.tsx
+++ b/src/app/components/SaveButton/index.tsx
@@ -24,6 +24,8 @@ const SaveButton = ({
isSaved = false,
onClick,
testId,
+ // TODO :Ticket needed
+ // Add a buttonType prop (e.g. follow, favourites) to determine which icon to display.
...rest
}: SaveButtonProps) => {
const [isFocusedOrHovered, setIsFocusedOrHovered] = useState(false);
diff --git a/src/app/hooks/createUASStatusHook.ts b/src/app/hooks/createUASStatusHook.ts
new file mode 100644
index 00000000000..83066dc90e6
--- /dev/null
+++ b/src/app/hooks/createUASStatusHook.ts
@@ -0,0 +1,118 @@
+import { use } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import uasApiRequest from '#app/lib/uasApi';
+import { buildGlobalId, type ActivityType } from '#app/lib/uasApi/uasUtility';
+import { HTTP_NO_CONTENT } from '#app/lib/statusCodes.const';
+import { AccountContext } from '#app/contexts/AccountContext';
+
+interface UseUASStatusHookConfig {
+ activityType: ActivityType;
+ resourceDomain: string;
+ resourceType: string;
+}
+
+interface UseUASStatusHookParams {
+ config: UseUASStatusHookConfig;
+ queryKeyFn: (hashedUserId: string, resourceId: string) => unknown[];
+ statusField: StatusField;
+ enabledFn?: (resourceId: string, hashedUserId: string) => boolean;
+}
+
+type UseUASStatusHookReturn = Record<
+ StatusField,
+ boolean
+> & {
+ isLoading: boolean;
+ error: Error | null;
+ metadata?: Record;
+};
+
+enum UASStatusField {
+ SAVED = 'isSaved',
+ FOLLOWED = 'isFollowed',
+}
+
+/**
+ * Factory function that creates a UAS status fetch hook.
+ * Internal implementation - consumers should use the exported hooks (useUASFetchSaveStatus, useTopicFollowStatus).
+ *
+ * Returns a hook function that accepts a resourceId parameter and fetches its status.
+ * Handles: GET request, response parsing, query setup, error handling, caching.
+ *
+ * Example:
+ * const useArticleStatus = createUASStatusHook({
+ * config: FAVOURITES_CONFIG,
+ * queryKeyFn: (userId, id) => uasKeys.saveStatus(userId, id),
+ * statusField: UASStatusField.SAVED,
+ * });
+ */
+const createUASStatusHook = (
+ params: UseUASStatusHookParams,
+): ((resourceId: string) => UseUASStatusHookReturn) => {
+ const { config, queryKeyFn, statusField, enabledFn } = params;
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return (resourceId: string): UseUASStatusHookReturn => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const { hashedUserId = '', isRefreshAvailable } = use(AccountContext);
+
+ const isEnabled = enabledFn
+ ? enabledFn(resourceId, hashedUserId)
+ : !!resourceId && !!hashedUserId;
+
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const {
+ data = {
+ [statusField]: false,
+ metadata: undefined,
+ },
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: queryKeyFn(hashedUserId, resourceId),
+ queryFn: async () => {
+ const globalId = buildGlobalId(
+ resourceId,
+ config.resourceDomain,
+ config.resourceType,
+ );
+
+ const response = await uasApiRequest('GET', config.activityType, {
+ globalId,
+ isRefreshAvailable,
+ });
+
+ if (!response.ok || response.status === HTTP_NO_CONTENT) {
+ return { [statusField]: false };
+ }
+
+ try {
+ const responseData = (await response.json()) as {
+ metaData?: Record;
+ };
+
+ return {
+ [statusField]: true,
+ metadata: responseData.metaData,
+ };
+ } catch {
+ return {
+ [statusField]: true,
+ metadata: undefined,
+ };
+ }
+ },
+ enabled: isEnabled,
+ });
+
+ return {
+ [statusField]: data[statusField] as boolean,
+ isLoading,
+ error: error as Error | null,
+ metadata: data.metadata,
+ } as UseUASStatusHookReturn;
+ };
+};
+
+export { UASStatusField };
+export default createUASStatusHook;
diff --git a/src/app/hooks/useTopicFollowButton/index.ts b/src/app/hooks/useTopicFollowButton/index.ts
new file mode 100644
index 00000000000..a5db3f9295a
--- /dev/null
+++ b/src/app/hooks/useTopicFollowButton/index.ts
@@ -0,0 +1,75 @@
+import { use } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import uasApiRequest from '#app/lib/uasApi';
+import {
+ createFollowsPayload,
+ FOLLOWS_CONFIG,
+ type TopicFollowData,
+ buildGlobalId,
+} from '#app/lib/uasApi/uasUtility';
+import uasKeys from '#app/lib/uasApi/queryKeys';
+import { AccountContext } from '#app/contexts/AccountContext';
+import useTopicFollowStatus from '#app/hooks/useTopicFollowStatus';
+
+enum FollowAction {
+ FOLLOW = 'follow',
+ UNFOLLOW = 'unfollow',
+}
+
+interface UseTopicFollowButtonReturn {
+ isFollowed: boolean;
+ isLoading: boolean;
+ isUpdating: boolean;
+ error: Error | null;
+ handleFollowAction: (action: FollowAction) => void;
+}
+
+const useTopicFollowButton = (
+ topicData: TopicFollowData,
+): UseTopicFollowButtonReturn => {
+ const { topicId } = topicData;
+ const { hashedUserId = '', isRefreshAvailable } = use(AccountContext);
+ const queryClient = useQueryClient();
+ const { isFollowed, isLoading, error } = useTopicFollowStatus(topicId);
+
+ const mutation = useMutation({
+ mutationFn: async (action: FollowAction) => {
+ if (action === FollowAction.FOLLOW) {
+ const body = createFollowsPayload(topicData);
+ await uasApiRequest('POST', FOLLOWS_CONFIG.activityType, {
+ body,
+ isRefreshAvailable,
+ });
+ return;
+ }
+ const globalId = buildGlobalId(
+ topicId,
+ FOLLOWS_CONFIG.resourceDomain,
+ FOLLOWS_CONFIG.resourceType,
+ );
+ await uasApiRequest('DELETE', FOLLOWS_CONFIG.activityType, {
+ globalId,
+ isRefreshAvailable,
+ });
+ },
+ onSuccess: (_result, action) => {
+ queryClient.setQueryData(uasKeys.followStatus(hashedUserId, topicId), {
+ isFollowed: action === FollowAction.FOLLOW,
+ });
+ queryClient.invalidateQueries({
+ queryKey: uasKeys.followsList(hashedUserId),
+ });
+ },
+ });
+
+ return {
+ isFollowed,
+ isLoading,
+ isUpdating: mutation.isPending,
+ error: mutation.error || error,
+ handleFollowAction: mutation.mutate,
+ };
+};
+
+export { FollowAction };
+export default useTopicFollowButton;
diff --git a/src/app/hooks/useTopicFollowStatus/index.ts b/src/app/hooks/useTopicFollowStatus/index.ts
new file mode 100644
index 00000000000..a5f4284e8b0
--- /dev/null
+++ b/src/app/hooks/useTopicFollowStatus/index.ts
@@ -0,0 +1,22 @@
+import { FOLLOWS_CONFIG } from '#app/lib/uasApi/uasUtility';
+import uasKeys from '#app/lib/uasApi/queryKeys';
+import createUASStatusHook, {
+ UASStatusField,
+} from '#app/hooks/createUASStatusHook';
+
+/**
+ * POC (Follow Topics): fetches whether the signed-in user follows a topic.
+ * Wraps the generic createUASStatusHook factory with topic-specific config.
+ */
+const useTopicFollowStatus = createUASStatusHook({
+ config: {
+ activityType: FOLLOWS_CONFIG.activityType,
+ resourceDomain: FOLLOWS_CONFIG.resourceDomain,
+ resourceType: FOLLOWS_CONFIG.resourceType,
+ },
+ queryKeyFn: (hashedUserId, topicId) =>
+ uasKeys.followStatus(hashedUserId, topicId) as unknown as unknown[],
+ statusField: UASStatusField.FOLLOWED,
+});
+
+export default useTopicFollowStatus;
diff --git a/src/app/hooks/useUASFetchSaveStatus/index.ts b/src/app/hooks/useUASFetchSaveStatus/index.ts
index e97153e5d4f..35e0f058d96 100644
--- a/src/app/hooks/useUASFetchSaveStatus/index.ts
+++ b/src/app/hooks/useUASFetchSaveStatus/index.ts
@@ -1,14 +1,13 @@
-import { use } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import uasApiRequest from '#app/lib/uasApi';
-import { buildGlobalId, FAVOURITES_CONFIG } from '#app/lib/uasApi/uasUtility';
-import { HTTP_NO_CONTENT } from '#app/lib/statusCodes.const';
+import { FAVOURITES_CONFIG } from '#app/lib/uasApi/uasUtility';
import uasKeys from '#app/lib/uasApi/queryKeys';
-import { AccountContext } from '#app/contexts/AccountContext';
+import createUASStatusHook, {
+ UASStatusField,
+} from '#app/hooks/createUASStatusHook';
-/** A hook that fetches an article's saved status from the UAS API,
- * also returning saved article metadata if available.
- * Returns the saved status, loading state, error, and metadata. */
+/**
+ * Fetches an article's saved status from UAS.
+ * Wraps the generic createUASStatusHook factory with article-specific config.
+ */
interface UseUASFetchSaveStatusReturn {
isSaved: boolean;
@@ -17,54 +16,23 @@ interface UseUASFetchSaveStatusReturn {
savedMetadata?: Record;
}
-interface SavedArticleDetail {
- metaData?: Record;
-}
-
-const fetchSaveStatusWithMetadata = async (
- articleId: string,
- isRefreshAvailable: boolean,
-): Promise<{ isSaved: boolean; metadata?: Record }> => {
- const globalId = buildGlobalId(articleId);
- const response = await uasApiRequest('GET', FAVOURITES_CONFIG.activityType, {
- globalId,
- isRefreshAvailable,
- });
-
- if (!response.ok || response.status === HTTP_NO_CONTENT) {
- return { isSaved: false };
- }
-
- try {
- const responseData = (await response.json()) as SavedArticleDetail;
- return {
- isSaved: true,
- metadata: responseData.metaData,
- };
- } catch {
- return { isSaved: true, metadata: undefined };
- }
-};
+const useStatusHook = createUASStatusHook({
+ config: FAVOURITES_CONFIG,
+ queryKeyFn: (hashedUserId, articleId) =>
+ uasKeys.favouriteStatus(hashedUserId, articleId) as unknown as unknown[],
+ statusField: UASStatusField.SAVED,
+ enabledFn: articleId => !!articleId,
+});
const useUASFetchSaveStatus = (
articleId: string,
): UseUASFetchSaveStatusReturn => {
- const { hashedUserId = '', isRefreshAvailable } = use(AccountContext);
-
- const {
- data = { isSaved: false },
- isLoading,
- error,
- } = useQuery({
- queryKey: uasKeys.favouriteStatus(hashedUserId, articleId),
- queryFn: () => fetchSaveStatusWithMetadata(articleId, isRefreshAvailable),
- enabled: !!articleId,
- });
+ const { isSaved, isLoading, error, metadata } = useStatusHook(articleId);
return {
- isSaved: data.isSaved,
+ isSaved,
isLoading,
- error: error as Error | null,
- savedMetadata: data.metadata,
+ error,
+ savedMetadata: metadata,
};
};
diff --git a/src/app/lib/config/services/hindi.ts b/src/app/lib/config/services/hindi.ts
index 811a8dbb5ed..ce07a7b75ba 100644
--- a/src/app/lib/config/services/hindi.ts
+++ b/src/app/lib/config/services/hindi.ts
@@ -143,6 +143,16 @@ export const service: DefaultServiceConfig = {
removeAccessible: 'सहेजा गया. मेरी ख़बरों से हटाएं',
removing: 'हटाया जा रहा है',
},
+ // TBC : TODO: Ticket needed
+ followTopicButton: {
+ loading: 'Loading...',
+ follow: 'Follow',
+ following: 'Following...',
+ followed: 'Followed',
+ unfollow: 'Unfollow',
+ unfollowAccessible: 'Followed. Unfollow',
+ unfollowing: 'Unfollowing...',
+ },
myNews: {
title: 'मेरी ख़बरें',
guestTitle: 'मेरी ख़बरों में आपका स्वागत है',
diff --git a/src/app/lib/uasApi/queryKeys.ts b/src/app/lib/uasApi/queryKeys.ts
index 7959ea00330..e3b3f1a0871 100644
--- a/src/app/lib/uasApi/queryKeys.ts
+++ b/src/app/lib/uasApi/queryKeys.ts
@@ -8,6 +8,13 @@ const uasKeys = {
[...uasKeys.favouritesList(userId), startIndex] as const,
favouriteStatus: (userId: string, articleId: string) =>
[...uasKeys.favourites(userId), 'status', articleId] as const,
+ // POC (Follow Topics): mirrors the favourites key structure under a
+ // separate `follows` namespace so topic caches never collide with articles.
+ follows: (userId: string) => [...uasKeys.all(userId), 'follows'] as const,
+ followsList: (userId: string) =>
+ [...uasKeys.follows(userId), 'list'] as const,
+ followStatus: (userId: string, topicId: string) =>
+ [...uasKeys.follows(userId), 'status', topicId] as const,
};
export default uasKeys;
diff --git a/src/app/lib/uasApi/uasUtility.ts b/src/app/lib/uasApi/uasUtility.ts
index 897affaa8ae..d906a172ed2 100644
--- a/src/app/lib/uasApi/uasUtility.ts
+++ b/src/app/lib/uasApi/uasUtility.ts
@@ -23,12 +23,26 @@ const FAVOURITES_CONFIG = {
action: 'favourited',
} as const;
-export type ActivityType = (typeof FAVOURITES_CONFIG)['activityType'];
+/**
+ * POC (Follow Topics): configuration for the UAS `follows` activity type.
+ * Mirrors FAVOURITES_CONFIG so the same generic `uasApiRequest` handler,
+ * `buildGlobalId`, error handling and TanStack Query patterns can be reused.
+ */
+const FOLLOWS_CONFIG = {
+ activityType: 'follows',
+ resourceDomain: 'world-service-news',
+ resourceType: 'topic',
+ action: 'followed',
+} as const;
+
+export type ActivityType =
+ | (typeof FAVOURITES_CONFIG)['activityType']
+ | (typeof FOLLOWS_CONFIG)['activityType'];
const buildGlobalId = (
resourceId: string,
- resourceDomain = FAVOURITES_CONFIG.resourceDomain,
- resourceType = FAVOURITES_CONFIG.resourceType,
+ resourceDomain: string = FAVOURITES_CONFIG.resourceDomain,
+ resourceType: string = FAVOURITES_CONFIG.resourceType,
): string => `urn:bbc:${resourceDomain}:${resourceType}:${resourceId}`;
interface MetadataComparisonResult {
@@ -103,11 +117,61 @@ const createFavouritesPayload = ({
}),
});
+/**
+ * POC (Follow Topics): the minimal set of topic fields we send to UAS so a
+ * followed topic can be rendered later (e.g. in a "Followed topics" list)
+ * without an extra lookup.
+ */
+export interface TopicFollowData {
+ topicId: string;
+ title: string;
+ service: Services;
+ url: string;
+ description?: string;
+ imageUrl?: string;
+}
+
+const buildTopicMetadata = ({
+ topicId,
+ title,
+ service,
+ url,
+ description,
+ imageUrl,
+}: TopicFollowData): Record => ({
+ topicId,
+ service,
+ title: sanitiseMetadataString(title),
+ locatorUrl: url,
+ description: sanitiseMetadataString(description),
+ imageUrl,
+});
+
+/**
+ * POC (Follow Topics): builds the UAS request body for following a topic.
+ * Structurally identical to `createFavouritesPayload`, only the config and
+ * metadata differ — demonstrating the activity-agnostic reuse of the UAS layer.
+ */
+const createFollowsPayload = (
+ topicData: TopicFollowData,
+): UasApiRequestBody => ({
+ activityType: FOLLOWS_CONFIG.activityType,
+ resourceDomain: FOLLOWS_CONFIG.resourceDomain,
+ resourceType: FOLLOWS_CONFIG.resourceType,
+ resourceId: topicData.topicId,
+ action: FOLLOWS_CONFIG.action,
+ resourceTitle: topicData.service,
+ metaData: buildTopicMetadata(topicData),
+});
+
export {
USER_ID_COOKIE_KEY,
FAVOURITES_CONFIG,
+ FOLLOWS_CONFIG,
buildGlobalId,
createFavouritesPayload,
+ createFollowsPayload,
+ buildTopicMetadata,
buildCurrentMetadata,
compareMetadataWithSaved,
sanitiseMetadataString,
diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts
index c8c622c9a8b..e4589d718b1 100644
--- a/src/app/models/types/translations.ts
+++ b/src/app/models/types/translations.ts
@@ -73,6 +73,15 @@ export interface Translations {
removeAccessible: string;
removing: string;
};
+ followTopicButton?: {
+ loading: string;
+ follow: string;
+ following: string;
+ followed: string;
+ unfollow: string;
+ unfollowAccessible: string;
+ unfollowing: string;
+ };
myNews?: {
title: string;
guestTitle: string;
diff --git a/src/app/pages/TopicPage/TopicPage.jsx b/src/app/pages/TopicPage/TopicPage.jsx
index 5e93dd291f1..55c9b6c4f42 100644
--- a/src/app/pages/TopicPage/TopicPage.jsx
+++ b/src/app/pages/TopicPage/TopicPage.jsx
@@ -1,6 +1,8 @@
import { Fragment, use } from 'react';
import path from 'ramda/src/path';
import Curation from '#app/components/Curation';
+import FollowTopicButton from '#app/components/FollowTopicButton';
+import parseRoute from '#app/routes/utils/parseRoute';
import AdContainer from '../../components/Ad';
import ATIAnalytics from '../../components/ATIAnalytics';
import ChartbeatAnalytics from '../../components/ChartbeatAnalytics';
@@ -8,6 +10,7 @@ import LinkedData from '../../components/LinkedData';
import styles from './index.styles';
import MetadataContainer from '../../components/Metadata';
import { ServiceContext } from '../../contexts/ServiceContext';
+import { RequestContext } from '../../contexts/RequestContext';
import TopicImage from './TopicImage';
import TopicTitle from './TopicTitle';
import TopicDescription from './TopicDescription';
@@ -16,7 +19,8 @@ import getItemList from '../../lib/seoUtils/getItemList';
import getNthCurationByStyleAndProminence from '../utils/getNthCurationByStyleAndProminence';
const TopicPage = ({ pageData }) => {
- const { lang, translations, brandName } = use(ServiceContext);
+ const { lang, translations, brandName, service } = use(ServiceContext);
+ const { pathname, canonicalLink } = use(RequestContext);
const {
title,
description,
@@ -28,8 +32,9 @@ const TopicPage = ({ pageData }) => {
activePage,
} = pageData;
+ const { assetId: topicId } = parseRoute(pathname);
const topStoriesTitle = path(['topStoriesTitle'], translations);
-
+ console.log('pageData', pageData);
const { pageXOfY, previousPage, nextPage, page } = {
pageXOfY: 'Page {x} of {y}',
previousPage: 'Previous Page',
@@ -76,6 +81,18 @@ const TopicPage = ({ pageData }) => {
{title}
{description && {description}}
+ {topicId && (
+
+ )}
{curations.map(
({