Compare commits
14 Commits
chore/crea
...
fix/member
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c7ae5a26 | ||
|
|
79be49bcae | ||
|
|
e5e0a7c8c5 | ||
|
|
90f3ffe270 | ||
|
|
ce47a7433e | ||
|
|
21b8358683 | ||
|
|
e1751b105f | ||
|
|
e43bea7c40 | ||
|
|
5fa669aec2 | ||
|
|
4b8f868b2b | ||
|
|
a0743a8272 | ||
|
|
2cae13c090 | ||
|
|
0bf287f1d6 | ||
|
|
d7d819b4b3 |
BIN
public/images/team-promo/contact.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/team-promo/documentation.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
public/images/team-promo/growth-plans.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
public/images/team-promo/hero-img.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/team-promo/hero.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/images/team-promo/invite-members.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/images/team-promo/many-roadmaps.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
public/images/team-promo/onboarding.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
public/images/team-promo/our-roadmaps.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/images/team-promo/progress-tracking.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/images/team-promo/roadmap-editor.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/images/team-promo/sharing-settings.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/images/team-promo/skill-gap.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/images/team-promo/team-dashboard.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/team-promo/team-insights.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/images/team-promo/update-progress.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
@@ -56,6 +56,12 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
|
||||
@@ -55,6 +55,12 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
@@ -86,10 +92,11 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
['/respond-invite', '/befriend'].includes(window.location.pathname)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
|
||||
@@ -55,6 +55,12 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
|
||||
@@ -73,7 +73,10 @@ function handleAuthenticated() {
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
|
||||
window.localStorage.removeItem('authRedirect');
|
||||
|
||||
window.location.href = authRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ const defaultPages: PageType[] = [
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
url: '/account/friends',
|
||||
title: 'Friends',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'roadmaps',
|
||||
url: '/roadmaps',
|
||||
@@ -47,6 +55,14 @@ const defaultPages: PageType[] = [
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
},
|
||||
{
|
||||
id: 'account-roadmaps',
|
||||
url: '/account/roadmaps',
|
||||
title: 'Custom Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
|
||||
@@ -86,13 +86,13 @@ export function RoadmapListPage() {
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-6 flex flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
|
||||
<div className="flex grow items-center gap-2">
|
||||
{tabTypes.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
|
||||
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
|
||||
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
|
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { TeamAnnouncement } from '../TeamAnnouncement';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function EmptyProgress(props: EmptyProgressProps) {
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
|
||||
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
|
||||
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
|
||||
Start learning ..
|
||||
</h2>
|
||||
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start justify-center py-6 sm:items-center">
|
||||
<h2
|
||||
className={'mb-1.5 flex items-center text-lg text-gray-200 sm:text-2xl'}
|
||||
>
|
||||
<CheckIcon additionalClasses="mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]" />
|
||||
{title}
|
||||
</h2>
|
||||
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
|
||||
|
||||
<p className="mt-5">
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,20 +119,23 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
showCustomRoadmaps={true}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MapIcon } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
import { TeamAnnouncement } from '../TeamAnnouncement';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@@ -87,6 +88,9 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-7 text-sm mt-2">
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
import {TeamAnnouncement} from "../TeamAnnouncement";
|
||||
---
|
||||
|
||||
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
|
||||
<div
|
||||
class='min-h-auto relative min-h-[192px] border-b border-b-[#1e293c] sm:min-h-[281px] transition-all'
|
||||
>
|
||||
<div
|
||||
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
|
||||
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
|
||||
id='hero-text'
|
||||
>
|
||||
<p class='-mt-4 sm:-mt-10 mb-7'>
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
>
|
||||
@@ -24,5 +31,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
their career.
|
||||
</p>
|
||||
</div>
|
||||
<FavoriteRoadmaps client:only="react" />
|
||||
<FavoriteRoadmaps client:only='react' />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,14 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
Friends
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account/roadmaps"
|
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Roadmaps
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<button
|
||||
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a
|
||||
@@ -27,8 +26,25 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
<li>
|
||||
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
|
||||
Teams
|
||||
<span
|
||||
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
|
||||
>
|
||||
New
|
||||
</span>
|
||||
|
||||
<span class='inline md:hidden absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<kbd
|
||||
@@ -45,7 +61,7 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown client:only="react" />
|
||||
<AccountDropdown client:only='react' />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useState, useMemo } from 'react';
|
||||
import { Globe2, Loader2, Lock } from 'lucide-react';
|
||||
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
|
||||
import { TransferToTeamList } from './TransferToTeamList';
|
||||
@@ -49,7 +49,10 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
const [members, setMembers] = useState<TeamMemberList[]>([]);
|
||||
|
||||
// Using global team members loading state to avoid glitchy UI when switching between teams
|
||||
const [isTeamMembersLoading, setIsTeamMembersLoading] = useState(false);
|
||||
const membersCache = useMemo(() => new Map<string, TeamMemberList[]>(), []);
|
||||
|
||||
const [visibility, setVisibility] = useState(defaultVisibility);
|
||||
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
|
||||
@@ -118,7 +121,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
};
|
||||
|
||||
const handleTransferToTeam = useCallback(
|
||||
async (teamId: string) => {
|
||||
async (teamId: string, sharedTeamMemberIds: string[]) => {
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +131,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
|
||||
{
|
||||
teamId,
|
||||
sharedTeamMemberIds,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -225,14 +229,6 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
{canTransferRoadmap && (
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
@@ -240,10 +236,43 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
members={members}
|
||||
setMembers={setMembers}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canTransferRoadmap && (
|
||||
<>
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
onTeamChange={() => {
|
||||
setSharedTeamMemberIds([]);
|
||||
}}
|
||||
/>
|
||||
{selectedTeamId && (
|
||||
<>
|
||||
<hr className="-mx-4 my-4" />
|
||||
<div className="mb-4">
|
||||
<ShareTeamMemberList
|
||||
title="Select who can access this roadmap. You can change this later."
|
||||
teamId={selectedTeamId!}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5">
|
||||
@@ -255,17 +284,23 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap ? (
|
||||
{canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
handleTransferToTeam(selectedTeamId!).then(() => null);
|
||||
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
|
||||
() => null
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Transfer
|
||||
</UpdateAction>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,27 +33,31 @@ export interface TeamMemberList extends TeamMemberDocument {
|
||||
|
||||
type ShareTeamMemberListProps = {
|
||||
teamId: string;
|
||||
setMembers: (members: TeamMemberList[]) => void;
|
||||
members: TeamMemberList[];
|
||||
title?: string;
|
||||
sharedTeamMemberIds: string[];
|
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
|
||||
|
||||
membersCache: Map<string, TeamMemberList[]>;
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
const {
|
||||
setMembers,
|
||||
members,
|
||||
teamId,
|
||||
title = 'Select Members',
|
||||
sharedTeamMemberIds,
|
||||
setSharedTeamMemberIds,
|
||||
teamId,
|
||||
|
||||
membersCache,
|
||||
isTeamMembersLoading: isLoading,
|
||||
setIsTeamMembersLoading: setIsLoading,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadTeamMembers() {
|
||||
if (members?.length > 0) {
|
||||
if (membersCache.has(teamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,21 +71,23 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMembers(response);
|
||||
const joinedMembers =
|
||||
response?.filter((member) => member.status === 'joined') || [];
|
||||
membersCache.set(teamId, joinedMembers);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamMembers().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [teamId]);
|
||||
|
||||
const loadingMembers = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
className="flex min-h-[66px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
@@ -93,11 +99,13 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const members = membersCache.get(teamId) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(members.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">Select Members</p>
|
||||
<p className="text-sm">{title}</p>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -11,10 +11,22 @@ type TransferToTeamListProps = {
|
||||
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
onTeamChange: (teamId: string | null) => void;
|
||||
};
|
||||
|
||||
export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props;
|
||||
const {
|
||||
teams,
|
||||
setTeams,
|
||||
selectedTeamId,
|
||||
setSelectedTeamId,
|
||||
isTeamMembersLoading,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
@@ -73,11 +85,17 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
)}
|
||||
disabled={isTeamMembersLoading}
|
||||
onClick={() => {
|
||||
setSelectedTeamId(team._id);
|
||||
if (isSelected) {
|
||||
setSelectedTeamId(null);
|
||||
} else {
|
||||
setSelectedTeamId(team._id);
|
||||
}
|
||||
onTeamChange(team._id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
|
||||
16
src/components/TeamAnnouncement.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
type TeamAnnouncementProps = {};
|
||||
|
||||
export function TeamAnnouncement(props: TeamAnnouncementProps) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md border border-dashed border-purple-700 px-3 py-1.5 text-purple-400 transition-colors hover:border-gray-700 hover:text-white"
|
||||
href="/teams"
|
||||
>
|
||||
<span className="relative -top-[0.5px] mr-1 text-xs font-semibold uppercase text-white">
|
||||
New
|
||||
</span>{' '}
|
||||
<span className={'hidden sm:inline'}>Announcing roadmaps for teams. <span className='font-semibold'>Learn more!</span></span>
|
||||
<span className={'inline sm:hidden'}>Announcing roadmaps for teams!</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
164
src/components/TeamMarketing/TeamDemo.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
|
||||
|
||||
const demoItems = [
|
||||
{
|
||||
title: 'Roadmap Editor',
|
||||
description:
|
||||
'<span class="font-semibold">Powerful editor</span> to create custom roadmaps and other trackable documents',
|
||||
image: '/images/team-promo/roadmap-editor.png',
|
||||
},
|
||||
{
|
||||
title: 'Invite Members',
|
||||
description:
|
||||
'Invite your <span class="font-semibold">team members and assign roles</span>',
|
||||
image: '/images/team-promo/invite-members.png',
|
||||
},
|
||||
{
|
||||
title: 'Track Progress',
|
||||
description:
|
||||
'You and your team can <span class="font-semibold">track progress</span> on the roadmaps',
|
||||
image: '/images/team-promo/update-progress.png',
|
||||
},
|
||||
{
|
||||
title: 'Team Dashboard',
|
||||
description:
|
||||
'Keep an eye on the team progress through <span class="font-semibold">team dashboards</span>',
|
||||
image: '/images/team-promo/team-dashboard.png',
|
||||
},
|
||||
{
|
||||
title: 'Roadmaps and Documents',
|
||||
description:
|
||||
'Create as many <span class="font-semibold">roadmaps or trackable documents</span> as you want',
|
||||
image: '/images/team-promo/many-roadmaps.png',
|
||||
},
|
||||
{
|
||||
title: 'Community Roadmaps',
|
||||
description:
|
||||
'Create custom roadmaps or customize <span class="font-semibold">community roadmaps</span> to fit your needs',
|
||||
image: '/images/team-promo/our-roadmaps.png',
|
||||
},
|
||||
{
|
||||
title: 'Sharing Settings',
|
||||
description:
|
||||
'Share a roadmap or trackable document with <span class="font-semibold">everyone or specific people</span>',
|
||||
image: '/images/team-promo/sharing-settings.png',
|
||||
},
|
||||
{
|
||||
title: 'More Coming Soon!',
|
||||
description:
|
||||
'<span class="font-semibold">We have a lot more coming soon!</span>',
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamDemo() {
|
||||
const [hasViewed, setHasViewed] = useState<number[]>([0]);
|
||||
const [activeItem, setActiveItem] = useState(demoItems[0]);
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hidden border-t py-12 sm:block">
|
||||
<div className="container">
|
||||
<h2 className="mb-2 text-3xl font-bold">See how it works</h2>
|
||||
<p>Here is a sneak peek of what you can do today (more coming soon!)</p>
|
||||
|
||||
<div className="relative mt-7 flex flex-row items-center gap-2.5">
|
||||
{demoItems.map((item, counter) => {
|
||||
const isActive = item === activeItem;
|
||||
const hasAlreadyViewed = hasViewed.includes(counter);
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<span key={item.title} className="relative flex items-center">
|
||||
<span
|
||||
onClick={() => {
|
||||
setHasViewed([...hasViewed, counter]);
|
||||
setActiveItem(item);
|
||||
}}
|
||||
className={cn('z-50 cursor-pointer rounded-full p-[6px]', {
|
||||
'bg-black': item === activeItem,
|
||||
'bg-gray-300 hover:bg-gray-400': item !== activeItem,
|
||||
})}
|
||||
/>
|
||||
{!hasAlreadyViewed && (
|
||||
<span className="pointer-events-none absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 opacity-75"></span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={item.title}
|
||||
className=" rounded-full bg-black px-3 py-0.5 text-sm text-white"
|
||||
>
|
||||
{activeItem.title}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-gray-300">
|
||||
<div className="p-3">
|
||||
<p
|
||||
className="text-base text-black"
|
||||
dangerouslySetInnerHTML={{ __html: activeItem.description }}
|
||||
/>
|
||||
</div>
|
||||
{activeItem.image && (
|
||||
<img
|
||||
className="rounded-b-xl border-t"
|
||||
src={activeItem.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{!activeItem.image && (
|
||||
<div className="bg-gray-50 py-4 pl-3">
|
||||
<p className="mb-3">
|
||||
Register your team now and help us shape the future of teams in
|
||||
roadmap.sh!
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-base font-medium text-white hover:bg-purple-700"
|
||||
>
|
||||
Create your Team
|
||||
</a>
|
||||
{!isAuthenticated && (
|
||||
<span className="ml-1 text-base">
|
||||
or
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
href="/login"
|
||||
className="text-purple-600 underline hover:text-purple-700"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/TeamMarketing/TeamHeroBanner.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CheckCircle, CheckCircle2, CheckIcon } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const featureList = [
|
||||
'Create custom roadmaps for your team',
|
||||
"Plan, track and document your team's skills and growth",
|
||||
'Invite your team members',
|
||||
"Get insights on your team's skills and growth",
|
||||
];
|
||||
|
||||
export function fireTeamCreationClick() {
|
||||
window.fireEvent({
|
||||
category: 'FeatureClick',
|
||||
action: `Pages / Teams`,
|
||||
label: 'Create your Team',
|
||||
});
|
||||
}
|
||||
|
||||
export function TeamHeroBanner() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white py-8 lg:py-12">
|
||||
<div className="container">
|
||||
<div className="flex flex-row items-center justify-start text-left lg:justify-between">
|
||||
<div className="flex flex-grow flex-col">
|
||||
<h1 className="mb-0.5 text-2xl font-bold sm:mb-2.5 sm:text-4xl lg:mb-4 lg:text-5xl">
|
||||
Roadmaps for Teams
|
||||
</h1>
|
||||
<p className="mb-4 text-base leading-normal text-gray-600 sm:mb-0 sm:leading-none lg:text-lg">
|
||||
Train, plan and track your team's skills and career growth.
|
||||
</p>
|
||||
|
||||
<ul className="mb-4 mt-0 hidden text-sm leading-7 sm:mb-4 sm:mt-4 sm:flex sm:flex-col lg:mb-6 lg:mt-6 lg:text-base lg:leading-8">
|
||||
{featureList.map((feature, index) => (
|
||||
<li key={feature}>
|
||||
<CheckCircle className="hidden h-6 w-6 text-green-500 sm:inline-block" />
|
||||
<span className="ml-0 sm:ml-2">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 sm:w-auto sm:text-base"
|
||||
>
|
||||
Create your Team
|
||||
</a>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<span className="ml-1 hidden text-base sm:inline">
|
||||
or
|
||||
<a
|
||||
href="/login"
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
className="text-purple-600 underline hover:text-purple-700"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
href="/login"
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-purple-600 px-5 py-2 text-base text-sm font-medium text-purple-600 hover:bg-blue-700 sm:hidden sm:text-base"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
alt={'team roadmaps'}
|
||||
className="hidden h-64 md:block lg:h-80"
|
||||
src="/images/team-promo/hero-img.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/TeamMarketing/TeamPricing.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Check, CheckCircle, Copy, Sparkles } from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TeamPricing() {
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const teamEmail = 'teams@roadmap.sh';
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-t py-4 sm:py-8 md:py-12">
|
||||
<div className="container">
|
||||
<h2 className="mb-1 text-xl font-bold sm:mb-1.5 sm:text-2xl md:mb-2 md:text-3xl">
|
||||
Beta Pricing
|
||||
</h2>
|
||||
<p className="mb-4 text-base text-gray-600 sm:mb-8 sm:text-lg">
|
||||
We are currently in public beta and are offering free access to all
|
||||
features.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:gap-4">
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-purple-500">
|
||||
<div className="px-8 pb-2 pt-5 text-center sm:pt-4">
|
||||
<h3 className="mb-1 text-2xl font-bold">Free</h3>
|
||||
<p className="text-sm text-gray-500">No credit card required</p>
|
||||
<p className="flex items-start justify-center gap-1 py-6 text-3xl">
|
||||
<span className="text-base text-gray-600">$</span>
|
||||
<span className="text-5xl font-bold">0</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('redirect', '/team/new');
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="block rounded-md bg-purple-600 px-6 py-2 text-center text-sm font-medium leading-6 text-white shadow transition hover:bg-gray-700 hover:shadow-lg focus:outline-none"
|
||||
>
|
||||
{isAuthenticated ? 'Create your Team' : 'Sign up for free'}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1 border-t px-8 py-5 text-center sm:py-3">
|
||||
<p className="font-semibold text-black">All the features</p>
|
||||
<p className="text-gray-600">Roles and Permissions</p>
|
||||
<p className="text-gray-600">Custom Roadmaps</p>
|
||||
<p className="text-gray-600">Sharing Options</p>
|
||||
<p className="text-gray-600">Progress Tracking</p>
|
||||
<p className="text-gray-600">Team Insights</p>
|
||||
<p className="text-gray-600">Onboarding support</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col items-center justify-center rounded-md border border-gray-300 py-8">
|
||||
<img
|
||||
alt={'waving hand'}
|
||||
src={'/images/team-promo/contact.png'}
|
||||
className="mb-3 h-40"
|
||||
/>
|
||||
<p className="mb-2 font-medium text-gray-500">
|
||||
Questions? We are here to help!
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
<button
|
||||
onClick={() => {
|
||||
copyText(teamEmail);
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center justify-between gap-3 overflow-hidden rounded-md border border-black bg-white px-4 py-2 text-black hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{teamEmail}
|
||||
<Copy
|
||||
className="relative top-[1px] ml-2 inline-block text-black transition-opacity"
|
||||
size={16}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black text-white transition-all',
|
||||
{
|
||||
'top-full': !isCopied,
|
||||
'top-0': isCopied,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Email copied!
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/TeamMarketing/TeamTools.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
const toolsList = [
|
||||
{
|
||||
imageUrl: '/images/team-promo/growth-plans.png',
|
||||
title: 'Growth plans',
|
||||
description: 'Prepare shared or individual growth plans for members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/progress-tracking.png',
|
||||
title: 'Progress tracking',
|
||||
description: 'Track the and compare the progress of team members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/onboarding.png',
|
||||
title: 'Onboarding',
|
||||
description: 'Prepare onboarding plans for new team members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/team-insights.png',
|
||||
title: 'Team insights',
|
||||
description: 'Get insights about your team skills, progress and more.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/skill-gap.png',
|
||||
title: 'Skill gap analysis',
|
||||
description: 'Understand the skills of your team and identify gaps.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/documentation.png',
|
||||
title: 'Documentation',
|
||||
description: 'Create and share visual team documentation.',
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamTools() {
|
||||
return (
|
||||
<div className="py-4 sm:py-8 md:py-12 border-t">
|
||||
<div className="container">
|
||||
<h2 className="mb-1 sm:mb-1.5 md:mb-2 text-xl sm:text-2xl md:text-3xl font-bold">Track and guide your team’s knowledge</h2>
|
||||
<p className='text-sm md:text-base'>
|
||||
Individual and team level growth plans, progress tracking, skill gap analysis, team insights and more.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 sm:mt-5 md:mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4">
|
||||
{toolsList.map((tool) => {
|
||||
return (
|
||||
<div className="rounded-md sm:rounded-xl border p-2 sm:p-5 text-left sm:text-center md:text-left">
|
||||
<img
|
||||
alt={tool.title}
|
||||
src={tool.imageUrl}
|
||||
className="mb-5 h-48 hidden sm:block mx-auto md:mx-0"
|
||||
/>
|
||||
<h3 className="mb-0.5 sm:mb-2 text-lg sm:text-2xl font-bold">{tool.title}</h3>
|
||||
<p className='text-sm sm:text-base'>{tool.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,4 @@ For more resources, visit the following links:
|
||||
|
||||
- [Change Tracking in EF Core](https://learn.microsoft.com/en-us/ef/core/change-tracking/)
|
||||
- [Intro to Change Tracking](https://www.oreilly.com/library/view/programming-entity-framework/9781449331825/ch05.html)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspxs)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspx)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspx)
|
||||
|
||||
14
src/pages/teams.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { TeamHeroBanner } from '../components/TeamMarketing/TeamHeroBanner';
|
||||
import { TeamTools } from '../components/TeamMarketing/TeamTools';
|
||||
import { TeamDemo } from '../components/TeamMarketing/TeamDemo';
|
||||
import { TeamPricing } from '../components/TeamMarketing/TeamPricing';
|
||||
---
|
||||
|
||||
<BaseLayout title='Roadmaps for teams' permalink={'/teams'}>
|
||||
<TeamHeroBanner client:load />
|
||||
<TeamTools />
|
||||
<TeamDemo client:load />
|
||||
<TeamPricing client:load />
|
||||
</BaseLayout>
|
||||