Add SQL course landing page (#8127)

* wip: courses

* fix: update course sidebar

* wip

* fix: merge lessons

* wip

* wip: course footer

* wip

* fix: refactor layout

* fix: refactor

* feat: course progress

* fix: update current lesson store

* fix: refactor props

* wip

* wip

* feat: course certificate

* wip: course rating

* wip: course notes

* wip

* feat: implement course notes

* feat: make card clickable

* fix: add hover background

* fix: refactor course layout

* fix: resizeable

* fix: go back on save

* feat: delete confimation

* wip

* feat: chat UI

* fix: lesson complete guard issue

* wip: add public json files

* wip: course ai

* fix: loading card

* Fix failing dev without internet

* Light mode and UI changes

* Update UI

* Update course UI

* Add chapter page

* Improve sidebar of course

* Update navigation:

* Update quiz view

* Improve UI for quiz attempts

* Remove unnecessary console.logs

* Add progress loading skeletons

* Update UI

* Change background color of editor

* Fix line color not applied on editor

* UI updates

* feat: empty view

* feat: course ai token limit

* feat: handle auth users

* wip

* feat: course landing page

* wip

* Add first chapter of SQL

* Add introduction chapter

* Add quiz for introduction

* Add expressions in select

* Add content for DISTINCT

* Add filter with where

* Add lesson about limit and offset

* Add lesson for handling null values

* Add lesson about comments

* Add challenges

* Add challenge

* Add challenge

* Add challenge

* Add challenge 7

* Add creating tables lesson

* Add common data types lesson

* Add data types in sqlite

* Add more on data types lesson

* feat: course landing page

* Add more on numeric types

* Update

* Add lesson about temporal data types

* Add constraints

* Add primary keys chapter

* Add modifying tables

* Add dropping and truncating

* Rewrite for PostgreSQL

* Update numeric types to PostgreSQL

* Improve temporal data type content

* Improve temporal data type content

* Add setup for temporal data

* Improve challenges in SQL basics

* Update challenge names

* Add new challenges

* Add temporal validation challenge

* Add new constraint

* Add modifying tables query

* Removing table

* Add insert operations lesson

* Add updating data lesson

* Add delete operations

* Add inserting and updating challenges

* Add lesson for cleaning up data

* Update course title

* Add relation data lesson

* Add relationships and types

* Add relationships and types

* wip

* Add joins lesson

* Joins in queries

* Add inner join details

* Add join queries

* Add inner join details

* Add foreign key constraint lesson

* Update composite foreign keys

* Add lesson about foreign keys

* Add lesson about set operation queries

* Add lesson about set operation queries

* Add set operator challenges

* Add new challenge

* Add view lesson

* Add notes in views

* Add inactive customer challenge

* Add high value order challenge

* gst

* Add new challenges

* Add readers like you challenge

* Update inactive customer query

* Update inactive customer query

* Update inactive customer query

* Update inactive customer query

* Update inactive customer query

* add challenge for same price books

* Add aggregate functions introduction

* Add basic aggregation lesson

* Add basic aggregation lesson

* Add introduction quiz

* Add grouping lesson

* Add grouping gotchas

* Add grouping and filtering lesson

* Add note for lesson

* Add challenges for aggregate

* Update aggregate challenge

* Rearrange chapters

* Add scalar functions lessons

* Add numeric functions

* Add date functions

* Add conversion functions

* Add conversion functions

* Add logical functions chapter

* Add exercises

* Add new challenges

* Add monthly sales analysis

* Add subqueries and ctes

* Update

* Add correlated subqueries

* Add common table expressions

* Add common-table expressions

* Add example

* Add recursive CTEs

* Add subquery challenge

* Add latest category books challenge

* Add challenges

* Add bestseller rankings challenge

* Add new customer analysis

* Add daily sales report

* Improve queries

* Add introduction to window functions

* Add over and partition

* wip: billing page

* Add ranking functions

* Improve ranking functions

* Add order by

* Add window frames lesson

* Add window frames explanation

* Add challenges for window functions

* Add price range analysis challenge

* wip

* wip: course enroll

* fix: start learning

* wip

* wip

* Enrollment changes

* wip

* wip

* feat: mobile responsive

* Changelog banner refactor

* Update

* Header for course

* Header for what to expect

* UI color

* Table of contents

* Icons on chapters

* Change design for road to sql

* Add sql course page

* Add lesson content

* Update UI

* Expanded chapter row

* Add course page

* Refactor

* Add spotlight

* Improve features

* Add course features

* Add certificate note

* Zoom in on the image

* Update

* Add floating purchase

* Floating purchase indicatorg

* Add about section

* Update about section

* Add FAQ section

* Update UI

* Add purchase power parity

* Show purchasing power pricing

* Add course login popup

* Add course login popup

* Add account button

* Add trigger for course purchase

* Course purchase param

* Buy button changes

* Add faqs

* Add purchase trigger on reload

* Landing verification

* Make header responsive

* Make course page upper half responsive

* Full page is responsive

* Fix login height bug

* Responsiveness

* Implement login after checkout

* Remove unused code

* Update dependenciesg

* Update

* fix: refetch mount to false

* Remove unused code

* Remove unused code

* Remove unused code

* Remove unused code

* Remove unused code

* Remove unused code

* Remove unused

* Add quizzes to chapters

* Update course slug

* Update dependencies

* Add header for sql course

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
pull/8129/head
Kamran Ahmed 4 days ago committed by GitHub
parent 4696af9c6a
commit 28af19cd1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .astro/settings.json
  2. 3
      .env.example
  3. 3
      package.json
  4. 2690
      pnpm-lock.yaml
  5. 3
      src/components/AdvertiseForm.tsx
  6. 2
      src/components/AstroIcon.astro
  7. 143
      src/components/AuthenticationFlow/CourseLoginPopup.tsx
  8. 26
      src/components/AuthenticationFlow/GitHubButton.tsx
  9. 25
      src/components/AuthenticationFlow/GoogleButton.tsx
  10. 27
      src/components/AuthenticationFlow/LinkedInButton.tsx
  11. 8
      src/components/Navigation/Navigation.astro
  12. 28
      src/components/ReactIcons/RoadmapLogo.tsx
  13. 67
      src/components/SQLCourse/AccountButton.tsx
  14. 210
      src/components/SQLCourse/BuyButton.tsx
  15. 145
      src/components/SQLCourse/ChapterRow.tsx
  16. 24
      src/components/SQLCourse/CourseAuthor.tsx
  17. 94
      src/components/SQLCourse/CourseFeature.tsx
  18. 113
      src/components/SQLCourse/FAQSection.tsx
  19. 56
      src/components/SQLCourse/FloatingPurchase.tsx
  20. 415
      src/components/SQLCourse/SQLCoursePage.tsx
  21. 29
      src/components/SQLCourse/SectionHeader.tsx
  22. 57
      src/components/SQLCourse/Spotlight.tsx
  23. 1
      src/components/TopicDetail/TopicProgressButton.tsx
  24. 1
      src/env.d.ts
  25. 1
      src/layouts/BaseLayout.astro
  26. 2
      src/layouts/SkeletonLayout.astro
  27. 1
      src/lib/browser.ts
  28. 12
      src/lib/discord.ts
  29. 1
      src/lib/jwt.ts
  30. 12
      src/lib/number.ts
  31. 16
      src/pages/courses/sql.astro
  32. 4
      src/pages/devops/career-path.astro
  33. 2
      src/pages/get-started.astro
  34. 4
      src/pages/index.astro
  35. 2
      src/pages/roadmaps.astro
  36. 25
      src/queries/billing.ts
  37. 41
      src/queries/course-progress.ts
  38. 17
      src/styles/global.css
  39. 11
      tailwind.config.cjs

@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1737069970237
"lastUpdateCheck": 1737392387456
}
}

@ -1,3 +1,4 @@
PUBLIC_API_URL=https://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
PUBLIC_COURSE_APP_URL=http://localhost:5173

@ -67,10 +67,12 @@
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"sanitize-html": "^2.13.1",
"satori": "^0.11.2",
"satori-html": "^0.3.2",
"sharp": "^0.33.5",
"slugify": "^1.6.6",
"tiptap-markdown": "^0.8.10",
"tailwind-merge": "^2.5.3",
"tailwindcss": "^3.4.13",
"turndown": "^7.2.0",
@ -86,6 +88,7 @@
"@types/prismjs": "^1.26.4",
"@types/react-calendar-heatmap": "^1.6.7",
"@types/react-slick": "^0.23.13",
"@types/sanitize-html": "^2.13.0",
"@types/turndown": "^5.0.5",
"csv-parser": "^3.0.0",
"gh-pages": "^6.2.0",

File diff suppressed because it is too large Load Diff

@ -86,9 +86,6 @@ export function AdvertiseForm() {
pageProgressMessage.set('Please wait');
// Placeholder function to send data
console.log('Form data:', formData);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-advertise`,
formData,

@ -1,6 +1,5 @@
---
import { parse } from 'node-html-parser';
import type { Attributes } from 'node-html-parser/dist/nodes/html';
export interface Props {
icon: string;
@ -15,7 +14,6 @@ async function getSVG(name: string) {
eager: true,
});
if (!(filepath in files)) {
throw new Error(`${filepath} not found`);
}

@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
import { LinkedInButton } from './LinkedInButton';
import { EmailLoginForm } from './EmailLoginForm';
import { EmailSignupForm } from './EmailSignupForm';
type CourseLoginPopupProps = {
onClose: () => void;
checkoutAfterLogin?: boolean;
};
export const CHECKOUT_AFTER_LOGIN_KEY = 'checkoutAfterLogin';
export function CourseLoginPopup(props: CourseLoginPopupProps) {
const { onClose: parentOnClose, checkoutAfterLogin = true } = props;
const [isDisabled, setIsDisabled] = useState(false);
const [isUsingEmail, setIsUsingEmail] = useState(false);
const [emailNature, setEmailNature] = useState<'login' | 'signup' | null>(
null,
);
function onClose() {
// if user didn't login and closed the popup, we remove the checkoutAfterLogin flag
// so that login from other buttons on course page will trigger purchase
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
parentOnClose();
}
useEffect(() => {
localStorage.setItem(
CHECKOUT_AFTER_LOGIN_KEY,
checkoutAfterLogin ? '1' : '0',
);
}, [checkoutAfterLogin]);
if (emailNature) {
const emailHeader = (
<div className="mb-7 text-center">
<p className="mb-3.5 pt-2 text-2xl font-semibold leading-5 text-slate-900">
{emailNature === 'login'
? 'Login to your account'
: 'Create an account'}
</p>
<p className="mt-2 text-sm leading-4 text-slate-600">
Fill in the details below to continue
</p>
</div>
);
return (
<Modal onClose={onClose} bodyClassName="p-5 h-auto">
{emailHeader}
{emailNature === 'login' && (
<EmailLoginForm
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
)}
{emailNature === 'signup' && (
<EmailSignupForm
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
)}
<button
className="mt-2 w-full rounded-md border border-gray-400 py-2 text-center text-sm text-gray-600 hover:bg-gray-100"
onClick={() => setEmailNature(null)}
>
Back to Options
</button>
</Modal>
);
}
return (
<Modal onClose={onClose} bodyClassName="p-5 h-auto">
<div className="mb-7 text-center">
<p className="mb-3.5 pt-2 text-2xl font-semibold leading-5 text-slate-900">
Create or login to your account
</p>
<p className="mt-2 text-sm leading-4 text-slate-600">
Login or sign up for an account to start learning
</p>
</div>
<div className="flex w-full flex-col gap-2">
<GitHubButton
className="rounded-md border-gray-400 hover:bg-gray-100"
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
<GoogleButton
className="rounded-md border-gray-400 hover:bg-gray-100"
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
<LinkedInButton
className="rounded-md border-gray-400 hover:bg-gray-100"
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
/>
</div>
<div className="flex w-full items-center gap-4 py-6 text-sm text-gray-600">
<div className="h-px w-full bg-gray-200" />
OR
<div className="h-px w-full bg-gray-200" />
</div>
<div className="flex flex-row gap-2">
{!isUsingEmail && (
<button
className="flex-grow rounded-md border border-gray-400 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={() => setIsUsingEmail(true)}
>
Use your email address
</button>
)}
{isUsingEmail && (
<>
<button
className="flex-grow rounded-md border border-gray-400 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={() => setEmailNature('login')}
>
Already have an account
</button>
<button
className="flex-grow rounded-md border border-gray-400 px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={() => setEmailNature('signup')}
>
Create an account
</button>
</>
)}
</div>
</Modal>
);
}

@ -1,21 +1,23 @@
import { useEffect, useState } from 'react';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, 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';
type GitHubButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
className?: string;
};
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
const GITHUB_LAST_PAGE = 'githubLastPage';
export function GitHubButton(props: GitHubButtonProps) {
const { isDisabled, setIsDisabled } = props;
const { isDisabled, setIsDisabled, className } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
@ -74,6 +76,17 @@ export function GitHubButton(props: GitHubButtonProps) {
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
setAuthToken(response.token);
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();
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
window.location.href = redirectUrl;
})
.catch((err) => {
@ -120,7 +133,10 @@ export function GitHubButton(props: GitHubButtonProps) {
return (
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
className={cn(
'inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none hover:border-gray-400 hover:bg-gray-50 focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
disabled={isLoading || isDisabled}
onClick={handleClick}
>

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { cn } from '../../lib/classname.ts';
import { httpGet } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { COURSE_PURCHASE_PARAM, setAuthToken } from '../../lib/jwt';
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
import {
getStoredUtmParams,
triggerUtmRegistration,
@ -12,13 +13,14 @@ import {
type GoogleButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
className?: string;
};
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
const GOOGLE_LAST_PAGE = 'googleLastPage';
export function GoogleButton(props: GoogleButtonProps) {
const { isDisabled, setIsDisabled } = props;
const { isDisabled, setIsDisabled, className } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
@ -75,6 +77,16 @@ export function GoogleButton(props: GoogleButtonProps) {
redirectUrl = authRedirectUrl;
}
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();
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
setAuthToken(response.token);
@ -130,7 +142,10 @@ export function GoogleButton(props: GoogleButtonProps) {
return (
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
className={cn(
'inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none hover:border-gray-400 hover:bg-gray-50 focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
disabled={isLoading || isDisabled}
onClick={handleClick}
>

@ -1,21 +1,23 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
import { cn } from '../../lib/classname.ts';
import { httpGet } from '../../lib/http';
import { Spinner } from '../ReactIcons/Spinner.tsx';
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';
import { triggerUtmRegistration } from '../../lib/browser.ts';
type LinkedInButtonProps = {
isDisabled?: boolean;
setIsDisabled?: (isDisabled: boolean) => void;
className?: string;
};
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
export function LinkedInButton(props: LinkedInButtonProps) {
const { isDisabled, setIsDisabled } = props;
const { isDisabled, setIsDisabled, className } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
@ -70,6 +72,16 @@ export function LinkedInButton(props: LinkedInButtonProps) {
redirectUrl = authRedirectUrl;
}
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();
localStorage.removeItem(CHECKOUT_AFTER_LOGIN_KEY);
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
setAuthToken(response.token);
@ -125,14 +137,17 @@ export function LinkedInButton(props: LinkedInButtonProps) {
return (
<>
<button
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
className={cn(
'inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none hover:border-gray-400 focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
disabled={isLoading || isDisabled}
onClick={handleClick}
>
{isLoading ? (
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
) : (
<LinkedInIcon className={'h-[18px] w-[18px]'} />
<LinkedInIcon className={'h-[18px] w-[18px] text-blue-700'} />
)}
Continue with LinkedIn
</button>

@ -1,11 +1,9 @@
---
import { Menu } from 'lucide-react';
import { AccountStreak } from '../AccountStreak/AccountStreak';
import Icon from '../AstroIcon.astro';
import { NavigationDropdown } from '../NavigationDropdown';
import { AccountDropdown } from './AccountDropdown';
import NewIndicator from './NewIndicator.astro';
import { AccountStreak } from '../AccountStreak/AccountStreak';
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
import { AccountDropdown } from './AccountDropdown';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@ -48,7 +46,7 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu'
</a>
<a
href='/changelog'
class='group relative text-blue-300 hover:text-white hidden md:block ml-0.5'
class='group relative ml-0.5 hidden text-blue-300 hover:text-white md:block'
>
Changelog

@ -0,0 +1,28 @@
import type { SVGProps } from 'react';
type RoadmapLogoIconProps = SVGProps<SVGSVGElement> & {
color?: 'white' | 'black';
};
export function RoadmapLogoIcon(props: RoadmapLogoIconProps) {
const { color = 'white', ...rest } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="30"
height="30"
viewBox="0 0 283 283"
{...rest}
>
<path
fill={color === 'black' ? '#000' : '#fff'}
d="M0 39C0 17.46 17.46 0 39 0h205c21.539 0 39 17.46 39 39v205c0 21.539-17.461 39-39 39H39c-21.54 0-39-17.461-39-39V39Z"
/>
<path
fill={color === 'black' ? '#fff' : '#000'}
d="M121.215 210.72c-1.867.56-4.854 1.12-8.96 1.68-3.92.56-8.027.84-12.32.84-4.107 0-7.84-.28-11.2-.84-3.174-.56-5.88-1.68-8.12-3.36s-4.014-3.92-5.32-6.72c-1.12-2.987-1.68-6.813-1.68-11.48v-84c0-4.293.746-7.933 2.24-10.92 1.68-3.173 4.013-5.973 7-8.4s6.626-4.573 10.92-6.44c4.48-2.053 9.24-3.827 14.28-5.32a106.176 106.176 0 0 1 15.68-3.36 95.412 95.412 0 0 1 16.24-1.4c8.96 0 16.053 1.773 21.28 5.32 5.226 3.36 7.84 8.96 7.84 16.8 0 2.613-.374 5.227-1.12 7.84-.747 2.427-1.68 4.667-2.8 6.72a133.1 133.1 0 0 0-12.04.56c-4.107.373-8.12.933-12.04 1.68s-7.654 1.587-11.2 2.52c-3.36.747-6.254 1.68-8.68 2.8v95.48zm45.172-22.4c0-7.84 2.426-14.373 7.28-19.6s11.48-7.84 19.88-7.84 15.026 2.613 19.88 7.84 7.28 11.76 7.28 19.6-2.427 14.373-7.28 19.6-11.48 7.84-19.88 7.84-15.027-2.613-19.88-7.84-7.28-11.76-7.28-19.6z"
/>
</svg>
);
}

@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { isLoggedIn } from '../../lib/jwt';
import {
courseProgressOptions
} from '../../queries/course-progress';
import { queryClient } from '../../stores/query-client';
import { CourseLoginPopup } from '../AuthenticationFlow/CourseLoginPopup';
import { BuyButton, COURSE_SLUG } from './BuyButton';
export function AccountButton() {
const [isVisible, setIsVisible] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const { data: courseProgress, isLoading: isLoadingCourseProgress } = useQuery(
courseProgressOptions(COURSE_SLUG),
queryClient,
);
useEffect(() => {
setIsVisible(true);
}, []);
const buttonClasses =
'rounded-full px-5 py-2 text-base font-medium text-yellow-700 hover:text-yellow-500 transition-colors';
const hasEnrolled = !!courseProgress?.enrolledAt;
const loginModal = (
<CourseLoginPopup
checkoutAfterLogin={false}
onClose={() => {
setShowLoginModal(false);
}}
/>
);
if (!isVisible || isLoadingCourseProgress) {
return <button className={`${buttonClasses} opacity-0`}>...</button>;
}
if (!isLoggedIn()) {
return (
<>
<button
onClick={() => setShowLoginModal(true)}
className={`${buttonClasses} animate-fade-in`}
>
Login
</button>
{showLoginModal && loginModal}
</>
);
}
if (!hasEnrolled) {
return <BuyButton variant="top-nav" />;
}
return (
<a
href={`${import.meta.env.PUBLIC_COURSE_APP_URL}/sql`}
className={`${buttonClasses} animate-fade-in`}
>
Start Learning
</a>
);
}

@ -0,0 +1,210 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname';
import { COURSE_PURCHASE_PARAM, isLoggedIn } from '../../lib/jwt';
import { coursePriceOptions } from '../../queries/billing';
import { courseProgressOptions } from '../../queries/course-progress';
import { queryClient } from '../../stores/query-client';
import { CourseLoginPopup } from '../AuthenticationFlow/CourseLoginPopup';
import { useToast } from '../../hooks/use-toast';
import { httpPost } from '../../lib/query-http';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
export const COURSE_SLUG = 'master-sql';
type CreateCheckoutSessionBody = {
courseId: string;
success?: string;
cancel?: string;
};
type CreateCheckoutSessionResponse = {
checkoutUrl: string;
};
type BuyButtonProps = {
variant?: 'main' | 'floating' | 'top-nav';
};
export function BuyButton(props: BuyButtonProps) {
const { variant = 'main' } = props;
const [isLoginPopupOpen, setIsLoginPopupOpen] = useState(false);
const toast = useToast();
const { data: coursePricing, isLoading: isLoadingCourse } = useQuery(
coursePriceOptions({ courseSlug: COURSE_SLUG }),
queryClient,
);
const { data: courseProgress, isLoading: isLoadingCourseProgress } = useQuery(
courseProgressOptions(COURSE_SLUG),
queryClient,
);
const {
mutate: createCheckoutSession,
isPending: isCreatingCheckoutSession,
} = useMutation(
{
mutationFn: (body: CreateCheckoutSessionBody) => {
return httpPost<CreateCheckoutSessionResponse>(
'/v1-create-checkout-session',
body,
);
},
onMutate: () => {
toast.loading('Creating checkout session...');
},
onSuccess: (data) => {
window.location.href = data.checkoutUrl;
},
onError: (error) => {
console.error(error);
toast.error(error?.message || 'Failed to create checkout session');
},
},
queryClient,
);
useEffect(() => {
const urlParams = getUrlParams();
const shouldTriggerPurchase = urlParams[COURSE_PURCHASE_PARAM] === '1';
if (shouldTriggerPurchase) {
deleteUrlParam(COURSE_PURCHASE_PARAM);
initPurchase();
}
}, []);
const isLoadingPricing =
isLoadingCourse || !coursePricing || isLoadingCourseProgress;
const isAlreadyEnrolled = !!courseProgress?.enrolledAt;
function initPurchase() {
if (!isLoggedIn()) {
return;
}
createCheckoutSession({
courseId: COURSE_SLUG,
success: `/courses/sql?e=1`,
cancel: `/courses/sql`,
});
}
function onBuyClick() {
if (!isLoggedIn()) {
setIsLoginPopupOpen(true);
return;
}
const hasEnrolled = !!courseProgress?.enrolledAt;
if (hasEnrolled) {
window.location.href = `${import.meta.env.PUBLIC_COURSE_APP_URL}/sql`;
return;
}
initPurchase();
}
const courseLoginPopup = isLoginPopupOpen && (
<CourseLoginPopup onClose={() => setIsLoginPopupOpen(false)} />
);
if (variant === 'main') {
return (
<div className="relative flex w-full flex-col items-center gap-2 md:w-auto">
{courseLoginPopup}
<button
onClick={onBuyClick}
disabled={isLoadingPricing}
className={cn(
'group relative inline-flex w-full min-w-[235px] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-r from-yellow-500 to-yellow-300 px-8 py-3 text-base font-semibold text-black transition-all duration-300 ease-out hover:scale-[1.02] hover:shadow-[0_0_30px_rgba(234,179,8,0.4)] focus:outline-none active:ring-0 md:w-auto md:rounded-full md:text-lg',
(isLoadingPricing || isCreatingCheckoutSession) &&
'striped-loader-yellow pointer-events-none scale-105 bg-yellow-500',
)}
>
{isLoadingPricing ? (
<span className="relative flex items-center gap-2">&nbsp;</span>
) : isAlreadyEnrolled ? (
<span className="relative flex items-center gap-2">
Start Learning
</span>
) : (
<span className="relative flex items-center gap-2">
{coursePricing?.isEligibleForDiscount && coursePricing?.flag} Buy
now for{' '}
{coursePricing?.isEligibleForDiscount ? (
<span className="flex items-center gap-2">
<span className="hidden text-base line-through opacity-75 md:inline">
${coursePricing?.fullPrice}
</span>
<span className="text-base md:text-xl">
${coursePricing?.regionalPrice}
</span>
</span>
) : (
<span>${coursePricing?.regionalPrice}</span>
)}
<ArrowRightIcon className="h-5 w-5 transition-transform duration-300 ease-out group-hover:translate-x-1" />
</span>
)}
</button>
{!isLoadingPricing &&
!isAlreadyEnrolled &&
coursePricing?.isEligibleForDiscount && (
<span className="absolute top-full translate-y-2.5 text-sm text-yellow-400">
{coursePricing.regionalDiscountPercentage}% regional discount
applied
</span>
)}
</div>
);
}
if (variant === 'top-nav') {
return (
<button
onClick={onBuyClick}
disabled={isLoadingPricing}
className={`animate-fade-in rounded-full px-5 py-2 text-base font-medium text-yellow-700 transition-colors hover:text-yellow-500`}
>
Purchase Course
</button>
);
}
return (
<div className="relative flex flex-col items-center gap-2">
{courseLoginPopup}
<button
onClick={onBuyClick}
disabled={isLoadingPricing}
className={cn(
'group relative inline-flex min-w-[220px] items-center justify-center overflow-hidden rounded-full bg-gradient-to-r from-yellow-500 to-yellow-300 px-8 py-2 font-medium text-black transition-all duration-300 ease-out hover:scale-[1.02] hover:shadow-[0_0_30px_rgba(234,179,8,0.4)] focus:outline-none',
(isLoadingPricing || isCreatingCheckoutSession) &&
'striped-loader-yellow pointer-events-none bg-yellow-500',
)}
>
{isLoadingPricing ? (
<span className="relative flex items-center gap-2">&nbsp;</span>
) : isAlreadyEnrolled ? (
<span className="relative flex items-center gap-2">
Start Learning
</span>
) : (
<span className="relative flex items-center gap-2">
{coursePricing?.flag} Buy Now ${coursePricing?.regionalPrice}
<ArrowRightIcon className="h-5 w-5 transition-transform duration-300 ease-out group-hover:translate-x-1" />
</span>
)}
</button>
{!isAlreadyEnrolled && coursePricing?.isEligibleForDiscount && (
<span className="top-full text-sm text-yellow-400">
{coursePricing.regionalDiscountPercentage}% regional discount applied
</span>
)}
</div>
);
}

@ -0,0 +1,145 @@
import { ChevronDown, BookIcon, CodeIcon, FileQuestion, MessageCircleQuestionIcon, CircleDot } from 'lucide-react';
import { cn } from '../../lib/classname';
import { useEffect, useState } from 'react';
type ChapterRowProps = {
counter: number;
icon: React.ReactNode;
title: string;
description: string;
lessonCount: number;
challengeCount: number;
isExpandable?: boolean;
className?: string;
lessons?: { title: string; type: 'lesson' | 'challenge' | 'quiz' }[];
};
export function ChapterRow(props: ChapterRowProps) {
const {
counter,
icon,
title,
description,
lessonCount,
challengeCount,
isExpandable = true,
className,
lessons = [],
} = props;
const [isExpanded, setIsExpanded] = useState(true);
const regularLessons = lessons.filter((l) => l.type === 'lesson');
const challenges = lessons.filter((l) =>
['challenge', 'quiz'].includes(l.type),
);
useEffect(() => {
const isMobile = window.innerWidth < 768;
setIsExpanded(!isMobile);
}, []);
return (
<div
className={cn('group relative select-none overflow-hidden', className)}
>
<div
role="button"
onClick={() => isExpandable && setIsExpanded(!isExpanded)}
className={cn(
'relative rounded-xl border border-zinc-800 bg-zinc-800 p-6',
'bg-gradient-to-br from-zinc-900/90 via-zinc-900/70 to-zinc-900/50',
!isExpanded &&
'hover:bg-gradient-to-br hover:from-zinc-900/95 hover:via-zinc-900/80 hover:to-zinc-900/60',
!isExpanded &&
'hover:cursor-pointer hover:shadow-[0_0_30px_rgba(0,0,0,0.2)]',
isExpanded && 'rounded-b-none border-b-0',
)}
>
<div className="flex items-start gap-4">
<div className="hidden flex-shrink-0 md:block">
<div className="rounded-full bg-yellow-500/10 p-3">{icon}</div>
</div>
<div className="flex-grow">
<h3 className="text-xl font-semibold tracking-wide text-white">
<span className="inline text-gray-500 md:hidden">
{counter}.{' '}
</span>
{title}
</h3>
<p className="mt-2 text-zinc-400">{description}</p>
<div className="mt-4 flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span>{lessonCount} Lessons</span>
</div>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span>{challengeCount} Challenges</span>
</div>
</div>
</div>
{isExpandable && (
<div className="flex-shrink-0 rounded-full bg-zinc-800/80 p-2 text-zinc-400 group-hover:bg-zinc-800 group-hover:text-yellow-500">
<ChevronDown
className={cn(
'h-4 w-4 transition-transform',
isExpanded ? 'rotate-180' : '',
)}
/>
</div>
)}
</div>
</div>
{isExpanded && (
<div className="rounded-b-xl border border-t-0 border-zinc-800 bg-gradient-to-br from-zinc-900/50 via-zinc-900/30 to-zinc-900/20">
<div className="grid grid-cols-1 divide-zinc-800 md:grid-cols-2 md:divide-x">
{regularLessons.length > 0 && (
<div className="p-6 pb-0 md:pb-6">
<h4 className="mb-4 text-sm font-medium uppercase tracking-wider text-zinc-500">
Lessons
</h4>
<div className="space-y-3">
{regularLessons.map((lesson, index) => (
<div
key={index}
className="flex items-center gap-3 text-zinc-400 hover:text-yellow-500"
>
<BookIcon className="h-4 w-4" />
<span>{lesson.title}</span>
</div>
))}
</div>
</div>
)}
{challenges.length > 0 && (
<div className="p-6">
<h4 className="mb-4 text-sm font-medium uppercase tracking-wider text-zinc-500">
Exercises
</h4>
<div className="space-y-3">
{challenges.map((challenge, index) => (
<div
key={index}
className="flex items-center gap-3 text-zinc-400 hover:text-yellow-500"
>
{challenge.type === 'challenge' ? (
<CodeIcon className="h-4 w-4" />
) : (
<CircleDot className="h-4 w-4" />
)}
<span>{challenge.title}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

@ -0,0 +1,24 @@
export function CourseAuthor() {
return (
<div className="mt-8 w-full max-w-3xl space-y-4">
<div className="flex flex-row items-center gap-5">
<img
src="https://github.com/kamranahmedse.png"
className="size-12 rounded-full bg-yellow-500/10 md:size-16"
/>
<a
href="https://twitter.com/kamrify"
target="_blank"
className="flex flex-col"
>
<span className="text-lg font-medium text-zinc-200 md:text-2xl">
Kamran Ahmed
</span>
<span className="text-sm text-zinc-500 md:text-lg">
Software Engineer
</span>
</a>
</div>
</div>
);
}

@ -0,0 +1,94 @@
import { MinusIcon, PlusIcon, type LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname';
type CourseFeatureProps = {
title: string;
icon: LucideIcon;
description: string;
imgUrl?: string;
};
export function CourseFeature(props: CourseFeatureProps) {
const { title, icon: Icon, description, imgUrl } = props;
const [isExpanded, setIsExpanded] = useState(false);
const [isZoomed, setIsZoomed] = useState(false);
useEffect(() => {
function onScroll() {
if (isZoomed) {
setIsZoomed(false);
}
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [isZoomed]);
return (
<>
{isZoomed && (
<div
onClick={() => {
setIsZoomed(false);
setIsExpanded(false);
}}
className="fixed inset-0 z-[999] flex cursor-zoom-out items-center justify-center bg-black bg-opacity-75"
>
<img
src={imgUrl}
alt={title}
className="max-h-[50%] max-w-[90%] rounded-xl object-contain"
/>
</div>
)}
<div
className={cn(
'fixed inset-0 z-10 bg-black/70 opacity-100 transition-opacity duration-200 ease-out',
{
'pointer-events-none opacity-0': !isExpanded,
},
)}
onClick={() => setIsExpanded(false)}
></div>
<div className="relative">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'z-20 flex w-full items-center rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-left transition-colors duration-200 ease-out hover:bg-zinc-800/40',
{
'relative bg-zinc-800 hover:bg-zinc-800': isExpanded,
},
)}
>
<span className="flex flex-grow items-center space-x-3">
<Icon />
<span>{title}</span>
</span>
{isExpanded ? (
<MinusIcon className="h-4 w-4" />
) : (
<PlusIcon className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="absolute left-0 top-full z-20 translate-y-2 rounded-lg border border-zinc-800 bg-zinc-800 p-4">
<p>{description}</p>
{imgUrl && (
<img
onClick={() => {
setIsZoomed(true);
setIsExpanded(false);
}}
src={imgUrl}
alt={title}
className="mt-4 h-auto pointer-events-none md:pointer-events-auto w-full cursor-zoom-in rounded-lg object-right-top"
/>
)}
</div>
)}
</div>
</>
);
}

@ -0,0 +1,113 @@
import { ChevronDownIcon } from 'lucide-react';
import { useState } from 'react';
import { SectionHeader } from './SectionHeader';
type FAQItem = {
question: string;
answer: string;
};
function FAQRow({ question, answer }: FAQItem) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex w-full items-center justify-between p-4 md:p-6 text-left gap-2"
>
<h3 className="text-lg md:text-xl text-balance font-normal text-white">{question}</h3>
<ChevronDownIcon
className={`h-5 w-5 text-zinc-400 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{isExpanded && (
<div className="border-t border-zinc-800 p-6 pt-4 text-base md:text-lg leading-relaxed">
<p>{answer}</p>
</div>
)}
</div>
);
}
export function FAQSection() {
const faqs: FAQItem[] = [
{
question: 'What is the format of the course?',
answer:
'The course is written in textual format. There are several chapters; each chapter has a set of lessons, followed by a set of practice problems and quizzes. You can learn at your own pace and revisit the content anytime.',
},
{
question: 'What prerequisites do I need for this course?',
answer:
'No prior SQL knowledge is required. The course starts from the basics and gradually progresses to advanced topics.',
},
{
question: 'Do I need to have a local database to follow the course?',
answer:
'No, we have an integrated coding playground, populated with a sample databases depending on the lesson, that you can use to follow the course. You can also use your own database if you have one.',
},
{
question: 'How long do I have access to the course?',
answer:
'You get lifetime access to the course including all future updates. Once you purchase, you can learn at your own pace and revisit the content anytime.',
},
{
question: 'What kind of support is available?',
answer:
'You get access to an AI tutor within the course that can help you with queries 24/7. Additionally, you can use the community forums to discuss problems and get help from other learners.',
},
{
question: 'Will I get a certificate upon completion?',
answer:
"Yes, upon completing the course and its challenges, you'll receive a certificate of completion that you can share with employers or add to your LinkedIn profile.",
},
{
question: 'Can I use this for job interviews?',
answer:
'Absolutely! The course covers common SQL interview topics and includes practical challenges similar to what you might face in technical interviews. The hands-on experience will prepare you well for real-world scenarios.',
},
{
question: "What if I don't like the course?",
answer:
'I will refund your purchase within 7 days of the purchase. No questions asked. However, I would love to hear your feedback so that I can improve the course. Send me an email at kamran@roadmap.sh',
},
{
question: 'I already know SQL, can I still take this course?',
answer:
'Yes! The course starts from the basics and gradually progresses to advanced topics. You can skip the chapters that you already know and focus on the ones that you need.',
},
{
question: 'Do you offer any team licenses?',
answer: 'Yes, please contact me at kamran@roadmap.sh',
},
{
question: 'How can I gift this course to someone?',
answer:
'Please contact me at kamran@roadmap.sh and I will be happy to help you.',
},
{
question: 'What if I have a question that is not answered here?',
answer:
'Please contact me at kamran@roadmap.sh and I will be happy to help you.',
},
];
return (
<>
<SectionHeader
title="Frequently Asked Questions"
description="Find answers to common questions about the course below."
className="mt-10 md:mt-24"
/>
<div className="mt-6 md:mt-8 w-full max-w-3xl space-y-2 md:space-y-6">
{faqs.map((faq, index) => (
<FAQRow key={index} {...faq} />
))}
</div>
</>
);
}

@ -0,0 +1,56 @@
import { ArrowRightIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname';
import { BuyButton } from './BuyButton';
export function FloatingPurchase() {
const [isHidden, setIsHidden] = useState(true);
useEffect(() => {
function onScroll() {
setIsHidden(window.scrollY < 400);
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 z-[5] flex items-center justify-center transition-all duration-200 ease-out',
{
'pointer-events-none -bottom-10 opacity-0': isHidden,
},
)}
>
{/* Desktop version */}
<div className="hidden mb-5 md:flex w-full max-w-[800px] items-center justify-between rounded-2xl bg-yellow-950 p-5 shadow-lg ring-1 ring-yellow-500/40">
<div className="flex flex-col">
<h2 className="mb-1 text-xl font-medium text-white">
Go from Zero to Hero in SQL
</h2>
<p className="text-sm text-zinc-400">
Get instant access to the course and start learning today
</p>
</div>
<BuyButton variant="floating" />
</div>
{/* Mobile version */}
<div className="flex md:hidden w-full flex-col bg-yellow-950 px-4 pt-3 pb-4 shadow-lg ring-1 ring-yellow-500/40">
<div className="flex flex-col items-center text-center mb-3">
<h2 className="text-lg font-medium text-white">
Master SQL Today
</h2>
<p className="text-xs text-zinc-400">
Get instant lifetime access
</p>
</div>
<BuyButton variant="floating" />
</div>
</div>
);
}

@ -0,0 +1,415 @@
import {
ArrowRightIcon,
ArrowUpDownIcon,
BarChartIcon,
BrainIcon,
ClipboardIcon,
CodeIcon,
DatabaseIcon,
Eye,
FileCheckIcon,
FileQuestionIcon,
GitBranchIcon,
GitMergeIcon,
LayersIcon,
TableIcon,
WrenchIcon,
} from 'lucide-react';
import { ChapterRow } from './ChapterRow';
import { CourseFeature } from './CourseFeature';
import { SectionHeader } from './SectionHeader';
import { Spotlight } from './Spotlight';
import { FloatingPurchase } from './FloatingPurchase';
import { CourseAuthor } from './CourseAuthor';
import { FAQSection } from './FAQSection';
import { BuyButton } from './BuyButton';
import { AccountButton } from './AccountButton';
import { RoadmapLogoIcon } from '../ReactIcons/RoadmapLogo';
type ChapterData = {
icon: React.ReactNode;
title: string;
description: string;
lessonCount: number;
challengeCount: number;
lessons: { title: string; type: 'lesson' | 'challenge' | 'quiz' }[];
};
export function SQLCoursePage() {
const chapters: ChapterData[] = [
{
icon: <DatabaseIcon className="h-6 w-6 text-yellow-500" />,
title: 'Introduction',
description:
'Get comfortable with database concepts and SQL fundamentals.',
lessonCount: 4,
challengeCount: 1,
lessons: [
{ title: 'Basics of Databases', type: 'lesson' },
{ title: 'What is SQL?', type: 'lesson' },
{ title: 'Types of Queries', type: 'lesson' },
{ title: 'Next Steps', type: 'lesson' },
{ title: 'Introduction Quiz', type: 'challenge' },
],
},
{
icon: <TableIcon className="h-6 w-6 text-yellow-500" />,
title: 'SQL Basics',
description: 'Master the essential SQL query operations and syntax.',
lessonCount: 9,
challengeCount: 7,
lessons: [
{ title: 'SELECT Fundamentals', type: 'lesson' },
{ title: 'Aliases and Constants', type: 'lesson' },
{ title: 'Expressions in SELECT', type: 'lesson' },
{ title: 'Selecting DISTINCT Values', type: 'lesson' },
{ title: 'Filtering with WHERE', type: 'lesson' },
{ title: 'Sorting with ORDER BY', type: 'lesson' },
{ title: 'Limiting Results with LIMIT', type: 'lesson' },
{ title: 'Handling NULL Values', type: 'lesson' },
{ title: 'Comments', type: 'lesson' },
{ title: 'Basic Queries Quiz', type: 'quiz' },
{ title: 'Projection Challenge', type: 'challenge' },
{ title: 'Select Expression', type: 'challenge' },
{ title: 'Select Unique', type: 'challenge' },
{ title: 'Logical Operators', type: 'challenge' },
{ title: 'Sorting Challenge', type: 'challenge' },
{ title: 'Sorting and Limiting', type: 'challenge' },
{ title: 'Sorting and Filtering', type: 'challenge' },
],
},
{
icon: <CodeIcon className="h-6 w-6 text-yellow-500" />,
title: 'Manipulating Data',
description: 'Learn how to modify and manipulate data in your database.',
lessonCount: 3,
challengeCount: 3,
lessons: [
{ title: 'INSERT Operations', type: 'lesson' },
{ title: 'UPDATE Operations', type: 'lesson' },
{ title: 'DELETE Operations', type: 'lesson' },
{ title: 'Data Manipulation Quiz', type: 'quiz' },
{ title: 'Inserting Customers', type: 'challenge' },
{ title: 'Updating Bookstore', type: 'challenge' },
{ title: 'Deleting Books', type: 'challenge' },
],
},
{
icon: <LayersIcon className="h-6 w-6 text-yellow-500" />,
title: 'Defining Tables',
description: 'Master database schema design and table management.',
lessonCount: 9,
challengeCount: 7,
lessons: [
{ title: 'Creating Tables', type: 'lesson' },
{ title: 'Data Types in SQLite', type: 'lesson' },
{ title: 'Common Data Types', type: 'lesson' },
{ title: 'More on Numeric Types', type: 'lesson' },
{ title: 'Temporal Data Types', type: 'lesson' },
{ title: 'CHECK Constraints', type: 'lesson' },
{ title: 'Primary Key Constraint', type: 'lesson' },
{ title: 'Modifying Tables', type: 'lesson' },
{ title: 'Dropping and Truncating', type: 'lesson' },
{ title: 'Defining Tables Quiz', type: 'quiz' },
{ title: 'Simple Table Creation', type: 'challenge' },
{ title: 'Data Types Challenge', type: 'challenge' },
{ title: 'Constraints Challenge', type: 'challenge' },
{ title: 'Temporal Validation', type: 'challenge' },
{ title: 'Sales Data Analysis', type: 'challenge' },
{ title: 'Modifying Tables', type: 'challenge' },
{ title: 'Removing Table Data', type: 'challenge' },
],
},
{
icon: <GitMergeIcon className="h-6 w-6 text-yellow-500" />,
title: 'Multi-Table Queries',
description:
'Learn to work with multiple tables using JOINs and relationships.',
lessonCount: 7,
challengeCount: 10,
lessons: [
{ title: 'More on Relational Data', type: 'lesson' },
{ title: 'Relationships and Types', type: 'lesson' },
{ title: 'JOINs in Queries', type: 'lesson' },
{ title: 'Self Joins and Usecases', type: 'lesson' },
{ title: 'Foreign Key Constraint', type: 'lesson' },
{ title: 'Set Operator Queries', type: 'lesson' },
{ title: 'Views and Virtual Tables', type: 'lesson' },
{ title: 'Multi-Table Queries Quiz', type: 'quiz' },
{ title: 'Inactive Customers', type: 'challenge' },
{ title: 'Recent 3 Orders', type: 'challenge' },
{ title: 'High Value Orders', type: 'challenge' },
{ title: 'Specific Book Customers', type: 'challenge' },
{ title: 'Referred Customers', type: 'challenge' },
{ title: 'Readers Like You', type: 'challenge' },
{ title: 'Same Price Books', type: 'challenge' },
{ title: 'Multi-Section Authors', type: 'challenge' },
{ title: 'Expensive Books', type: 'challenge' },
{ title: 'Trending Tech Books', type: 'challenge' },
],
},
{
icon: <WrenchIcon className="h-6 w-6 text-yellow-500" />,
title: 'Aggregate Functions',
description:
"Analyze and summarize data using SQL's powerful aggregation features.",
lessonCount: 4,
challengeCount: 10,
lessons: [
{ title: 'What is Aggregation?', type: 'lesson' },
{ title: 'Basic Aggregation', type: 'lesson' },
{ title: 'Grouping Data', type: 'lesson' },
{ title: 'Grouping and Filtering', type: 'lesson' },
{ title: 'Aggregate Queries Quiz', type: 'quiz' },
{ title: 'Book Sales Summary', type: 'challenge' },
{ title: 'Category Insights', type: 'challenge' },
{ title: 'Author Tier Analysis', type: 'challenge' },
{ title: 'Author Book Stats', type: 'challenge' },
{ title: 'Daily Sales Report', type: 'challenge' },
{ title: 'Publisher Stats', type: 'challenge' },
{ title: 'High Value Publishers', type: 'challenge' },
{ title: 'Premium Authors', type: 'challenge' },
{ title: 'Sales Analysis', type: 'challenge' },
{ title: 'Employee Performance', type: 'challenge' },
],
},
{
icon: <BarChartIcon className="h-6 w-6 text-yellow-500" />,
title: 'Scalar Functions',
description:
'Master built-in functions for data transformation and manipulation.',
lessonCount: 6,
challengeCount: 5,
lessons: [
{ title: 'What are they?', type: 'lesson' },
{ title: 'String Functions', type: 'lesson' },
{ title: 'Numeric Functions', type: 'lesson' },
{ title: 'Date Functions', type: 'lesson' },
{ title: 'Conversion Functions', type: 'lesson' },
{ title: 'Logical Functions', type: 'lesson' },
{ title: 'Scalar Functions Quiz', type: 'quiz' },
{ title: 'Customer Contact List', type: 'challenge' },
{ title: 'Membership Duration', type: 'challenge' },
{ title: 'Book Performance', type: 'challenge' },
{ title: 'Book Categories', type: 'challenge' },
{ title: 'Monthly Sales Analysis', type: 'challenge' },
],
},
{
icon: <GitBranchIcon className="h-6 w-6 text-yellow-500" />,
title: 'Subqueries and CTEs',
description:
'Write complex queries using subqueries and common table expressions.',
lessonCount: 4,
challengeCount: 6,
lessons: [
{ title: 'What are Subqueries?', type: 'lesson' },
{ title: 'Correlated Subqueries', type: 'lesson' },
{ title: 'Common Table Expressions', type: 'lesson' },
{ title: 'Recursive CTEs', type: 'lesson' },
{ title: 'Subqueries Quiz', type: 'quiz' },
{ title: 'Books Above Average', type: 'challenge' },
{ title: 'Latest Category Books', type: 'challenge' },
{ title: 'Low Stock by Category', type: 'challenge' },
{ title: 'Bestseller Rankings', type: 'challenge' },
{ title: 'New Customer Analysis', type: 'challenge' },
{ title: 'Daily Sales Report', type: 'challenge' },
],
},
{
icon: <ArrowUpDownIcon className="h-6 w-6 text-yellow-500" />,
title: 'Window Functions',
description:
'Advanced analytics and calculations using window functions.',
lessonCount: 5,
challengeCount: 7,
lessons: [
{ title: 'What are they?', type: 'lesson' },
{ title: 'OVER and PARTITION BY', type: 'lesson' },
{ title: 'Use of ORDER BY', type: 'lesson' },
{ title: 'Ranking Functions', type: 'lesson' },
{ title: 'Window Frames', type: 'lesson' },
{ title: 'Window Functions Quiz', type: 'quiz' },
{ title: 'Basic Sales Metrics', type: 'challenge' },
{ title: 'Bestseller Comparison', type: 'challenge' },
{ title: 'Author Category Sales', type: 'challenge' },
{ title: 'Top Authors', type: 'challenge' },
{ title: 'Price Tier Rankings', type: 'challenge' },
{ title: 'Month-over-Month Sales', type: 'challenge' },
{ title: 'Price Range Analysis', type: 'challenge' },
],
},
];
return (
<div className="flex flex-grow flex-col items-center bg-gradient-to-b from-zinc-900 to-zinc-950 px-4 pb-52 pt-3 text-zinc-400 md:px-10 md:pt-8">
<div className="flex w-full items-center justify-between">
<a
href="https://roadmap.sh"
target="_blank"
className="opacity-20 transition-opacity hover:opacity-100"
>
<RoadmapLogoIcon />
</a>
<AccountButton />
</div>
<div className="relative mt-7 max-w-3xl text-left md:mt-20 md:text-center">
<Spotlight className="left-[-170px] top-[-200px]" fill="#EAB308" />
<div className="inline-block rounded-full bg-yellow-500/10 px-4 py-1.5 text-base text-yellow-500 md:px-6 md:py-2 md:text-lg">
<span className="hidden sm:block">
Complete Course to Master Practical SQL
</span>
<span className="block sm:hidden">Complete SQL Course</span>
</div>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-white md:mt-8 md:text-7xl">
Master SQL <span className="hidden min-[384px]:inline">Queries</span>
<div className="mt-2.5 bg-gradient-to-r from-yellow-500 to-yellow-300 bg-clip-text text-transparent md:text-6xl lg:text-7xl">
From Basic to Advanced
</div>
</h1>
<p className="mx-auto my-5 max-w-2xl text-xl text-zinc-300 md:my-12 lg:text-2xl">
A structured course to master database querying - perfect for
developers, data analysts, and anyone working with data.
</p>
<div className="hidden flex-row items-center justify-center gap-5 md:flex">
<div className="flex flex-row items-center gap-2">
<ClipboardIcon className="size-6 text-yellow-600" />
<span>55+ Lessons</span>
</div>
<div className="flex flex-row items-center gap-2">
<FileQuestionIcon className="size-6 text-yellow-600" />
<span>100+ Challenges</span>
</div>
<div className="flex flex-row items-center gap-2">
<CodeIcon className="size-6 text-yellow-600" />
<span>Integrated IDE</span>
</div>
<div className="flex flex-row items-center gap-2">
<BrainIcon className="size-6 text-yellow-600" />
<span>AI Tutor</span>
</div>
</div>
<div className="mt-7 flex justify-start md:mt-12 md:justify-center">
<BuyButton variant="main" />
</div>
</div>
<SectionHeader
title="Not your average SQL course"
description="Built around a text-based interactive approach and packed with practical challenges, this course stands out with features that make it truly unique."
className="mt-16 md:mt-32"
/>
<div className="mx-auto mt-6 w-full max-w-5xl md:mt-10">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-4 lg:grid-cols-3">
<CourseFeature
title="Textual Course"
icon={Eye}
imgUrl="https://assets.roadmap.sh/guest/textual-course.png"
description="Unlike video-based courses where you have to learn at the pace of the instructor, this course is text-based, allowing you to learn at your own pace."
/>
<CourseFeature
title="Coding Environment"
icon={CodeIcon}
imgUrl="https://assets.roadmap.sh/guest/coding-environment.png"
description="With the integrated IDE, you can practice your SQL queries in real-time, getting instant feedback on your results."
/>
<CourseFeature
title="Practical Challenges"
icon={FileQuestionIcon}
imgUrl="https://assets.roadmap.sh/guest/coding-challenges.png"
description="The course is packed with practical challenges and quizzes, allowing you to test your knowledge and skills."
/>
<CourseFeature
title="AI Instructor"
icon={BrainIcon}
description="Powerful AI tutor to help you with your queries, provide additional explanations and help if you get stuck."
imgUrl="https://assets.roadmap.sh/guest/ai-integration.png"
/>
<CourseFeature
title="Take Notes"
icon={ClipboardIcon}
description="The course allows you to take notes, where you can write down your thoughts and ideas. You can visit them later to review your progress."
imgUrl="https://assets.roadmap.sh/guest/course-notes.png"
/>
<CourseFeature
title="Completion Certificate"
icon={FileCheckIcon}
imgUrl="https://assets.roadmap.sh/guest/course-certificate.jpg"
description="The course provides a completion certificate, which you can share with your potential employers."
/>
</div>
</div>
<div className="mt-7 w-full max-w-3xl text-left md:mt-9">
<p className="text-lg leading-normal md:text-xl">
Oh, and you get the{' '}
<span className="bg-gradient-to-r from-yellow-500 to-yellow-300 bg-clip-text text-transparent">
lifetime access
</span>{' '}
to the course including all the future updates. Also, there is a
certificate of completion which you can share with your potential
employers.
</p>
</div>
<SectionHeader
title="Course Overview"
description="The course is designed to help you go from SQL beginner to expert
through hands-on practice with real-world scenarios, mastering
everything from basic to complex queries."
className="mt-8 md:mt-24"
/>
<div className="mt-8 w-full max-w-3xl space-y-4 md:mt-12">
{chapters.map((chapter, index) => (
<ChapterRow key={index} counter={index + 1} {...chapter} />
))}
</div>
<SectionHeader
title="About the Author"
className="mt-12 md:mt-24"
description={
<div className="mt-2 md:mt-4 flex flex-col gap-4 md:gap-6 text-lg md:text-xl leading-[1.52]">
<p>
I am Kamran Ahmed, an engineering leader with over a decade of
experience in the tech industry. Throughout my career I have built
and scaled software systems, architected complex data systems, and
worked with large amounts of data to create efficient solutions.
</p>
<p>
I am also the creator of{' '}
<a
href="https://roadmap.sh"
target="_blank"
className="text-yellow-400"
>
roadmap.sh
</a>
, a platform trusted by millions of developers to guide their
learning journeys. I love to simplify complex topics and make
learning practical and accessible.
</p>
<p>
In this course, I will share everything I have learned about SQL
from the basics to advanced concepts in a way that is easy to
understand and apply. Whether you are just starting or looking to
sharpen your skills, you are in the right place.
</p>
</div>
}
/>
<CourseAuthor />
<FAQSection />
<FloatingPurchase />
</div>
);
}

@ -0,0 +1,29 @@
import { cn } from '../../lib/classname';
type SectionHeaderProps = {
title: string;
description: string | React.ReactNode;
className?: string;
};
export function SectionHeader(props: SectionHeaderProps) {
const { title, description, className } = props;
return (
<div className={cn('mx-auto w-full mt-24 max-w-3xl', className)}>
<div className="relative w-full">
<div className="flex items-center gap-6">
<div className="inline-flex items-center rounded-xl ">
<span className="text-2xl md:text-3xl font-medium text-zinc-200">{title}</span>
</div>
<div className="h-[1px] flex-grow bg-gradient-to-r from-yellow-500/20 to-transparent"></div>
</div>
</div>
{typeof description === 'string' ? (
<p className="mt-2 md:mt-5 text-lg md:text-xl text-zinc-400">{description}</p>
) : (
description
)}
</div>
);
}

@ -0,0 +1,57 @@
import { cn } from '../../lib/classname';
type SpotlightProps = {
className?: string;
fill?: string;
};
export function Spotlight(props: SpotlightProps) {
const { className, fill } = props;
return (
<svg
className={cn(
'animate-spotlight pointer-events-none absolute z-[1] h-[169%] w-[238%] opacity-0 lg:w-[138%]',
className,
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 3787 2842"
fill="none"
>
<g filter="url(#filter)">
<ellipse
cx="1924.71"
cy="273.501"
rx="1924.71"
ry="273.501"
transform="matrix(-0.822377 -0.568943 -0.568943 0.822377 3631.88 2291.09)"
fill={fill || 'white'}
fillOpacity="0.21"
></ellipse>
</g>
<defs>
<filter
id="filter"
x="0.860352"
y="0.838989"
width="3785.16"
height="2840.26"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
></feBlend>
<feGaussianBlur
stdDeviation="151"
result="effect1_foregroundBlur_1065_8"
></feGaussianBlur>
</filter>
</defs>
</svg>
);
}

@ -108,7 +108,6 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
useKeydown(
'r',
() => {
console.log(progress);
if (progress === 'pending') {
onClose();
return;

1
src/env.d.ts vendored

@ -7,6 +7,7 @@ interface ImportMetaEnv {
PUBLIC_APP_URL: string;
PUBLIC_AVATAR_BASE_URL: string;
PUBLIC_EDITOR_APP_URL: string;
PUBLIC_COURSE_APP_URL: string;
}
interface ImportMeta {

@ -167,7 +167,6 @@ const gaPageIdentifier = Astro.url.pathname
<slot name='page-footer'>
<slot name='changelog-banner'>
<ChangelogBanner />
</slot>
<slot name='open-source-banner'>
<OpenSourceBanner />

@ -1,5 +1,5 @@
---
import BaseLayout, { Props as BaseLayoutProps } from './BaseLayout.astro';
import BaseLayout, { type Props as BaseLayoutProps } from './BaseLayout.astro';
export interface Props extends BaseLayoutProps {}

@ -33,7 +33,6 @@ export function getUrlUtmParams(): UtmParams {
export function triggerUtmRegistration() {
const utmParams = getStoredUtmParams();
console.log(utmParams);
if (!utmParams.utmSource) {
return;
}

@ -1,3 +1,5 @@
import { siteConfig } from './config.ts';
const formatter = Intl.NumberFormat('en-US', {
notation: 'compact',
});
@ -14,6 +16,16 @@ export async function getDiscordInfo(): Promise<{
return discordStats;
}
if (import.meta.env.DEV) {
return {
url: 'https://roadmap.sh/discord',
total: 27000,
totalFormatted: '27k',
online: 49,
onlineFormatted: '3.44k',
};
}
const response = await fetch(
'https://discord.com/api/v9/invites/cJpEt5Qbwa?with_counts=true',
);

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

@ -9,3 +9,15 @@ export function formatCommaNumber(number: number): string {
export function decimalIfNeeded(number: number): string {
return number % 1 === 0 ? number.toString() : number.toFixed(1);
}
export function humanizeNumber(number: number): string {
if (number < 1000) {
return formatCommaNumber(number);
}
if (number < 1000000) {
return `${decimalIfNeeded(number / 1000)}k`;
}
return `${decimalIfNeeded(number / 1000000)}m`;
}

@ -0,0 +1,16 @@
---
import { SQLCoursePage } from '../../components/SQLCourse/SQLCoursePage.tsx';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
---
<SkeletonLayout
title='Master SQL'
briefTitle='Learn SQL from the ground up'
ogImageUrl='https://assets.roadmap.sh/guest/sql-course-bjc53.png'
description='Learn SQL from the ground up. This course covers the basics of SQL, intermediate concepts, and advanced topics.'
keywords={['sql', 'database', 'database management', 'database administration']}
canonicalUrl='/courses/sql'
noIndex={true}
>
<SQLCoursePage client:load />
</SkeletonLayout>

@ -19,8 +19,8 @@ const ogImageUrl =
---
<BaseLayout
title={guideData.seo.title}
description={guideData.seo.description}
title={replaceVariables(guideData.seo.title)}
description={replaceVariables(guideData.seo.description)}
permalink={guideData.excludedBySlug}
canonicalUrl={guideData.canonicalUrl}
ogImageUrl={ogImageUrl}

@ -40,6 +40,7 @@ import {
} from 'lucide-react';
import { SectionBadge } from '../components/GetStarted/SectionBadge';
import { TipItem } from '../components/GetStarted/TipItem';
import ChangelogBanner from '../components/ChangelogBanner.astro';
---
<BaseLayout
@ -508,4 +509,5 @@ import { TipItem } from '../components/GetStarted/TipItem';
</p>
</div>
</div>
<ChangelogBanner slot='changelog-banner' />
</BaseLayout>

@ -9,6 +9,7 @@ import { getAllGuides } from '../lib/guide';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';
import { getAllQuestionGroups } from '../lib/question-group';
import ChangelogBanner from '../components/ChangelogBanner.astro';
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
@ -23,7 +24,7 @@ const projectGroups = [
title: 'Backend',
id: 'backend',
},
]
];
const guides = await getAllGuides();
const questionGuides = (await getAllQuestionGroups()).filter(
@ -107,4 +108,5 @@ const videos = await getAllVideos();
<FeaturedVideos heading='Videos' videos={videos.slice(0, 7)} />
</div>
</div>
<ChangelogBanner slot='changelog-banner' />
</BaseLayout>

@ -5,6 +5,7 @@ import GridItem from '../components/GridItem.astro';
import SimplePageHeader from '../components/SimplePageHeader.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getRoadmapsByTag } from '../lib/roadmap';
import ChangelogBanner from '../components/ChangelogBanner.astro';
---
<BaseLayout
@ -14,4 +15,5 @@ import { getRoadmapsByTag } from '../lib/roadmap';
>
<RoadmapsPageHeader client:load />
<RoadmapsPage client:load />
<ChangelogBanner slot='changelog-banner' />
</BaseLayout>

@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
type CoursePriceParams = {
courseSlug: string;
};
type CoursePriceResponse = {
flag: string;
fullPrice: number;
regionalPrice: number;
regionalDiscountPercentage: number;
isEligibleForDiscount: boolean;
};
export function coursePriceOptions(params: CoursePriceParams) {
return queryOptions({
queryKey: ['course-price', params],
queryFn: async () => {
return httpGet<CoursePriceResponse>(
`/v1-course-price/${params.courseSlug}`,
);
},
});
}

@ -0,0 +1,41 @@
import { queryOptions } from '@tanstack/react-query';
import { isLoggedIn } from '../lib/jwt';
import { httpGet } from '../lib/query-http';
export interface CourseProgressDocument {
_id: string;
userId: string;
courseId: string;
completed: {
chapterId: string;
lessonId: string;
completedAt: Date;
}[];
review?: {
rating: number;
feedback?: string;
};
enrolledAt?: Date;
completedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export type CourseProgressResponse = Pick<
CourseProgressDocument,
'completed' | 'completedAt' | 'review' | 'enrolledAt'
>;
export function courseProgressOptions(courseSlug: string) {
return queryOptions({
queryKey: ['course-progress', courseSlug],
retryOnMount: false,
queryFn: async () => {
return httpGet<CourseProgressResponse>(
`/v1-course-progress/${courseSlug}`,
);
},
enabled: !!isLoggedIn(),
});
}

@ -56,6 +56,9 @@ h3 > code {
.prose ul li > code:before,
p > code:before,
.prose ul li > code:after,
.prose ol li > code:before,
p > code:before,
.prose ol li > code:after,
p > code:after,
a > code:after,
a > code:before {
@ -113,8 +116,20 @@ a > code:before {
animation: barberpole 15s linear infinite;
}
.striped-loader-yellow {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
hsla(55, 100%, 50%, 0.7) 5px,
hsla(55, 100%, 50%, 0.7) 10px
);
background-size: 200% 200%;
animation: barberpole 15s linear infinite;
}
@keyframes barberpole {
100% {
background-position: 100% 100%;
}
}
}

@ -37,11 +37,22 @@ module.exports = {
opacity: '1',
},
},
spotlight: {
"0%": {
opacity: 0,
transform: "translate(-72%, -62%) scale(0.5)",
},
"100%": {
opacity: 1,
transform: "translate(-50%,-40%) scale(1)",
},
},
},
animation: {
'fade-slide-up':
'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
'fade-in': 'fade-in 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
spotlight: "spotlight 2s ease 0.25s 1 forwards",
},
},
container: {

Loading…
Cancel
Save