feat: new user flag (#8070)

* feat: new user flag

* feat: share icon event

* fix: upload the query tag

* fix: name and label

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
feat/sub-changelog^2
Arik Chakma 2 weeks ago committed by GitHub
parent 0bef28fa20
commit 1ffa292c98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      src/components/AuthenticationFlow/EmailLoginForm.tsx
  2. 31
      src/components/AuthenticationFlow/GitHubButton.tsx
  3. 36
      src/components/AuthenticationFlow/GoogleButton.tsx
  4. 36
      src/components/AuthenticationFlow/LinkedInButton.tsx
  5. 11
      src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
  6. 20
      src/components/ReactIcons/HackerNewsIcon.tsx
  7. 20
      src/components/ReactIcons/RedditIcon.tsx
  8. 2
      src/components/ReactIcons/TwitterIcon.tsx
  9. 34
      src/components/ShareIcons/ShareIcons.astro
  10. 105
      src/components/ShareIcons/ShareIcons.tsx
  11. 32
      src/components/ShareIcons/sharer.js
  12. 1
      src/lib/jwt.ts
  13. 51
      src/pages/[roadmapId]/courses.astro
  14. 5
      src/pages/[roadmapId]/index.astro
  15. 5
      src/pages/best-practices/[bestPracticeId]/index.astro

@ -2,7 +2,7 @@ import Cookies from 'js-cookie';
import type { FormEvent } from 'react';
import { useId, useState } from 'react';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { FIRST_LOGIN_PARAM, setAuthToken } from '../../lib/jwt';
type EmailLoginFormProps = {
isDisabled?: boolean;
@ -24,19 +24,24 @@ export function EmailLoginForm(props: EmailLoginFormProps) {
setIsDisabled?.(true);
setError('');
const { response, error } = await httpPost<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
{
email,
password,
},
);
const { response, error } = await httpPost<{
token: string;
isNewUser: boolean;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-login`, {
email,
password,
});
// Log the user in and reload the page
if (response?.token) {
setAuthToken(response.token);
window.location.reload();
const currentLocation = window.location.href;
const url = new URL(currentLocation, window.location.origin);
if (response?.isNewUser) {
url.searchParams.set(FIRST_LOGIN_PARAM, '1');
}
window.location.href = url.toString();
return;
}

@ -1,8 +1,12 @@
import { useEffect, useState } from 'react';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import {
FIRST_LOGIN_PARAM,
COURSE_PURCHASE_PARAM,
setAuthToken,
} from '../../lib/jwt';
import { cn } from '../../../editor/utils/classname.ts';
import { httpGet } from '../../lib/http';
import { COURSE_PURCHASE_PARAM, setAuthToken } from '../../lib/jwt';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
import { triggerUtmRegistration } from '../../lib/browser.ts';
@ -34,7 +38,7 @@ export function GitHubButton(props: GitHubButtonProps) {
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
httpGet<{ token: string; isNewUser: boolean }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
window.location.search
}`,
@ -51,7 +55,7 @@ export function GitHubButton(props: GitHubButtonProps) {
triggerUtmRegistration();
let redirectUrl = '/';
let redirectUrl = new URL('/', window.location.origin);
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE);
@ -63,31 +67,36 @@ export function GitHubButton(props: GitHubButtonProps) {
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGithub;
redirectUrl = new URL(lastPageBeforeGithub, window.location.origin);
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
redirectUrl = new URL(authRedirectUrl, window.location.origin);
}
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
setAuthToken(response.token);
if (response?.isNewUser) {
redirectUrl.searchParams.set(FIRST_LOGIN_PARAM, '1');
}
const shouldTriggerPurchase =
localStorage.getItem(CHECKOUT_AFTER_LOGIN_KEY) !== '0';
if (redirectUrl.includes('/courses/sql') && shouldTriggerPurchase) {
const tempUrl = new URL(redirectUrl, window.location.origin);
tempUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
redirectUrl = tempUrl.toString();
if (
redirectUrl.pathname.includes('/courses/sql') &&
shouldTriggerPurchase
) {
redirectUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
window.location.href = redirectUrl;
window.location.href = redirectUrl.toString();
})
.catch((err) => {
setError('Something went wrong. Please try again later.');

@ -1,7 +1,12 @@
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname.ts';
import Cookies from 'js-cookie';
import {
FIRST_LOGIN_PARAM,
TOKEN_COOKIE_NAME,
setAuthToken,
} from '../../lib/jwt';
import { httpGet } from '../../lib/http';
import { COURSE_PURCHASE_PARAM, setAuthToken } from '../../lib/jwt';
import { COURSE_PURCHASE_PARAM } from '../../lib/jwt';
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
@ -9,6 +14,7 @@ import {
getStoredUtmParams,
triggerUtmRegistration,
} from '../../lib/browser.ts';
import { cn } from '../../lib/classname.ts';
type GoogleButtonProps = {
isDisabled?: boolean;
@ -37,14 +43,12 @@ export function GoogleButton(props: GoogleButtonProps) {
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
httpGet<{ token: string; isNewUser: boolean }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
window.location.search
}`,
)
.then(({ response, error }) => {
const utmParams = getStoredUtmParams();
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
@ -55,7 +59,7 @@ export function GoogleButton(props: GoogleButtonProps) {
triggerUtmRegistration();
let redirectUrl = '/';
let redirectUrl = new URL('/', window.location.origin);
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE);
@ -67,22 +71,27 @@ export function GoogleButton(props: GoogleButtonProps) {
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGoogle;
redirectUrl = new URL(lastPageBeforeGoogle, window.location.origin);
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
redirectUrl = new URL(authRedirectUrl, window.location.origin);
}
if (response?.isNewUser) {
redirectUrl.searchParams.set(FIRST_LOGIN_PARAM, '1');
}
const shouldTriggerPurchase =
localStorage.getItem(CHECKOUT_AFTER_LOGIN_KEY) !== '0';
if (redirectUrl.includes('/courses/sql') && shouldTriggerPurchase) {
const tempUrl = new URL(redirectUrl, window.location.origin);
tempUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
redirectUrl = tempUrl.toString();
if (
redirectUrl.pathname.includes('/courses/sql') &&
shouldTriggerPurchase
) {
redirectUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
@ -90,7 +99,8 @@ export function GoogleButton(props: GoogleButtonProps) {
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
setAuthToken(response.token);
window.location.href = redirectUrl;
window.location.href = redirectUrl.toString();
})
.catch((err) => {
setError('Something went wrong. Please try again later.');

@ -1,7 +1,13 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import {
FIRST_LOGIN_PARAM,
COURSE_PURCHASE_PARAM,
TOKEN_COOKIE_NAME,
setAuthToken,
} from '../../lib/jwt';
import { cn } from '../../lib/classname.ts';
import { httpGet } from '../../lib/http';
import { COURSE_PURCHASE_PARAM, setAuthToken } from '../../lib/jwt';
import { LinkedInIcon } from '../ReactIcons/LinkedInIcon.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
@ -34,7 +40,7 @@ export function LinkedInButton(props: LinkedInButtonProps) {
setIsLoading(true);
setIsDisabled?.(true);
httpGet<{ token: string }>(
httpGet<{ token: string; isNewUser: boolean }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
window.location.search
}`,
@ -50,7 +56,7 @@ export function LinkedInButton(props: LinkedInButtonProps) {
triggerUtmRegistration();
let redirectUrl = '/';
let redirectUrl = new URL('/', window.location.origin);
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
@ -62,30 +68,38 @@ export function LinkedInButton(props: LinkedInButtonProps) {
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeLinkedIn;
redirectUrl = new URL(
lastPageBeforeLinkedIn,
window.location.origin,
);
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
redirectUrl = new URL(authRedirectUrl, window.location.origin);
}
if (response?.isNewUser) {
redirectUrl.searchParams.set(FIRST_LOGIN_PARAM, '1');
}
const shouldTriggerPurchase =
localStorage.getItem(CHECKOUT_AFTER_LOGIN_KEY) !== '0';
if (redirectUrl.includes('/courses/sql') && shouldTriggerPurchase) {
const tempUrl = new URL(redirectUrl, window.location.origin);
tempUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
redirectUrl = tempUrl.toString();
if (
redirectUrl.pathname.includes('/courses/sql') &&
shouldTriggerPurchase
) {
redirectUrl.searchParams.set(COURSE_PURCHASE_PARAM, '1');
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
setAuthToken(response.token);
window.location.href = redirectUrl;
window.location.href = redirectUrl.toString();
})
.catch((err) => {
setError('Something went wrong. Please try again later.');

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { FIRST_LOGIN_PARAM, TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { Spinner } from '../ReactIcons/Spinner';
import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2';
import { triggerUtmRegistration } from '../../lib/browser.ts';
@ -13,7 +13,7 @@ export function TriggerVerifyAccount() {
const triggerVerify = (code: string) => {
setIsLoading(true);
httpPost<{ token: string }>(
httpPost<{ token: string; isNewUser: boolean }>(
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
{
code,
@ -30,7 +30,12 @@ export function TriggerVerifyAccount() {
triggerUtmRegistration();
setAuthToken(response.token);
window.location.href = '/';
const url = new URL('/', window.location.origin);
if (response?.isNewUser) {
url.searchParams.set(FIRST_LOGIN_PARAM, '1');
}
window.location.href = url.toString();
})
.catch((err) => {
setIsLoading(false);

@ -0,0 +1,20 @@
import { cn } from '../../lib/classname';
interface HackerNewsIconProps {
className?: string;
}
export function HackerNewsIcon(props: HackerNewsIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"
fill="currentColor"
className={cn('h-[26px] w-[26px]', className)}
>
<path d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zM21.2 229.2H21c.1-.1.2-.3.3-.4 0 .1 0 .3-.1.4zm218 53.9V384h-31.4V281.3L128 128h37.3c52.5 98.3 49.2 101.2 59.3 125.6 12.3-27 5.8-24.4 60.6-125.6H320l-80.8 155.1z" />
</svg>
);
}

@ -0,0 +1,20 @@
import { cn } from '../../lib/classname';
interface RedditIconProps {
className?: string;
}
export function RedditIcon(props: RedditIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"
fill="currentColor"
className={cn('h-[26px] w-[26px]', className)}
>
<path d="M283.2 345.5c2.7 2.7 2.7 6.8 0 9.2-24.5 24.5-93.8 24.6-118.4 0-2.7-2.4-2.7-6.5 0-9.2 2.4-2.4 6.5-2.4 8.9 0 18.7 19.2 81 19.6 100.5 0 2.4-2.3 6.6-2.3 9 0zm-91.3-53.8c0-14.9-11.9-26.8-26.5-26.8a26.67 26.67 0 0 0-26.8 26.8c0 14.6 11.9 26.5 26.8 26.5 14.6 0 26.5-11.9 26.5-26.5zm90.7-26.8c-14.6 0-26.5 11.9-26.5 26.8 0 14.6 11.9 26.5 26.5 26.5 14.9 0 26.8-11.9 26.8-26.5a26.67 26.67 0 0 0-26.8-26.8zM448 80v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V80c0-26.5 21.5-48 48-48h352c26.5 0 48 21.5 48 48zm-99.7 140.6c-10.1 0-19 4.2-25.6 10.7-24.1-16.7-56.5-27.4-92.5-28.6l18.7-84.2 59.5 13.4c0 14.6 11.9 26.5 26.5 26.5 14.9 0 26.8-12.2 26.8-26.8s-11.9-26.8-26.8-26.8c-10.4 0-19.3 6.2-23.8 14.9l-65.7-14.6c-3.3-.9-6.5 1.5-7.4 4.8l-20.5 92.8c-35.7 1.5-67.8 12.2-91.9 28.9-6.5-6.8-15.8-11-25.9-11-37.5 0-49.8 50.4-15.5 67.5-1.2 5.4-1.8 11-1.8 16.7 0 56.5 63.7 102.3 141.9 102.3 78.5 0 142.2-45.8 142.2-102.3 0-5.7-.6-11.6-2.1-17 33.6-17.2 21.2-67.2-16.1-67.2z" />
</svg>
);
}

@ -18,7 +18,7 @@ export function TwitterIcon(props: TwitterIconProps) {
<rect width="23" height="23" rx="3" fill={boxColor} />
<path
d="M12.9285 10.3522L18.5135 4H17.1905L12.339 9.5144L8.467 4H4L9.8565 12.3395L4 19H5.323L10.443 13.1754L14.533 19H19M5.8005 4.97619H7.833L17.1895 18.0718H15.1565"
fill='currentColor'
fill="currentColor"
/>
</svg>
);

@ -1,34 +0,0 @@
---
import Icon from '../AstroIcon.astro';
export interface Props {
pageUrl: string;
description: string;
}
const { pageUrl, description } = Astro.props;
const twitterUrl = `https://twitter.com/intent/tweet?text=${description}&url=${pageUrl}`;
const fbUrl = `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${pageUrl}`;
const hnUrl = `https://news.ycombinator.com/submitlink?t=${description}&u=${pageUrl}`;
const redditUrl = `https://www.reddit.com/submit?title=${description}&url=${pageUrl}`;
---
<div class='absolute left-[-18px] top-[110px] h-full hidden' id='page-share-icons'>
<div class='flex sticky top-[100px] flex-col gap-1.5 items-center'>
<a href={twitterUrl} target='_blank' class='text-gray-500 hover:text-gray-700 mb-0.5'>
<Icon icon='twitter' />
</a>
<a href={fbUrl} target='_blank' class='text-gray-500 hover:text-gray-700'>
<Icon icon='facebook' />
</a>
<a href={hnUrl} target='_blank' class='text-gray-500 hover:text-gray-700'>
<Icon icon='hackernews' />
</a>
<a href={redditUrl} target='_blank' class='text-gray-500 hover:text-gray-700'>
<Icon icon='reddit' />
</a>
</div>
</div>
<script src='./sharer.js'></script>

@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
import { cn } from '../../lib/classname';
import { FacebookIcon } from '../ReactIcons/FacebookIcon';
import { HackerNewsIcon } from '../ReactIcons/HackerNewsIcon';
import { RedditIcon } from '../ReactIcons/RedditIcon';
import { TwitterIcon } from '../ReactIcons/TwitterIcon';
type ShareIconsProps = {
resourceId: string;
resourceType: string;
pageUrl: string;
description: string;
};
export function ShareIcons(props: ShareIconsProps) {
const { pageUrl, description, resourceType, resourceId } = props;
const shareIconsRef = useRef<HTMLDivElement>(null);
const twitterUrl = `https://twitter.com/intent/tweet?text=${description}&url=${pageUrl}`;
const fbUrl = `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${pageUrl}`;
const hnUrl = `https://news.ycombinator.com/submitlink?t=${description}&u=${pageUrl}`;
const redditUrl = `https://www.reddit.com/submit?title=${description}&url=${pageUrl}`;
const icons = [
{
url: twitterUrl,
icon: (
<TwitterIcon
className="size-[24px] [&>path]:fill-[#E5E5E5]"
boxColor="currentColor"
/>
),
},
{
url: fbUrl,
icon: <FacebookIcon className="size-[26px]" />,
},
{
url: hnUrl,
icon: <HackerNewsIcon className="size-[26px]" />,
},
{
url: redditUrl,
icon: <RedditIcon className="size-[26px]" />,
},
];
useEffect(() => {
const shareIcons = shareIconsRef.current;
if (!shareIcons) {
return;
}
const onScroll = () => {
if (window.scrollY < 100 || window.innerWidth < 1050) {
shareIcons.classList.add('hidden');
return null;
}
shareIcons.classList.remove('hidden');
};
onScroll();
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
};
}, []);
return (
<div
className="absolute left-[-18px] top-[110px] hidden h-full"
ref={shareIconsRef}
>
<div className="sticky top-[100px] flex flex-col items-center gap-1.5">
{icons.map((icon, index) => {
const host = new URL(icon.url).host;
return (
<a
key={index}
href={icon.url}
target="_blank"
className={cn(
'text-gray-500 hover:text-gray-700',
index === 0 && 'mt-0.5',
)}
onClick={() => {
window.fireEvent({
category: 'RoadmapShareLink',
action: `Share Roadmap / ${resourceType} / ${resourceId} / ${host}`,
label: icon.url,
});
}}
>
{icon.icon}
</a>
);
})}
</div>
</div>
);
}

@ -1,32 +0,0 @@
export class Sharer {
constructor() {
this.init = this.init.bind(this);
this.onScroll = this.onScroll.bind(this);
this.shareIconsId = 'page-share-icons';
}
get shareIconsEl() {
return document.getElementById(this.shareIconsId);
}
onScroll() {
if (window.scrollY < 100 || window.innerWidth < 1050) {
this.shareIconsEl.classList.add('hidden');
return null;
}
this.shareIconsEl.classList.remove('hidden');
}
init() {
if (!this.shareIconsEl) {
return;
}
window.addEventListener('scroll', this.onScroll, { passive: true });
}
}
const sharer = new Sharer();
sharer.init();

@ -3,6 +3,7 @@ import Cookies from 'js-cookie';
import type { AllowedOnboardingStatus } from '../api/user';
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
export const FIRST_LOGIN_PARAM = 'fl' as const;
export const COURSE_PURCHASE_PARAM = 't';
export type TokenPayload = {

@ -1,25 +1,8 @@
---
import { EditorRoadmap } from '../../components/EditorRoadmap/EditorRoadmap';
import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import { FolderKanbanIcon } from 'lucide-react';
import { EmptyProjects } from '../../components/Projects/EmptyProjects';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getProjectsByRoadmapId } from '../../lib/project';
import {
generateArticleSchema,
generateFAQSchema,
} from '../../lib/jsonld-schema';
import { getOpenGraphImageUrl } from '../../lib/open-graph';
import { type RoadmapFrontmatter, getRoadmapIds } from '../../lib/roadmap';
import RoadmapNote from '../../components/RoadmapNote.astro';
import { RoadmapTitleQuestion } from '../../components/RoadmapTitleQuestion';
import ResourceProgressStats from '../../components/ResourceProgressStats.astro';
import AstroIcon from '../../components/AstroIcon.astro';
import CourseStep from '../../components/courses/CourseStep.astro';
import Milestone from '../../components/courses/Milestone.astro';
@ -95,24 +78,42 @@ const seoDescription = `Seeking ${nounTitle.toLowerCase()} courses to enhance yo
<div class='relative my-3 rounded-lg border bg-white px-12 py-8'>
<span class='absolute inset-y-0 left-[26.3px] w-[1px] bg-black'></span>
<div class='mb-8 flex flex-col gap-4 text-sm text-gray-500 leading-normal'>
<div
class='mb-8 flex flex-col gap-4 text-sm leading-normal text-gray-500'
>
<p>
Frontend development is a vast field with a lot of tools and
technologies. We have the <a class="font-medium underline underline-offset-2 text-black" href="/frontend">frontend roadmap</a>
which is filled with a lot of <span class="font-medium text-black">free and good</span> resources to help you learn. But sometimes it helps to have a minimalistic list of courses
and project recommendations to help you get started.
technologies. We have the <a
class='font-medium text-black underline underline-offset-2'
href='/frontend'>frontend roadmap</a
>
which is filled with a lot of <span class='font-medium text-black'
>free and good</span
> resources to help you learn. But sometimes it helps to have a minimalistic
list of courses and project recommendations to help you get started.
</p>
<p class="bg-yellow-100 text-yellow-900 rounded-md p-2">
Below are some of the best courses (paid) and projects to help you learn frontend development. These are handpicked and are a great way to get started.
<p class='rounded-md bg-yellow-100 p-2 text-yellow-900'>
Below are some of the best courses (paid) and projects to help you
learn frontend development. These are handpicked and are a great way
to get started.
</p>
<p>
Please note that these are paid courses curated from external platforms. We earn a small commission if you purchase the course using the links below. This helps us maintain the website and keep it free for everyone.
Please note that these are paid courses curated from external
platforms. We earn a small commission if you purchase the course
using the links below. This helps us maintain the website and keep
it free for everyone.
</p>
<p>
If you are looking for free resources, you can check out the <a class="font-medium underline underline-offset-2 text-black" href="/frontend">frontend roadmap</a>. Also, we have a <a class="font-medium underline underline-offset-2 text-black" href="/frontend/projects">list of projects</a> that you can work on to enhance your skills.
If you are looking for free resources, you can check out the <a
class='font-medium text-black underline underline-offset-2'
href='/frontend'>frontend roadmap</a
>. Also, we have a <a
class='font-medium text-black underline underline-offset-2'
href='/frontend/projects'>list of projects</a
> that you can work on to enhance your skills.
</p>
</div>

@ -4,7 +4,7 @@ import FAQs, { type FAQType } from '../../components/FAQs/FAQs.astro';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import { ShareIcons } from '../../components/ShareIcons/ShareIcons';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import { UserProgressModal } from '../../components/UserProgress/UserProgressModal';
import BaseLayout from '../../layouts/BaseLayout.astro';
@ -134,8 +134,11 @@ const projects = await getProjectsByRoadmapId(roadmapId);
<div class='container relative !max-w-[1000px]'>
<ShareIcons
resourceId={roadmapId}
resourceType='roadmap'
description={roadmapData.briefDescription}
pageUrl={`https://roadmap.sh/${roadmapId}`}
client:load
/>
{

@ -2,7 +2,7 @@
import BestPracticeHeader from '../../../components/BestPracticeHeader.astro';
import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../../components/MarkdownFile.astro';
import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro';
import { ShareIcons } from '../../../components/ShareIcons/ShareIcons';
import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
import UpcomingForm from '../../../components/UpcomingForm.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
@ -94,8 +94,11 @@ const ogImageUrl = getOpenGraphImageUrl({
!bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && (
<div class='container relative !max-w-[1000px]'>
<ShareIcons
resourceId={bestPracticeId}
resourceType='best-practice'
description={bestPracticeData.briefDescription}
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
client:load
/>
<TopicDetail

Loading…
Cancel
Save