Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.following;
}
if (isFollowed) return followTopicButton.followed;
return followTopicButton.follow;
};

const getAccessibleLabel = () => {
if (isLoading) return followTopicButton.loading;
if (isUpdating) {
return isFollowed
? followTopicButton.unfollowing
: followTopicButton.following;
}
// 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 (
<SaveButton
onClick={handleClick}
isLoading={isLoading}
isUpdating={isUpdating}
isSaved={isFollowed}
visualLabel={getVisualLabel()}
hoverVisualLabel={hoverVisualLabel}
accessibleLabel={getAccessibleLabel()}
testId="follow-topic-btn-authorized"
{...viewTracker}
/>
);
};

export default FollowTopicButtonAuthenticated;
Original file line number Diff line number Diff line change
@@ -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: () => <FollowTopicButtonGuest />,
},
);
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
jinidev marked this conversation as resolved.
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<HTMLButtonElement>) => {
onClickTrack?.(e);
setIsModalOpen(true);
};

return (
<>
<SaveButton
onClick={handleClick}
visualLabel={label ?? ''}
accessibleLabel={label ?? ''}
testId="follow-topic-btn-guest"
isLoading={!isHydrated}
{...viewTracker}
/>
{isModalOpen &&
createPortal(
<AccountSignInModal
onClose={() => setIsModalOpen(false)}
signInUrl={signInUrl}
registerUrl={registerUrl}
/>,
document.body,
)}
</>
);
};

export default FollowTopicButtonGuest;
15 changes: 15 additions & 0 deletions src/app/components/FollowTopicButton/index.styles.ts
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions src/app/components/FollowTopicButton/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<noscript>
<style>{`#${FOLLOW_TOPIC_BUTTON_ID} { display: none; }`}</style>
</noscript>
<div css={styles.buttonWrapper} id={FOLLOW_TOPIC_BUTTON_ID}>
{isPersonalizationEnabled ? (
<FollowTopicButtonAuthenticated topicData={topicData} />
) : (
<FollowTopicButtonGuest topicId={topicData.topicId} />
)}
</div>
Comment thread
jinidev marked this conversation as resolved.
</>
);
};

export default FollowTopicButton;
2 changes: 2 additions & 0 deletions src/app/components/SaveButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
118 changes: 118 additions & 0 deletions src/app/hooks/createUASStatusHook.ts
Original file line number Diff line number Diff line change
@@ -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<StatusField extends string> {
config: UseUASStatusHookConfig;
queryKeyFn: (hashedUserId: string, resourceId: string) => unknown[];
statusField: StatusField;
enabledFn?: (resourceId: string, hashedUserId: string) => boolean;
}

type UseUASStatusHookReturn<StatusField extends string> = Record<
StatusField,
boolean
> & {
isLoading: boolean;
error: Error | null;
metadata?: Record<string, unknown>;
};

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 = <StatusField extends string>(
params: UseUASStatusHookParams<StatusField>,
): ((resourceId: string) => UseUASStatusHookReturn<StatusField>) => {
const { config, queryKeyFn, statusField, enabledFn } = params;

// eslint-disable-next-line react-hooks/rules-of-hooks
return (resourceId: string): UseUASStatusHookReturn<StatusField> => {
// 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<string, unknown>;
};

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<StatusField>;
};
};

export { UASStatusField };
export default createUASStatusHook;
Loading
Loading