mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-01 06:50:26 +02:00
Add progress nudge on roadmap
This commit is contained in:
@@ -10,6 +10,7 @@ import { httpGet } from '../../lib/http.ts';
|
|||||||
import { useToast } from '../../hooks/use-toast.ts';
|
import { useToast } from '../../hooks/use-toast.ts';
|
||||||
import type { UserDocument } from '../../api/user.ts';
|
import type { UserDocument } from '../../api/user.ts';
|
||||||
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||||
|
import { OnboardingNudge } from '../OnboardingNudge.tsx';
|
||||||
|
|
||||||
export type OnboardingConfig = Pick<
|
export type OnboardingConfig = Pick<
|
||||||
UserDocument,
|
UserDocument,
|
||||||
@@ -24,7 +25,7 @@ export function AccountDropdown() {
|
|||||||
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
|
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
|
||||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||||
|
|
||||||
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
const [isConfigLoading, setIsConfigLoading] = useState(false);
|
||||||
const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
|
const [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
|
||||||
const [onboardingConfig, setOnboardingConfig] = useState<
|
const [onboardingConfig, setOnboardingConfig] = useState<
|
||||||
OnboardingConfig | undefined
|
OnboardingConfig | undefined
|
||||||
@@ -93,66 +94,80 @@ export function AccountDropdown() {
|
|||||||
).length;
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-[90] animate-fade-in">
|
<>
|
||||||
{isOnboardingModalOpen && onboardingConfig && (
|
{shouldShowOnboardingStatus && !isOnboardingModalOpen && (
|
||||||
<OnboardingModal
|
<OnboardingNudge
|
||||||
onboardingConfig={onboardingConfig}
|
onStartOnboarding={() => {
|
||||||
onClose={() => {
|
loadOnboardingConfig().then(() => {
|
||||||
setIsOnboardingModalOpen(false);
|
setIsOnboardingModalOpen(true);
|
||||||
}}
|
});
|
||||||
onIgnoreTask={(taskId, status) => {
|
|
||||||
loadOnboardingConfig().finally(() => {});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isCreatingRoadmap && (
|
|
||||||
<CreateRoadmapModal
|
|
||||||
onClose={() => {
|
|
||||||
setIsCreatingRoadmap(false);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="relative z-[90] animate-fade-in">
|
||||||
className="relative flex h-8 w-40 items-center justify-center gap-1.5 rounded-full 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"
|
{isOnboardingModalOpen && onboardingConfig && (
|
||||||
onClick={() => {
|
<OnboardingModal
|
||||||
setIsTeamsOpen(false);
|
onboardingConfig={onboardingConfig}
|
||||||
setShowDropdown(!showDropdown);
|
onClose={() => {
|
||||||
}}
|
setIsOnboardingModalOpen(false);
|
||||||
>
|
}}
|
||||||
<span className="inline-flex items-center">
|
onIgnoreTask={(taskId, status) => {
|
||||||
Account <span className="text-gray-300">/</span> Teams
|
loadOnboardingConfig().finally(() => {});
|
||||||
</span>
|
}}
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
/>
|
||||||
{shouldShowOnboardingStatus && !showDropdown && <NotificationIndicator />}
|
)}
|
||||||
</button>
|
{isCreatingRoadmap && (
|
||||||
|
<CreateRoadmapModal
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreatingRoadmap(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showDropdown && (
|
<button
|
||||||
<div
|
className="relative flex h-8 w-40 items-center justify-center gap-1.5 rounded-full 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"
|
||||||
ref={dropdownRef}
|
onClick={() => {
|
||||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
setIsTeamsOpen(false);
|
||||||
|
setShowDropdown(!showDropdown);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isTeamsOpen ? (
|
<span className="inline-flex items-center">
|
||||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
Account <span className="text-gray-300">/</span> Teams
|
||||||
) : (
|
</span>
|
||||||
<AccountDropdownList
|
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||||
onCreateRoadmap={() => {
|
{shouldShowOnboardingStatus && !showDropdown && (
|
||||||
setIsCreatingRoadmap(true);
|
<NotificationIndicator />
|
||||||
setShowDropdown(false);
|
|
||||||
}}
|
|
||||||
setIsTeamsOpen={setIsTeamsOpen}
|
|
||||||
onOnboardingClick={() => {
|
|
||||||
setIsOnboardingModalOpen(true);
|
|
||||||
setShowDropdown(false);
|
|
||||||
}}
|
|
||||||
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
|
|
||||||
isConfigLoading={isConfigLoading}
|
|
||||||
onboardingConfigCount={onboardingCount}
|
|
||||||
doneConfigCount={onboardingDoneCount}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
{showDropdown && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||||
|
>
|
||||||
|
{isTeamsOpen ? (
|
||||||
|
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||||
|
) : (
|
||||||
|
<AccountDropdownList
|
||||||
|
onCreateRoadmap={() => {
|
||||||
|
setIsCreatingRoadmap(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
setIsTeamsOpen={setIsTeamsOpen}
|
||||||
|
onOnboardingClick={() => {
|
||||||
|
setIsOnboardingModalOpen(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
|
||||||
|
isConfigLoading={isConfigLoading}
|
||||||
|
onboardingConfigCount={onboardingCount}
|
||||||
|
doneConfigCount={onboardingDoneCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -45,7 +45,7 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full items-center rounded py-1 pl-3 pr-2 text-sm font-medium text-slate-100 hover:opacity-80',
|
'flex h-9 w-full items-center rounded py-1 pl-3 pr-2 text-sm font-medium text-slate-100 hover:opacity-80',
|
||||||
isConfigLoading
|
isConfigLoading
|
||||||
? 'striped-loader-lighter flex border-slate-800 opacity-70'
|
? 'striped-loader-darker flex border-slate-800 opacity-70'
|
||||||
: 'border-slate-600 bg-slate-700',
|
: 'border-slate-600 bg-slate-700',
|
||||||
)}
|
)}
|
||||||
onClick={onOnboardingClick}
|
onClick={onOnboardingClick}
|
||||||
|
69
src/components/OnboardingNudge.tsx
Normal file
69
src/components/OnboardingNudge.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { cn } from '../lib/classname.ts';
|
||||||
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
import { useScrollPosition } from '../hooks/use-scroll-position.ts';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
type OnboardingNudgeProps = {
|
||||||
|
onStartOnboarding: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUDGE_ONBOARDING_KEY = 'should_nudge_onboarding';
|
||||||
|
|
||||||
|
export function OnboardingNudge(props: OnboardingNudgeProps) {
|
||||||
|
const { onStartOnboarding } = props;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { y: scrollY } = useScrollPosition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) === null) {
|
||||||
|
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'true');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (localStorage.getItem(NUDGE_ONBOARDING_KEY) !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollY < 100) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 right-0 top-0 z-[91] flex w-full items-center justify-center bg-yellow-300 py-1.5',
|
||||||
|
{
|
||||||
|
'striped-loader': isLoading,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-base font-semibold text-yellow-950">
|
||||||
|
Welcome! Please take a moment to{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
|
||||||
|
onStartOnboarding();
|
||||||
|
}}
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
complete onboarding
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative top-[3px] ml-1 px-1 py-1 text-yellow-600 hover:text-yellow-950"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
localStorage.setItem(NUDGE_ONBOARDING_KEY, 'false');
|
||||||
|
setIsLoading(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
22
src/hooks/use-scroll-position.ts
Normal file
22
src/hooks/use-scroll-position.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useScrollPosition() {
|
||||||
|
const [scrollPosition, setScrollPosition] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrollPosition({ x: window.scrollX, y: window.scrollY });
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return scrollPosition;
|
||||||
|
}
|
@@ -84,7 +84,7 @@ a > code:before {
|
|||||||
animation: barberpole 15s linear infinite;
|
animation: barberpole 15s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.striped-loader-lighter {
|
.striped-loader-darker {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
-45deg,
|
-45deg,
|
||||||
transparent,
|
transparent,
|
||||||
|
Reference in New Issue
Block a user