feat: onboarding for new users (#5629)
* wip * feat: add onboarding * feat: implement onboarding * Update indicator design * Update UI for onboarding dropdown * Changes to onboarding UI --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/5662/head
parent
67fbba4708
commit
f8cdd76fa9
12 changed files with 925 additions and 325 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,20 @@ |
|||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
|
||||||
|
type NotificationIndicatorProps = { |
||||||
|
className?: string; |
||||||
|
}; |
||||||
|
export function NotificationIndicator(props: NotificationIndicatorProps) { |
||||||
|
const { className = '' } = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'absolute -top-1 right-0 h-3 w-3 text-xs uppercase tracking-wider', |
||||||
|
className, |
||||||
|
)} |
||||||
|
> |
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span> |
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span> |
||||||
|
</span> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,248 @@ |
|||||||
|
import { ArrowUpRight, Check } from 'lucide-react'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { useEffect, useMemo, useState } from 'react'; |
||||||
|
import type { AllowedOnboardingStatus } from '../../api/user'; |
||||||
|
import { pageProgressMessage } from '../../stores/page'; |
||||||
|
import { httpPatch } from '../../lib/http'; |
||||||
|
import { useToast } from '../../hooks/use-toast'; |
||||||
|
import type { OnboardingConfig } from './AccountDropdown'; |
||||||
|
import { setAuthToken } from '../../lib/jwt'; |
||||||
|
|
||||||
|
type Task = { |
||||||
|
id: string; |
||||||
|
title: string; |
||||||
|
description: string; |
||||||
|
status: AllowedOnboardingStatus; |
||||||
|
url: string; |
||||||
|
urlText: string; |
||||||
|
onClick?: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
type OnboardingModalProps = { |
||||||
|
onClose: () => void; |
||||||
|
onboardingConfig: OnboardingConfig; |
||||||
|
onIgnoreTask?: (taskId: string, status: AllowedOnboardingStatus) => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function OnboardingModal(props: OnboardingModalProps) { |
||||||
|
const { onboardingConfig, onClose, onIgnoreTask } = props; |
||||||
|
|
||||||
|
const toast = useToast(); |
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null); |
||||||
|
|
||||||
|
const tasks = useMemo(() => { |
||||||
|
return [ |
||||||
|
{ |
||||||
|
id: 'updateProgress', |
||||||
|
title: 'Update your Progress', |
||||||
|
description: 'Mark your progress on roadmaps', |
||||||
|
status: onboardingConfig?.onboarding?.updateProgress || 'pending', |
||||||
|
url: '/roadmaps', |
||||||
|
urlText: 'Roadmaps List', |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'publishProfile', |
||||||
|
title: 'Claim a Username', |
||||||
|
description: 'Optionally create a public profile to share your skills', |
||||||
|
status: onboardingConfig?.onboarding?.publishProfile || 'pending', |
||||||
|
url: '/account/update-profile', |
||||||
|
urlText: 'Update Profile', |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'customRoadmap', |
||||||
|
title: 'Custom Roadmaps', |
||||||
|
description: 'Create your own roadmap from scratch', |
||||||
|
status: onboardingConfig?.onboarding?.customRoadmap || 'pending', |
||||||
|
url: import.meta.env.DEV |
||||||
|
? 'http://localhost:4321' |
||||||
|
: 'https://draw.roadmap.sh', |
||||||
|
urlText: 'Create Roadmap', |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'addFriends', |
||||||
|
title: 'Invite your Friends', |
||||||
|
description: 'Invite friends to join you on roadmaps', |
||||||
|
status: onboardingConfig?.onboarding?.addFriends || 'pending', |
||||||
|
url: '/account/friends', |
||||||
|
urlText: 'Add Friends', |
||||||
|
onClick: () => { |
||||||
|
ignoreOnboardingTask( |
||||||
|
'addFriends', |
||||||
|
'done', |
||||||
|
'Updating status..', |
||||||
|
).finally(() => pageProgressMessage.set('')); |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'roadCard', |
||||||
|
title: 'Create your Roadmap Card', |
||||||
|
description: 'Embed your skill card on your github or website', |
||||||
|
status: onboardingConfig?.onboarding?.roadCard || 'pending', |
||||||
|
url: '/account/road-card', |
||||||
|
urlText: 'Create Road Card', |
||||||
|
onClick: () => { |
||||||
|
ignoreOnboardingTask('roadCard', 'done', 'Updating status..').finally( |
||||||
|
() => pageProgressMessage.set(''), |
||||||
|
); |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'inviteTeam', |
||||||
|
title: 'Invite your Team', |
||||||
|
description: 'Invite your team to collaborate on roadmaps', |
||||||
|
status: onboardingConfig?.onboarding?.inviteTeam || 'pending', |
||||||
|
url: '/team', |
||||||
|
urlText: 'Create Team', |
||||||
|
}, |
||||||
|
]; |
||||||
|
}, [onboardingConfig]); |
||||||
|
|
||||||
|
const ignoreOnboardingTask = async ( |
||||||
|
taskId: string, |
||||||
|
status: AllowedOnboardingStatus, |
||||||
|
message: string = 'Ignoring Task', |
||||||
|
) => { |
||||||
|
pageProgressMessage.set(message); |
||||||
|
const { response, error } = await httpPatch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`, |
||||||
|
{ |
||||||
|
id: taskId, |
||||||
|
status, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
toast.error(error?.message || 'Failed to ignore task'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
onIgnoreTask?.(taskId, status); |
||||||
|
setSelectedTask(null); |
||||||
|
}; |
||||||
|
|
||||||
|
const ignoreForever = async () => { |
||||||
|
const { response, error } = await httpPatch<{ token: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-ignore-onboarding-forever`, |
||||||
|
{}, |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
toast.error(error?.message || 'Failed to ignore onboarding'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setAuthToken(response.token); |
||||||
|
window.location.reload(); |
||||||
|
}; |
||||||
|
|
||||||
|
const isAllTasksDone = tasks.every( |
||||||
|
(task) => task.status === 'done' || task.status === 'ignored', |
||||||
|
); |
||||||
|
useEffect(() => { |
||||||
|
if (!isAllTasksDone) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
pageProgressMessage.set('Finishing Onboarding'); |
||||||
|
ignoreForever().finally(() => {}); |
||||||
|
}, [isAllTasksDone]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal onClose={onClose} bodyClassName="text-black h-auto"> |
||||||
|
<div className="px-4 pb-2 pl-11 pt-4"> |
||||||
|
<h2 className="mb-0.5 text-xl font-semibold">Welcome to roadmap.sh</h2> |
||||||
|
<p className="text-balance text-sm text-gray-500"> |
||||||
|
Complete the tasks below to get started! |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<ul className={cn('flex flex-col divide-y', { |
||||||
|
'border-b': tasks[tasks.length - 1]?.status === 'done', |
||||||
|
})}> |
||||||
|
{/*sort to put completed tasks at the end */} |
||||||
|
{tasks.map((task, taskCounter) => { |
||||||
|
const isDone = task.status === 'done'; |
||||||
|
const isActive = selectedTask?.id === task.id; |
||||||
|
|
||||||
|
return ( |
||||||
|
<li |
||||||
|
key={task.id} |
||||||
|
data-active={isActive} |
||||||
|
data-status={task.status} |
||||||
|
className={cn('group/task px-4 py-2.5', { |
||||||
|
'bg-gray-100': isDone, |
||||||
|
'border-t': taskCounter === 0 && isDone, |
||||||
|
})} |
||||||
|
> |
||||||
|
<div className={cn('flex items-start gap-2', { |
||||||
|
'opacity-50': task.status === 'done' |
||||||
|
})}> |
||||||
|
<span className="relative top-px flex h-5 w-5 items-center justify-center"> |
||||||
|
{isDone ? ( |
||||||
|
<Check className="h-4 w-4 stroke-[3px] text-green-500" /> |
||||||
|
) : ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'h-4 w-4 rounded-md border border-gray-300', |
||||||
|
task.status === 'ignored' |
||||||
|
? 'bg-gray-200' |
||||||
|
: 'bg-transparent', |
||||||
|
)} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
<div className="group-data-[status=ignored]/task:text-gray-400"> |
||||||
|
<h3 className="flex items-center text-sm font-semibold group-data-[status=done]/task:line-through"> |
||||||
|
{task.title} |
||||||
|
|
||||||
|
<a |
||||||
|
href={task.url} |
||||||
|
target="_blank" |
||||||
|
className={cn( |
||||||
|
'ml-1 inline-block rounded-xl border border-black bg-white pl-1.5 pr-1 text-xs font-normal text-black hover:bg-black hover:text-white', |
||||||
|
)} |
||||||
|
aria-label="Open task in new tab" |
||||||
|
onClick={() => { |
||||||
|
if (!task?.onClick) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
task.onClick(); |
||||||
|
}} |
||||||
|
> |
||||||
|
{task.urlText} |
||||||
|
<ArrowUpRight className="relative -top-[0.5px] ml-0.5 inline-block h-3.5 w-3.5 stroke-[2px]" /> |
||||||
|
</a> |
||||||
|
</h3> |
||||||
|
<p className="text-xs text-gray-500 group-data-[status=ignored]/task:text-gray-400"> |
||||||
|
{task.description} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</li> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
|
||||||
|
<div className="mt-2 px-11 pb-5"> |
||||||
|
<button |
||||||
|
className="w-full rounded-md bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600" |
||||||
|
onClick={onClose} |
||||||
|
> |
||||||
|
Do it later |
||||||
|
</button> |
||||||
|
|
||||||
|
<button |
||||||
|
className="mt-3 text-sm text-gray-500 underline underline-offset-2 hover:text-black" |
||||||
|
onClick={() => { |
||||||
|
pageProgressMessage.set('Ignoring Onboarding'); |
||||||
|
ignoreForever().finally(); |
||||||
|
}} |
||||||
|
> |
||||||
|
Ignore forever |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
Loading…
Reference in new issue