From b9121c26e02e818aa937d7986aa47ed49ba270bd Mon Sep 17 00:00:00 2001 From: jinidev Date: Tue, 28 Jul 2026 21:56:05 +0300 Subject: [PATCH 1/4] FOllow topic module with UAS endpoints --- .../FollowTopicButtonAuthenticated/index.tsx | 87 +++++++++++++ .../FollowTopicButtonAuthenticated/lazy.tsx | 14 ++ .../FollowTopicButtonGuest/index.tsx | 66 ++++++++++ .../FollowTopicButton/index.styles.ts | 15 +++ .../components/FollowTopicButton/index.tsx | 36 ++++++ src/app/components/SaveButton/index.tsx | 2 + src/app/hooks/useTopicFollowButton/index.ts | 75 +++++++++++ src/app/hooks/useTopicFollowStatus/index.ts | 21 +++ src/app/hooks/useUASFetchSaveStatus/index.ts | 69 +++------- src/app/hooks/useUASStatusHook.ts | 121 ++++++++++++++++++ src/app/lib/config/services/hindi.ts | 11 ++ src/app/lib/uasApi/queryKeys.ts | 7 + src/app/lib/uasApi/uasUtility.ts | 70 +++++++++- src/app/models/types/translations.ts | 10 ++ src/app/pages/TopicPage/TopicPage.jsx | 21 ++- 15 files changed, 569 insertions(+), 56 deletions(-) create mode 100644 src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx create mode 100644 src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/lazy.tsx create mode 100644 src/app/components/FollowTopicButton/FollowTopicButtonGuest/index.tsx create mode 100644 src/app/components/FollowTopicButton/index.styles.ts create mode 100644 src/app/components/FollowTopicButton/index.tsx create mode 100644 src/app/hooks/useTopicFollowButton/index.ts create mode 100644 src/app/hooks/useTopicFollowStatus/index.ts create mode 100644 src/app/hooks/useUASStatusHook.ts diff --git a/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx new file mode 100644 index 00000000000..104fac4c72e --- /dev/null +++ b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx @@ -0,0 +1,87 @@ +import { use } from 'react'; +import { ServiceContext } from '#contexts/ServiceContext'; +import useTopicFollowButton, { + FollowAction, +} from '#app/hooks/useTopicFollowButton'; +import useClickTracker from '#app/hooks/useClickTrackerHandler'; +import useViewTracker from '#app/hooks/useViewTracker'; +import SaveButton from '#app/components/SaveButton'; + +import type { FollowTopicButtonProps } from '../index'; + +const FollowTopicButtonAuthenticated = ({ + topicData, +}: FollowTopicButtonProps) => { + const { translations } = use(ServiceContext); + const { followTopicButton } = translations || {}; + const { topicId } = topicData; + + const { isFollowed, isLoading, isUpdating, handleFollowAction } = + useTopicFollowButton(topicData); + + const clickComponentName = `follow-topic-button-click-${ + isFollowed ? FollowAction.UNFOLLOW : FollowAction.FOLLOW + }`; + + const viewTracker = useViewTracker({ + componentName: 'follow-topic-button-view', + }); + + const { onClick: onClickTrack } = useClickTracker({ + componentName: clickComponentName, + itemTracker: { + resourceId: topicId, + }, + }); + + if (!followTopicButton) return null; + + const getVisualLabel = () => { + if (isLoading) return followTopicButton.loading; + if (isUpdating) { + return isFollowed + ? followTopicButton.unfollowing + : followTopicButton.followingAction; + } + if (isFollowed) return followTopicButton.following; + return followTopicButton.follow; + }; + + const getAccessibleLabel = () => { + if (isLoading) return followTopicButton.loading; + if (isUpdating) { + return isFollowed + ? followTopicButton.unfollowing + : followTopicButton.followingAction; + } + // When following, screen readers should hear the action the button performs next. + if (isFollowed) return followTopicButton.unfollowAccessible; + return followTopicButton.follow; + }; + + const hoverVisualLabel = + isFollowed && !isUpdating ? followTopicButton.unfollow : undefined; + + const handleClick = (event?: React.MouseEvent) => { + onClickTrack?.(event); + handleFollowAction( + isFollowed ? FollowAction.UNFOLLOW : FollowAction.FOLLOW, + ); + }; + + return ( + + ); +}; + +export default FollowTopicButtonAuthenticated; diff --git a/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/lazy.tsx b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/lazy.tsx new file mode 100644 index 00000000000..215b2cac019 --- /dev/null +++ b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/lazy.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; +import FollowTopicButtonGuest from '../FollowTopicButtonGuest'; + +export default dynamic( + () => + import( + /* webpackChunkName: "follow_topic_button_authenticated" */ + '.' + ), + { + ssr: false, + loading: () => , + }, +); diff --git a/src/app/components/FollowTopicButton/FollowTopicButtonGuest/index.tsx b/src/app/components/FollowTopicButton/FollowTopicButtonGuest/index.tsx new file mode 100644 index 00000000000..a32dccad2d8 --- /dev/null +++ b/src/app/components/FollowTopicButton/FollowTopicButtonGuest/index.tsx @@ -0,0 +1,66 @@ +import { use, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ServiceContext } from '#contexts/ServiceContext'; +import { AccountContext } from '#app/contexts/AccountContext'; +import SaveButton from '#app/components/SaveButton'; +import useHydrationDetection from '#app/hooks/useHydrationDetection'; +import AccountSignInModal from '#app/components/Account/AccountSignInModal'; +import useClickTracker from '#app/hooks/useClickTrackerHandler'; +import useViewTracker from '#app/hooks/useViewTracker'; + +interface FollowTopicButtonGuestProps { + topicId?: string; +} + +const FollowTopicButtonGuest = ({ topicId }: FollowTopicButtonGuestProps) => { + const { translations } = use(ServiceContext); + const { signInUrl, registerUrl } = use(AccountContext); + const isHydrated = useHydrationDetection(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const { followTopicButton } = translations || {}; + + const label = isHydrated + ? followTopicButton?.follow + : followTopicButton?.loading; + + const viewTracker = useViewTracker({ + componentName: 'follow-topic-button-guest-view', + }); + + const { onClick: onClickTrack } = useClickTracker({ + componentName: 'follow-topic-button-guest-click-follow', + itemTracker: { + resourceId: topicId, + }, + }); + + const handleClick = (e: React.MouseEvent) => { + onClickTrack?.(e); + setIsModalOpen(true); + }; + + return ( + <> + + {isModalOpen && + createPortal( + 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/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..ac8731298bf --- /dev/null +++ b/src/app/hooks/useTopicFollowStatus/index.ts @@ -0,0 +1,21 @@ +import { FOLLOWS_CONFIG } from '#app/lib/uasApi/uasUtility'; +import uasKeys from '#app/lib/uasApi/queryKeys'; +import useUASStatusHook, { UASStatusField } from '#app/hooks/useUASStatusHook'; + +/** + * POC (Follow Topics): fetches whether the signed-in user follows a topic. + * Wraps the generic useUASStatusHook factory with topic-specific config. + */ +// eslint-disable-next-line react-hooks/rules-of-hooks +const useTopicFollowStatus = useUASStatusHook({ + 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..2c763b34f95 100644 --- a/src/app/hooks/useUASFetchSaveStatus/index.ts +++ b/src/app/hooks/useUASFetchSaveStatus/index.ts @@ -1,14 +1,11 @@ -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 useUASStatusHook, { UASStatusField } from '#app/hooks/useUASStatusHook'; -/** 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 useUASStatusHook factory with article-specific config. + */ interface UseUASFetchSaveStatusReturn { isSaved: boolean; @@ -17,54 +14,24 @@ 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 }; - } -}; +// eslint-disable-next-line react-hooks/rules-of-hooks +const statusHook = useUASStatusHook({ + 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 } = statusHook(articleId); return { - isSaved: data.isSaved, + isSaved, isLoading, - error: error as Error | null, - savedMetadata: data.metadata, + error, + savedMetadata: metadata, }; }; diff --git a/src/app/hooks/useUASStatusHook.ts b/src/app/hooks/useUASStatusHook.ts new file mode 100644 index 00000000000..837a577a515 --- /dev/null +++ b/src/app/hooks/useUASStatusHook.ts @@ -0,0 +1,121 @@ +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'; + +/** + * Generic factory for creating UAS "status" fetch hooks (e.g., isSaved, isFollowed). + * Handles all boilerplate: GET request, response parsing, query setup, error handling. + * + * Usage: + * const useMyStatus = useUASStatusHook({ + * config: MY_CONFIG, + * queryKeyFn: (userId, id) => uasKeys.myStatus(userId, id), + * statusField: 'isSaved', + * }); + */ + +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. + * Returns a hook function that accepts a resourceId parameter. + * + * Example usage: + * const useMyStatus = useUASStatusHook(params); + * const status = useMyStatus(id); + */ +const useUASStatusHook = ( + params: UseUASStatusHookParams, +): ((resourceId: string) => UseUASStatusHookReturn) => { + const { config, queryKeyFn, statusField, enabledFn } = params; + + 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; + + const { + data = { + [statusField]: false, + metadata: undefined, + }, + isLoading, + error, + // eslint-disable-next-line react-hooks/rules-of-hooks + } = 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 useUASStatusHook; diff --git a/src/app/lib/config/services/hindi.ts b/src/app/lib/config/services/hindi.ts index 811a8dbb5ed..c7a409559d2 100644 --- a/src/app/lib/config/services/hindi.ts +++ b/src/app/lib/config/services/hindi.ts @@ -143,6 +143,17 @@ export const service: DefaultServiceConfig = { removeAccessible: 'सहेजा गया. मेरी ख़बरों से हटाएं', removing: 'हटाया जा रहा है', }, + // TBC : TODO: Ticket needed + followTopicButton: { + loading: 'लोड हो रहा है', + follow: 'फ़ॉलो करें', + following: 'फ़ॉलो किया जा रहा है', + followingAction: 'फ़ॉलो किया जा रहा है', + followed: 'फ़ॉलो किया गया', + unfollow: 'अनफ़ॉलो करें', + unfollowAccessible: 'फ़ॉलो किया गया. अनफ़ॉलो करें', + 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..8a759623a97 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -73,6 +73,16 @@ export interface Translations { removeAccessible: string; removing: string; }; + followTopicButton?: { + loading: string; + follow: string; + following: string; + followingAction: 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( ({ From 457e360125703ca1f56ef673d70c4d0596b73aef Mon Sep 17 00:00:00 2001 From: jinidev Date: Tue, 28 Jul 2026 22:05:36 +0300 Subject: [PATCH 2/4] Translation reverted to ENglish for easy understanding --- src/app/lib/config/services/hindi.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/lib/config/services/hindi.ts b/src/app/lib/config/services/hindi.ts index c7a409559d2..03a9d206fbe 100644 --- a/src/app/lib/config/services/hindi.ts +++ b/src/app/lib/config/services/hindi.ts @@ -145,14 +145,14 @@ export const service: DefaultServiceConfig = { }, // TBC : TODO: Ticket needed followTopicButton: { - loading: 'लोड हो रहा है', - follow: 'फ़ॉलो करें', - following: 'फ़ॉलो किया जा रहा है', - followingAction: 'फ़ॉलो किया जा रहा है', - followed: 'फ़ॉलो किया गया', - unfollow: 'अनफ़ॉलो करें', - unfollowAccessible: 'फ़ॉलो किया गया. अनफ़ॉलो करें', - unfollowing: 'अनफ़ॉलो किया जा रहा है', + loading: 'Loading...', + follow: 'Follow', + following: 'Following...', + followingAction: 'Following...', + followed: 'Followed', + unfollow: 'Unfollow', + unfollowAccessible: 'Followed. Unfollow', + unfollowing: 'Unfollowing...', }, myNews: { title: 'मेरी ख़बरें', From 8487534a69f5d4de9fb292f27837e04bd5b30635 Mon Sep 17 00:00:00 2001 From: jinidev Date: Wed, 29 Jul 2026 11:07:14 +0300 Subject: [PATCH 3/4] Named as factory fn returns hook --- ...ASStatusHook.ts => createUASStatusHook.ts} | 35 +++++++++---------- src/app/hooks/useTopicFollowStatus/index.ts | 9 ++--- src/app/hooks/useUASFetchSaveStatus/index.ts | 11 +++--- 3 files changed, 27 insertions(+), 28 deletions(-) rename src/app/hooks/{useUASStatusHook.ts => createUASStatusHook.ts} (80%) diff --git a/src/app/hooks/useUASStatusHook.ts b/src/app/hooks/createUASStatusHook.ts similarity index 80% rename from src/app/hooks/useUASStatusHook.ts rename to src/app/hooks/createUASStatusHook.ts index 837a577a515..83066dc90e6 100644 --- a/src/app/hooks/useUASStatusHook.ts +++ b/src/app/hooks/createUASStatusHook.ts @@ -5,18 +5,6 @@ import { buildGlobalId, type ActivityType } from '#app/lib/uasApi/uasUtility'; import { HTTP_NO_CONTENT } from '#app/lib/statusCodes.const'; import { AccountContext } from '#app/contexts/AccountContext'; -/** - * Generic factory for creating UAS "status" fetch hooks (e.g., isSaved, isFollowed). - * Handles all boilerplate: GET request, response parsing, query setup, error handling. - * - * Usage: - * const useMyStatus = useUASStatusHook({ - * config: MY_CONFIG, - * queryKeyFn: (userId, id) => uasKeys.myStatus(userId, id), - * statusField: 'isSaved', - * }); - */ - interface UseUASStatusHookConfig { activityType: ActivityType; resourceDomain: string; @@ -43,19 +31,27 @@ enum UASStatusField { SAVED = 'isSaved', FOLLOWED = 'isFollowed', } + /** * Factory function that creates a UAS status fetch hook. - * Returns a hook function that accepts a resourceId parameter. + * 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 usage: - * const useMyStatus = useUASStatusHook(params); - * const status = useMyStatus(id); + * Example: + * const useArticleStatus = createUASStatusHook({ + * config: FAVOURITES_CONFIG, + * queryKeyFn: (userId, id) => uasKeys.saveStatus(userId, id), + * statusField: UASStatusField.SAVED, + * }); */ -const useUASStatusHook = ( +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); @@ -64,6 +60,7 @@ const useUASStatusHook = ( ? enabledFn(resourceId, hashedUserId) : !!resourceId && !!hashedUserId; + // eslint-disable-next-line react-hooks/rules-of-hooks const { data = { [statusField]: false, @@ -71,7 +68,6 @@ const useUASStatusHook = ( }, isLoading, error, - // eslint-disable-next-line react-hooks/rules-of-hooks } = useQuery({ queryKey: queryKeyFn(hashedUserId, resourceId), queryFn: async () => { @@ -117,5 +113,6 @@ const useUASStatusHook = ( } as UseUASStatusHookReturn; }; }; + export { UASStatusField }; -export default useUASStatusHook; +export default createUASStatusHook; diff --git a/src/app/hooks/useTopicFollowStatus/index.ts b/src/app/hooks/useTopicFollowStatus/index.ts index ac8731298bf..a5f4284e8b0 100644 --- a/src/app/hooks/useTopicFollowStatus/index.ts +++ b/src/app/hooks/useTopicFollowStatus/index.ts @@ -1,13 +1,14 @@ import { FOLLOWS_CONFIG } from '#app/lib/uasApi/uasUtility'; import uasKeys from '#app/lib/uasApi/queryKeys'; -import useUASStatusHook, { UASStatusField } from '#app/hooks/useUASStatusHook'; +import createUASStatusHook, { + UASStatusField, +} from '#app/hooks/createUASStatusHook'; /** * POC (Follow Topics): fetches whether the signed-in user follows a topic. - * Wraps the generic useUASStatusHook factory with topic-specific config. + * Wraps the generic createUASStatusHook factory with topic-specific config. */ -// eslint-disable-next-line react-hooks/rules-of-hooks -const useTopicFollowStatus = useUASStatusHook({ +const useTopicFollowStatus = createUASStatusHook({ config: { activityType: FOLLOWS_CONFIG.activityType, resourceDomain: FOLLOWS_CONFIG.resourceDomain, diff --git a/src/app/hooks/useUASFetchSaveStatus/index.ts b/src/app/hooks/useUASFetchSaveStatus/index.ts index 2c763b34f95..35e0f058d96 100644 --- a/src/app/hooks/useUASFetchSaveStatus/index.ts +++ b/src/app/hooks/useUASFetchSaveStatus/index.ts @@ -1,10 +1,12 @@ import { FAVOURITES_CONFIG } from '#app/lib/uasApi/uasUtility'; import uasKeys from '#app/lib/uasApi/queryKeys'; -import useUASStatusHook, { UASStatusField } from '#app/hooks/useUASStatusHook'; +import createUASStatusHook, { + UASStatusField, +} from '#app/hooks/createUASStatusHook'; /** * Fetches an article's saved status from UAS. - * Wraps the generic useUASStatusHook factory with article-specific config. + * Wraps the generic createUASStatusHook factory with article-specific config. */ interface UseUASFetchSaveStatusReturn { @@ -14,8 +16,7 @@ interface UseUASFetchSaveStatusReturn { savedMetadata?: Record; } -// eslint-disable-next-line react-hooks/rules-of-hooks -const statusHook = useUASStatusHook({ +const useStatusHook = createUASStatusHook({ config: FAVOURITES_CONFIG, queryKeyFn: (hashedUserId, articleId) => uasKeys.favouriteStatus(hashedUserId, articleId) as unknown as unknown[], @@ -26,7 +27,7 @@ const statusHook = useUASStatusHook({ const useUASFetchSaveStatus = ( articleId: string, ): UseUASFetchSaveStatusReturn => { - const { isSaved, isLoading, error, metadata } = statusHook(articleId); + const { isSaved, isLoading, error, metadata } = useStatusHook(articleId); return { isSaved, isLoading, From 8f47aa9aa724adb41fe58eef8a42cfd8ed806456 Mon Sep 17 00:00:00 2001 From: jinidev Date: Tue, 4 Aug 2026 19:56:08 +0300 Subject: [PATCH 4/4] Updates on followedstate --- .../FollowTopicButtonAuthenticated/index.tsx | 6 +++--- src/app/lib/config/services/hindi.ts | 1 - src/app/models/types/translations.ts | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx index 104fac4c72e..1a33e3faa70 100644 --- a/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx +++ b/src/app/components/FollowTopicButton/FollowTopicButtonAuthenticated/index.tsx @@ -41,9 +41,9 @@ const FollowTopicButtonAuthenticated = ({ if (isUpdating) { return isFollowed ? followTopicButton.unfollowing - : followTopicButton.followingAction; + : followTopicButton.following; } - if (isFollowed) return followTopicButton.following; + if (isFollowed) return followTopicButton.followed; return followTopicButton.follow; }; @@ -52,7 +52,7 @@ const FollowTopicButtonAuthenticated = ({ if (isUpdating) { return isFollowed ? followTopicButton.unfollowing - : followTopicButton.followingAction; + : followTopicButton.following; } // When following, screen readers should hear the action the button performs next. if (isFollowed) return followTopicButton.unfollowAccessible; diff --git a/src/app/lib/config/services/hindi.ts b/src/app/lib/config/services/hindi.ts index 03a9d206fbe..ce07a7b75ba 100644 --- a/src/app/lib/config/services/hindi.ts +++ b/src/app/lib/config/services/hindi.ts @@ -148,7 +148,6 @@ export const service: DefaultServiceConfig = { loading: 'Loading...', follow: 'Follow', following: 'Following...', - followingAction: 'Following...', followed: 'Followed', unfollow: 'Unfollow', unfollowAccessible: 'Followed. Unfollow', diff --git a/src/app/models/types/translations.ts b/src/app/models/types/translations.ts index 8a759623a97..e4589d718b1 100644 --- a/src/app/models/types/translations.ts +++ b/src/app/models/types/translations.ts @@ -77,7 +77,6 @@ export interface Translations { loading: string; follow: string; following: string; - followingAction: string; followed: string; unfollow: string; unfollowAccessible: string;