mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-22 00:43:01 +02:00
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>
This commit is contained in:
20
package.json
20
package.json
@@ -26,16 +26,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^8.2.5",
|
"@astrojs/node": "^8.2.5",
|
||||||
"@astrojs/react": "^3.3.1",
|
"@astrojs/react": "^3.3.4",
|
||||||
"@astrojs/sitemap": "^3.1.4",
|
"@astrojs/sitemap": "^3.1.4",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
"@fingerprintjs/fingerprintjs": "^4.3.0",
|
"@fingerprintjs/fingerprintjs": "^4.3.0",
|
||||||
"@nanostores/react": "^0.7.2",
|
"@nanostores/react": "^0.7.2",
|
||||||
"@napi-rs/image": "^1.9.2",
|
"@napi-rs/image": "^1.9.2",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"astro": "^4.7.0",
|
"astro": "^4.8.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"dom-to-image": "^2.6.0",
|
"dom-to-image": "^2.6.0",
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"htm": "^3.1.1",
|
"htm": "^3.1.1",
|
||||||
"image-size": "^1.1.1",
|
"image-size": "^1.1.1",
|
||||||
"jose": "^5.2.4",
|
"jose": "^5.3.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.376.0",
|
"lucide-react": "^0.378.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"nanostores": "^0.10.3",
|
"nanostores": "^0.10.3",
|
||||||
"node-html-parser": "^6.1.13",
|
"node-html-parser": "^6.1.13",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-tooltip": "^5.26.4",
|
"react-tooltip": "^5.26.4",
|
||||||
"reactflow": "^11.11.2",
|
"reactflow": "^11.11.3",
|
||||||
"rehype-external-links": "^3.0.0",
|
"rehype-external-links": "^3.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"roadmap-renderer": "^1.0.6",
|
"roadmap-renderer": "^1.0.6",
|
||||||
@@ -70,20 +70,20 @@
|
|||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.43.1",
|
"@playwright/test": "^1.44.0",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@types/dom-to-image": "^2.6.7",
|
"@types/dom-to-image": "^2.6.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/prismjs": "^1.26.3",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react-calendar-heatmap": "^1.6.7",
|
"@types/react-calendar-heatmap": "^1.6.7",
|
||||||
"csv-parser": "^3.0.0",
|
"csv-parser": "^3.0.0",
|
||||||
"gh-pages": "^6.1.1",
|
"gh-pages": "^6.1.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"openai": "^4.38.5",
|
"openai": "^4.45.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-astro": "^0.13.0",
|
"prettier-plugin-astro": "^0.13.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
"tsx": "^4.7.3"
|
"tsx": "^4.10.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
712
pnpm-lock.yaml
generated
712
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,9 @@ export const allowedProfileVisibility = ['public', 'private'] as const;
|
|||||||
export type AllowedProfileVisibility =
|
export type AllowedProfileVisibility =
|
||||||
(typeof allowedProfileVisibility)[number];
|
(typeof allowedProfileVisibility)[number];
|
||||||
|
|
||||||
|
export const allowedOnboardingStatus = ['done', 'pending', 'ignored'] as const;
|
||||||
|
export type AllowedOnboardingStatus = (typeof allowedOnboardingStatus)[number];
|
||||||
|
|
||||||
export interface UserDocument {
|
export interface UserDocument {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -56,6 +59,18 @@ export interface UserDocument {
|
|||||||
};
|
};
|
||||||
resetPasswordCodeAt: string;
|
resetPasswordCodeAt: string;
|
||||||
verifiedAt: string;
|
verifiedAt: string;
|
||||||
|
|
||||||
|
// Onboarding fields
|
||||||
|
onboardingStatus?: AllowedOnboardingStatus;
|
||||||
|
onboarding?: {
|
||||||
|
updateProgress: AllowedOnboardingStatus;
|
||||||
|
publishProfile: AllowedOnboardingStatus;
|
||||||
|
customRoadmap: AllowedOnboardingStatus;
|
||||||
|
addFriends: AllowedOnboardingStatus;
|
||||||
|
roadCard: AllowedOnboardingStatus;
|
||||||
|
inviteTeam: AllowedOnboardingStatus;
|
||||||
|
};
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
@@ -1,29 +1,110 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown, User } from 'lucide-react';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { getUser, isLoggedIn } from '../../lib/jwt';
|
||||||
import { AccountDropdownList } from './AccountDropdownList';
|
import { AccountDropdownList } from './AccountDropdownList';
|
||||||
import { DropdownTeamList } from './DropdownTeamList';
|
import { DropdownTeamList } from './DropdownTeamList';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||||
|
import { OnboardingModal } from './OnboardingModal.tsx';
|
||||||
|
import { httpGet } from '../../lib/http.ts';
|
||||||
|
import { useToast } from '../../hooks/use-toast.ts';
|
||||||
|
import type { UserDocument } from '../../api/user.ts';
|
||||||
|
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||||
|
|
||||||
|
export type OnboardingConfig = Pick<
|
||||||
|
UserDocument,
|
||||||
|
'onboarding' | 'onboardingStatus'
|
||||||
|
>;
|
||||||
|
|
||||||
export function AccountDropdown() {
|
export function AccountDropdown() {
|
||||||
|
const toast = useToast();
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
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 [isOnboardingModalOpen, setIsOnboardingModalOpen] = useState(false);
|
||||||
|
const [onboardingConfig, setOnboardingConfig] = useState<
|
||||||
|
OnboardingConfig | undefined
|
||||||
|
>(undefined);
|
||||||
|
const currentUser = getUser();
|
||||||
|
|
||||||
|
const shouldShowOnboardingStatus =
|
||||||
|
currentUser?.onboardingStatus === 'pending' ||
|
||||||
|
onboardingConfig?.onboardingStatus === 'pending';
|
||||||
|
|
||||||
|
const loadOnboardingConfig = async () => {
|
||||||
|
if (!isLoggedIn() || !shouldShowOnboardingStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConfigLoading(true);
|
||||||
|
const { response, error } = await httpGet<OnboardingConfig>(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-onboarding-config`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || !response) {
|
||||||
|
toast.error(error?.message || 'Failed to load onboarding config');
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnboardingConfig(response);
|
||||||
|
};
|
||||||
|
|
||||||
useOutsideClick(dropdownRef, () => {
|
useOutsideClick(dropdownRef, () => {
|
||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
setIsTeamsOpen(false);
|
setIsTeamsOpen(false);
|
||||||
|
setIsConfigLoading(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn() || !showDropdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOnboardingConfig().finally(() => {
|
||||||
|
setIsConfigLoading(false);
|
||||||
|
});
|
||||||
|
}, [showDropdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = () => {
|
||||||
|
loadOnboardingConfig().finally(() => {
|
||||||
|
setIsConfigLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('visibilitychange', loadConfig);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('visibilitychange', loadConfig);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onboardingDoneCount = Object.values(
|
||||||
|
onboardingConfig?.onboarding || {},
|
||||||
|
).filter((status) => status !== 'pending').length;
|
||||||
|
const onboardingCount = Object.keys(
|
||||||
|
onboardingConfig?.onboarding || {},
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-50 animate-fade-in">
|
<div className="relative z-50 animate-fade-in">
|
||||||
|
{isOnboardingModalOpen && onboardingConfig && (
|
||||||
|
<OnboardingModal
|
||||||
|
onboardingConfig={onboardingConfig}
|
||||||
|
onClose={() => {
|
||||||
|
setIsOnboardingModalOpen(false);
|
||||||
|
}}
|
||||||
|
onIgnoreTask={(taskId, status) => {
|
||||||
|
loadOnboardingConfig().finally(() => {});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isCreatingRoadmap && (
|
{isCreatingRoadmap && (
|
||||||
<CreateRoadmapModal
|
<CreateRoadmapModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -33,7 +114,7 @@ export function AccountDropdown() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="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"
|
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"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsTeamsOpen(false);
|
setIsTeamsOpen(false);
|
||||||
setShowDropdown(!showDropdown);
|
setShowDropdown(!showDropdown);
|
||||||
@@ -43,6 +124,7 @@ export function AccountDropdown() {
|
|||||||
Account <span className="text-gray-300">/</span> Teams
|
Account <span className="text-gray-300">/</span> Teams
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||||
|
{shouldShowOnboardingStatus && !showDropdown && <NotificationIndicator />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDropdown && (
|
{showDropdown && (
|
||||||
@@ -59,6 +141,14 @@ export function AccountDropdown() {
|
|||||||
setShowDropdown(false);
|
setShowDropdown(false);
|
||||||
}}
|
}}
|
||||||
setIsTeamsOpen={setIsTeamsOpen}
|
setIsTeamsOpen={setIsTeamsOpen}
|
||||||
|
onOnboardingClick={() => {
|
||||||
|
setIsOnboardingModalOpen(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
shouldShowOnboardingStatus={shouldShowOnboardingStatus}
|
||||||
|
isConfigLoading={isConfigLoading}
|
||||||
|
onboardingConfigCount={onboardingCount}
|
||||||
|
doneConfigCount={onboardingDoneCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -6,21 +6,67 @@ import {
|
|||||||
SquareUserRound,
|
SquareUserRound,
|
||||||
User2,
|
User2,
|
||||||
Users2,
|
Users2,
|
||||||
|
Handshake,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { logout } from './navigation';
|
import { logout } from './navigation';
|
||||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { cn } from '../../lib/classname.ts';
|
||||||
|
import { NotificationIndicator } from './NotificationIndicator.tsx';
|
||||||
|
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||||
|
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||||
|
|
||||||
type AccountDropdownListProps = {
|
type AccountDropdownListProps = {
|
||||||
onCreateRoadmap: () => void;
|
onCreateRoadmap: () => void;
|
||||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||||
|
onOnboardingClick: () => void;
|
||||||
|
isConfigLoading: boolean;
|
||||||
|
shouldShowOnboardingStatus?: boolean;
|
||||||
|
onboardingConfigCount: number;
|
||||||
|
doneConfigCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AccountDropdownList(props: AccountDropdownListProps) {
|
export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||||
const { setIsTeamsOpen, onCreateRoadmap } = props;
|
const {
|
||||||
|
setIsTeamsOpen,
|
||||||
|
onCreateRoadmap,
|
||||||
|
onOnboardingClick,
|
||||||
|
isConfigLoading = true,
|
||||||
|
shouldShowOnboardingStatus = false,
|
||||||
|
onboardingConfigCount,
|
||||||
|
doneConfigCount,
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
|
{shouldShowOnboardingStatus && (
|
||||||
|
<li className="mb-1 px-1">
|
||||||
|
<button
|
||||||
|
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',
|
||||||
|
isConfigLoading
|
||||||
|
? 'striped-loader-lighter flex border-slate-800 opacity-70'
|
||||||
|
: 'border-slate-600 bg-slate-700',
|
||||||
|
)}
|
||||||
|
onClick={onOnboardingClick}
|
||||||
|
disabled={isConfigLoading}
|
||||||
|
>
|
||||||
|
<NotificationIndicator className="-left-0.5 -top-0.5" />
|
||||||
|
|
||||||
|
{isConfigLoading ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Handshake className="mr-2 h-4 w-4 text-slate-400 group-hover:text-white" />
|
||||||
|
<span>Onboarding</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
{doneConfigCount} of {onboardingConfigCount}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li className="px-1">
|
<li className="px-1">
|
||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
|
20
src/components/Navigation/NotificationIndicator.tsx
Normal file
20
src/components/Navigation/NotificationIndicator.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
248
src/components/Navigation/OnboardingModal.tsx
Normal file
248
src/components/Navigation/OnboardingModal.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
@@ -4,10 +4,11 @@ import { CopyIcon } from 'lucide-react';
|
|||||||
type EditorProps = {
|
type EditorProps = {
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
onCopy?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Editor(props: EditorProps) {
|
export function Editor(props: EditorProps) {
|
||||||
const { text, title } = props;
|
const { text, title, onCopy } = props;
|
||||||
|
|
||||||
const { isCopied, copyText } = useCopyText();
|
const { isCopied, copyText } = useCopyText();
|
||||||
|
|
||||||
@@ -17,7 +18,13 @@ export function Editor(props: EditorProps) {
|
|||||||
<span className="text-xs uppercase leading-none text-gray-400">
|
<span className="text-xs uppercase leading-none text-gray-400">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
<button className="flex items-center" onClick={() => copyText(text)}>
|
<button
|
||||||
|
className="flex items-center"
|
||||||
|
onClick={() => {
|
||||||
|
copyText(text);
|
||||||
|
onCopy?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isCopied && (
|
{isCopied && (
|
||||||
<span className="mr-1 text-xs leading-none text-gray-700">
|
<span className="mr-1 text-xs leading-none text-gray-700">
|
||||||
Copied!
|
Copied!
|
||||||
@@ -33,6 +40,7 @@ export function Editor(props: EditorProps) {
|
|||||||
onClick={(e: any) => {
|
onClick={(e: any) => {
|
||||||
e.target.select();
|
e.target.select();
|
||||||
copyText(e.target.value);
|
copyText(e.target.value);
|
||||||
|
onCopy?.();
|
||||||
}}
|
}}
|
||||||
value={text}
|
value={text}
|
||||||
/>
|
/>
|
||||||
|
@@ -9,6 +9,8 @@ import { SelectionButton } from './SelectionButton';
|
|||||||
import { StepCounter } from './StepCounter';
|
import { StepCounter } from './StepCounter';
|
||||||
import { Editor } from './Editor';
|
import { Editor } from './Editor';
|
||||||
import { CopyIcon } from 'lucide-react';
|
import { CopyIcon } from 'lucide-react';
|
||||||
|
import { httpPatch } from '../../lib/http';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
|
||||||
type StepLabelProps = {
|
type StepLabelProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,11 +26,28 @@ function StepLabel(props: StepLabelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RoadCardPage() {
|
export function RoadCardPage() {
|
||||||
|
const user = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const { isCopied, copyText } = useCopyText();
|
const { isCopied, copyText } = useCopyText();
|
||||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||||
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
|
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
|
||||||
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
|
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
|
||||||
const user = useAuth();
|
|
||||||
|
const markRoadCardDone = async () => {
|
||||||
|
const { error } = await httpPatch(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-update-onboarding-config`,
|
||||||
|
{
|
||||||
|
id: 'roadCard',
|
||||||
|
status: 'done',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error?.message || 'Something went wrong');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -131,20 +150,24 @@ export function RoadCardPage() {
|
|||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
|
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
downloadImage({
|
downloadImage({
|
||||||
url: badgeUrl.toString(),
|
url: badgeUrl.toString(),
|
||||||
name: 'road-card',
|
name: 'road-card',
|
||||||
scale: 4,
|
scale: 4,
|
||||||
})
|
});
|
||||||
}
|
markRoadCardDone();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={isCopied}
|
disabled={isCopied}
|
||||||
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
||||||
onClick={() => copyText(badgeUrl.toString())}
|
onClick={() => {
|
||||||
|
copyText(badgeUrl.toString());
|
||||||
|
markRoadCardDone();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
|
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
|
||||||
|
|
||||||
@@ -156,11 +179,13 @@ export function RoadCardPage() {
|
|||||||
<Editor
|
<Editor
|
||||||
title={'HTML'}
|
title={'HTML'}
|
||||||
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
|
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
|
||||||
|
onCopy={() => markRoadCardDone()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
title={'Markdown'}
|
title={'Markdown'}
|
||||||
text={`[](https://roadmap.sh)`.trim()}
|
text={`[](https://roadmap.sh)`.trim()}
|
||||||
|
onCopy={() => markRoadCardDone()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import * as jose from 'jose';
|
import * as jose from 'jose';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
|
import type { AllowedOnboardingStatus } from '../api/user';
|
||||||
|
|
||||||
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
|
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ export type TokenPayload = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
onboardingStatus?: AllowedOnboardingStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function decodeToken(token: string): TokenPayload {
|
export function decodeToken(token: string): TokenPayload {
|
||||||
|
@@ -8,7 +8,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@apply rounded-sm bg-gray-400 px-1.5 py-0.5 text-xs font-medium uppercase text-white
|
@apply rounded-sm bg-gray-400 px-1.5 py-0.5 text-xs font-medium uppercase text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chrome, Safari and Opera */
|
/* Chrome, Safari and Opera */
|
||||||
@@ -84,6 +84,18 @@ a > code:before {
|
|||||||
animation: barberpole 15s linear infinite;
|
animation: barberpole 15s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.striped-loader-lighter {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 5px,
|
||||||
|
hsla(0, 0%, 0%, 0.20) 5px,
|
||||||
|
hsla(0, 0%, 0%, 0.20) 10px
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: barberpole 15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes barberpole {
|
@keyframes barberpole {
|
||||||
100% {
|
100% {
|
||||||
background-position: 100% 100%;
|
background-position: 100% 100%;
|
||||||
|
Reference in New Issue
Block a user