feat: implement activity stream (#5485)

* wip: implement activity stream

* feat: add empty stream

* fix: filter empty topic ids

* fix: update progress group

* fix: update icon

* feat: add topic titles

* fix: update topic title

* fix: update http call

* Redesign activity stream

* Add activity stream

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/5542/head
Arik Chakma 7 months ago committed by GitHub
parent 4db353e017
commit dccbe683fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 20
      src/components/Activity/ActivityPage.tsx
  2. 163
      src/components/Activity/ActivityStream.tsx
  3. 136
      src/components/Activity/ActivityTopicsModal.tsx
  4. 31
      src/components/Activity/EmptyStream.tsx
  5. 133
      src/components/Activity/ResourceProgress.tsx
  6. 132
      src/components/Activity/ResourceProgressActions.tsx
  7. 38
      src/components/UserProgress/ModalLoader.tsx
  8. 37
      src/components/UserProgress/ProgressLoadingError.tsx
  9. 10
      src/components/UserProgress/UserCustomProgressModal.tsx
  10. 10
      src/components/UserProgress/UserProgressModal.tsx
  11. 15
      src/components/UserPublicProfile/UserPublicProgresses.tsx

@ -4,6 +4,7 @@ import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
type ProgressResponse = {
updatedAt: string;
@ -45,6 +46,7 @@ export type ActivityResponse = {
resourceTitle?: string;
};
}[];
activities: UserStreamActivity[];
};
export function ActivityPage() {
@ -96,8 +98,13 @@ export function ActivityPage() {
return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter((bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0);
.filter(
(bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0,
);
const hasProgress =
learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0;
return (
<>
@ -107,16 +114,17 @@ export function ActivityPage() {
streak={activity?.streak || { count: 0 }}
/>
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
{learningRoadmapsToShow.length === 0 &&
learningBestPracticesToShow.length === 0 && <EmptyActivity />}
{(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && (
{(learningRoadmapsToShow.length > 0 ||
learningBestPracticesToShow.length > 0) && (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@ -192,6 +200,10 @@ export function ActivityPage() {
</>
)}
</div>
{hasProgress && (
<ActivityStream activities={activity?.activities || []} />
)}
</>
);
}

@ -0,0 +1,163 @@
import { useMemo, useState } from 'react';
import { getRelativeTimeString } from '../../lib/date';
import type { ResourceType } from '../../lib/resource-progress';
import { EmptyStream } from './EmptyStream';
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';
export const allowedActivityActionType = [
'in_progress',
'done',
'answered',
] as const;
export type AllowedActivityActionType =
(typeof allowedActivityActionType)[number];
export type UserStreamActivity = {
_id?: string;
resourceType: ResourceType | 'question';
resourceId: string;
resourceTitle: string;
resourceSlug?: string;
isCustomResource?: boolean;
actionType: AllowedActivityActionType;
topicIds?: string[];
createdAt: Date;
updatedAt: Date;
};
type ActivityStreamProps = {
activities: UserStreamActivity[];
};
export function ActivityStream(props: ActivityStreamProps) {
const { activities } = props;
const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] =
useState<UserStreamActivity | null>(null);
const sortedActivities = activities
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
.sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
.slice(0, showAll ? activities.length : 10);
return (
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity
</h2>
{selectedActivity && (
<ActivityTopicsModal
onClose={() => setSelectedActivity(null)}
activityId={selectedActivity._id!}
resourceId={selectedActivity.resourceId}
resourceType={selectedActivity.resourceType}
isCustomResource={selectedActivity.isCustomResource}
topicIds={selectedActivity.topicIds || []}
topicCount={selectedActivity.topicIds?.length || 0}
actionType={selectedActivity.actionType}
/>
)}
{activities.length > 0 ? (
<ul className="divide-y divide-gray-100">
{sortedActivities.map((activity) => {
const {
_id,
resourceType,
resourceId,
resourceTitle,
actionType,
updatedAt,
topicIds,
isCustomResource,
} = activity;
const resourceUrl =
resourceType === 'question'
? `/questions/${resourceId}`
: resourceType === 'best-practice'
? `/best-practices/${resourceId}`
: isCustomResource && resourceType === 'roadmap'
? `/r/${resourceId}`
: `/${resourceId}`;
const resourceLinkComponent = (
<a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank"
href={resourceUrl}
>
{resourceTitle}
</a>
);
const topicCount = topicIds?.length || 0;
const timeAgo = (
<span className="ml-1 text-xs text-gray-400">
{getRelativeTimeString(new Date(updatedAt).toISOString())}
</span>
);
return (
<li key={_id} className="py-2 text-sm text-gray-600">
{actionType === 'in_progress' && (
<>
Started{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'done' && (
<>
Completed{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'answered' && (
<>
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '}
{resourceLinkComponent} {timeAgo}
</>
)}
</li>
);
})}
</ul>
) : (
<EmptyStream />
)}
{activities.length > 10 && (
<button
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
onClick={() => setShowAll(!showAll)}
>
{showAll ? <>
<ChevronsUp size={14} />
Show less
</> : <>
<ChevronsDown size={14} />
Show all
</>}
</button>
)}
</div>
);
}

@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import type { ResourceType } from '../../lib/resource-progress';
import type { AllowedActivityActionType } from './ActivityStream';
import { httpPost } from '../../lib/http';
import { Modal } from '../Modal.tsx';
import { ModalLoader } from '../UserProgress/ModalLoader.tsx';
import { ArrowUpRight, BookOpen, Check } from 'lucide-react';
type ActivityTopicDetailsProps = {
activityId: string;
resourceId: string;
resourceType: ResourceType | 'question';
isCustomResource?: boolean;
topicIds: string[];
topicCount: number;
actionType: AllowedActivityActionType;
onClose: () => void;
};
export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
const {
resourceId,
resourceType,
isCustomResource,
topicIds = [],
topicCount,
actionType,
onClose,
} = props;
const [isLoading, setIsLoading] = useState(true);
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const loadTopicTitles = async () => {
setIsLoading(true);
setError(null);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
{
resourceId,
resourceType,
isCustomResource,
topicIds,
},
);
if (error || !response) {
setError(error?.message || 'Failed to load topic titles');
setIsLoading(false);
return;
}
setTopicTitles(response);
setIsLoading(false);
};
useEffect(() => {
loadTopicTitles().finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading || error) {
return (
<ModalLoader
error={error!}
text={'Loading topics..'}
isLoading={isLoading}
/>
);
}
let pageUrl = '';
if (resourceType === 'roadmap') {
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
} else if (resourceType === 'best-practice') {
pageUrl = `/best-practices/${resourceId}`;
} else {
pageUrl = `/questions/${resourceId}`;
}
return (
<Modal
onClose={() => {
onClose();
setError(null);
setIsLoading(false);
}}
>
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
<span className="mb-2 flex items-center justify-between text-lg font-semibold capitalize">
<span className="flex items-center gap-2">
{actionType.replace('_', ' ')}
</span>
<a
href={pageUrl}
target="_blank"
className="flex items-center gap-1 rounded-md border border-transparent py-0.5 pl-2 pr-1 text-sm font-normal text-gray-400 transition-colors hover:border-black hover:bg-black hover:text-white"
>
Visit Page{' '}
<ArrowUpRight
size={16}
strokeWidth={2}
className="relative top-px"
/>
</a>
</span>
<ul className="flex flex-col gap-1">
{topicIds.map((topicId) => {
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
const ActivityIcon =
actionType === 'done'
? Check
: actionType === 'in_progress'
? BookOpen
: Check;
return (
<li key={topicId} className="flex items-start gap-2">
<ActivityIcon
strokeWidth={3}
className="relative top-[4px] text-green-500"
size={16}
/>
{topicTitle}
</li>
);
})}
</ul>
</div>
</Modal>
);
}

@ -0,0 +1,31 @@
import { List } from 'lucide-react';
export function EmptyStream() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Activities will appear here as you start tracking your&nbsp;
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>
,&nbsp;
<a
href="/best-practices"
className="mt-4 text-blue-500 hover:underline"
>
Best Practices
</a>
&nbsp;or&nbsp;
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
Questions
</a>
&nbsp;progress.
</p>
</div>
</div>
);
}

@ -1,9 +1,6 @@
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import { useState } from 'react';
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ResourceProgressActions } from './ResourceProgressActions';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@ -22,9 +19,6 @@ type ResourceProgressType = {
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true, isCustomResource } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const userId = getUser()?.id;
@ -41,33 +35,6 @@ export function ResourceProgress(props: ResourceProgressType) {
roadmapSlug,
} = props;
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
},
);
if (error || !response) {
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
setIsClearing(false);
setIsConfirming(false);
if (onCleared) {
onCleared();
}
}
let url =
resourceType === 'roadmap'
? `/${resourceId}`
@ -78,95 +45,37 @@ export function ResourceProgress(props: ResourceProgressType) {
}
const totalMarked = doneCount + skippedCount;
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
const progressPercentage = getPercentage(totalMarked, totalCount);
return (
<div>
<div className="relative">
<a
target="_blank"
href={url}
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
>
<span className="flex-grow truncate">{title}</span>
<span className="text-xs text-gray-400">
{parseInt(progressPercentage, 10)}%
</span>
<span
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10"
style={{
width: `${progressPercentage}%`,
}}
></span>
<span className="relative flex-1 cursor-pointer truncate">
{title}
</span>
<span className="ml-1 cursor-pointer text-sm text-gray-400">
{getRelativeTimeString(updatedAt)}
</span>
</a>
<div className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
<span className="hidden flex-1 gap-1 sm:flex">
{doneCount > 0 && (
<>
<span>{doneCount} done</span> &bull;
</>
)}
{learningCount > 0 && (
<>
<span>{learningCount} in progress</span> &bull;
</>
)}
{skippedCount > 0 && (
<>
<span>{skippedCount} skipped</span> &bull;
</>
)}
<span>{totalCount} total</span>
</span>
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="text-xs font-normal"
shareIconClassName="w-2.5 h-2.5 stroke-2"
checkIconClassName="w-2.5 h-2.5"
/>
<span className={'hidden sm:block'}>&bull;</span>
{showClearButton && (
<>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{isClearing && 'Processing...'}
</button>
)}
{isConfirming && (
<span>
Are you sure?{' '}
<button
onClick={clearProgress}
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
>
Yes
</button>{' '}
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</span>
)}
</>
)}
</div>
<div className="absolute right-2 top-0 flex h-full items-center">
<ResourceProgressActions
userId={userId!}
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
onCleared={onCleared}
showClearButton={showClearButton}
/>
</div>
</div>
);

@ -0,0 +1,132 @@
import { MoreVertical, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import type { ResourceType } from '../../lib/resource-progress';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type ResourceProgressActionsType = {
userId: string;
resourceType: ResourceType;
resourceId: string;
isCustomResource: boolean;
showClearButton?: boolean;
onCleared?: () => void;
};
export function ResourceProgressActions(props: ResourceProgressActionsType) {
const {
userId,
resourceType,
resourceId,
isCustomResource,
showClearButton = true,
onCleared,
} = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
},
);
if (error || !response) {
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
setIsClearing(false);
setIsConfirming(false);
if (onCleared) {
onCleared();
}
}
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useKeydown('Escape', () => {
setIsOpen(false);
});
return (
<div className="relative h-full" ref={dropdownRef}>
<button
className="h-full text-gray-400 hover:text-gray-700"
onClick={() => setIsOpen(!isOpen)}
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="w-full gap-1.5 p-2 hover:bg-gray-100"
/>
{showClearButton && (
<>
{!isConfirming && (
<button
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing ? (
<>
<X className="h-3.5 w-3.5" />
Clear Progress
</>
) : (
'Processing...'
)}
</button>
)}
{isConfirming && (
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={clearProgress}
className="text-red-500 underline hover:text-red-800"
>
Yes
</button>
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</div>
</span>
)}
</>
)}
</div>
)}
</div>
);
}

@ -0,0 +1,38 @@
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
import { Spinner } from '../ReactIcons/Spinner';
type ModalLoaderProps = {
isLoading: boolean;
error?: string;
text: string;
};
export function ModalLoader(props: ModalLoaderProps) {
const { isLoading, text, error } = props;
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
{text || 'Loading...'}
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
);
}

@ -1,37 +0,0 @@
import { ErrorIcon } from "../ReactIcons/ErrorIcon";
import { Spinner } from "../ReactIcons/Spinner";
type ProgressLoadingErrorProps = {
isLoading: boolean;
error: string;
}
export function ProgressLoadingError(props: ProgressLoadingErrorProps) {
const { isLoading, error } = props;
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
Loading user progress...
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
)
}

@ -8,7 +8,7 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import { ProgressLoadingError } from './ProgressLoadingError';
import { ModalLoader } from './ModalLoader.tsx';
import { UserProgressModalHeader } from './UserProgressModalHeader';
import { X } from 'lucide-react';
@ -144,7 +144,13 @@ export function UserCustomProgressModal(props: ProgressMapProps) {
}
if (isLoading || error) {
return <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
return (
<ModalLoader
text={'Loading user progress..'}
isLoading={isLoading}
error={error || ''}
/>
);
}
return (

@ -8,7 +8,7 @@ import type { ResourceType } from '../../lib/resource-progress';
import { topicSelectorAll } from '../../lib/resource-progress';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import { ProgressLoadingError } from './ProgressLoadingError';
import { ModalLoader } from './ModalLoader.tsx';
import { UserProgressModalHeader } from './UserProgressModalHeader';
import { X } from 'lucide-react';
@ -187,7 +187,13 @@ export function UserProgressModal(props: ProgressMapProps) {
}
if (isLoading || error) {
return <ProgressLoadingError isLoading={isLoading} error={error} />;
return (
<ModalLoader
text={'Loading user progress..'}
isLoading={isLoading}
error={error}
/>
);
}
return (

@ -25,21 +25,6 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
(roadmap) => roadmap.isCustomResource,
);
// <UserPublicProgressStats
// updatedAt={roadmap.updatedAt}
// title={roadmap.title}
// totalCount={roadmap.total}
// doneCount={roadmap.done}
// learningCount={roadmap.learning}
// skippedCount={roadmap.skipped}
// resourceId={roadmap.id}
// resourceType="roadmap"
// roadmapSlug={roadmap.roadmapSlug}
// username={username!}
// isCustomResource={true}
// userId={userId}
// />
return (
<div>
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (

Loading…
Cancel
Save