mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-13 12:43:59 +02:00
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>
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1737069970237
|
"lastUpdateCheck": 1737392387456
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,3 +1,4 @@
|
|||||||
PUBLIC_API_URL=https://api.roadmap.sh
|
PUBLIC_API_URL=https://api.roadmap.sh
|
||||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
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",
|
"rehype-external-links": "^3.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"roadmap-renderer": "^1.0.6",
|
"roadmap-renderer": "^1.0.6",
|
||||||
|
"sanitize-html": "^2.13.1",
|
||||||
"satori": "^0.11.2",
|
"satori": "^0.11.2",
|
||||||
"satori-html": "^0.3.2",
|
"satori-html": "^0.3.2",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
|
"tiptap-markdown": "^0.8.10",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
@@ -86,6 +88,7 @@
|
|||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react-calendar-heatmap": "^1.6.7",
|
"@types/react-calendar-heatmap": "^1.6.7",
|
||||||
"@types/react-slick": "^0.23.13",
|
"@types/react-slick": "^0.23.13",
|
||||||
|
"@types/sanitize-html": "^2.13.0",
|
||||||
"@types/turndown": "^5.0.5",
|
"@types/turndown": "^5.0.5",
|
||||||
"csv-parser": "^3.0.0",
|
"csv-parser": "^3.0.0",
|
||||||
"gh-pages": "^6.2.0",
|
"gh-pages": "^6.2.0",
|
||||||
|
2718
pnpm-lock.yaml
generated
2718
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -86,9 +86,6 @@ export function AdvertiseForm() {
|
|||||||
|
|
||||||
pageProgressMessage.set('Please wait');
|
pageProgressMessage.set('Please wait');
|
||||||
|
|
||||||
// Placeholder function to send data
|
|
||||||
console.log('Form data:', formData);
|
|
||||||
|
|
||||||
const { response, error } = await httpPost(
|
const { response, error } = await httpPost(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-advertise`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-advertise`,
|
||||||
formData,
|
formData,
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { parse } from 'node-html-parser';
|
import { parse } from 'node-html-parser';
|
||||||
import type { Attributes } from 'node-html-parser/dist/nodes/html';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -15,7 +14,6 @@ async function getSVG(name: string) {
|
|||||||
eager: true,
|
eager: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!(filepath in files)) {
|
if (!(filepath in files)) {
|
||||||
throw new Error(`${filepath} not found`);
|
throw new Error(`${filepath} not found`);
|
||||||
}
|
}
|
||||||
|
143
src/components/AuthenticationFlow/CourseLoginPopup.tsx
Normal file
143
src/components/AuthenticationFlow/CourseLoginPopup.tsx
Normal file
@@ -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 { useEffect, useState } from 'react';
|
||||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
import { cn } from '../../../editor/utils/classname.ts';
|
||||||
import Cookies from 'js-cookie';
|
|
||||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
|
||||||
import { httpGet } from '../../lib/http';
|
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 { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||||
|
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
|
||||||
import { triggerUtmRegistration } from '../../lib/browser.ts';
|
import { triggerUtmRegistration } from '../../lib/browser.ts';
|
||||||
|
|
||||||
type GitHubButtonProps = {
|
type GitHubButtonProps = {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
setIsDisabled?: (isDisabled: boolean) => void;
|
setIsDisabled?: (isDisabled: boolean) => void;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||||
|
|
||||||
export function GitHubButton(props: GitHubButtonProps) {
|
export function GitHubButton(props: GitHubButtonProps) {
|
||||||
const { isDisabled, setIsDisabled } = props;
|
const { isDisabled, setIsDisabled, className } = props;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -74,6 +76,17 @@ export function GitHubButton(props: GitHubButtonProps) {
|
|||||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||||
setAuthToken(response.token);
|
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;
|
window.location.href = redirectUrl;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -120,7 +133,10 @@ export function GitHubButton(props: GitHubButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<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}
|
disabled={isLoading || isDisabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Cookies from 'js-cookie';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
|
||||||
import { httpGet } from '../../lib/http';
|
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 { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
|
||||||
|
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||||
|
import { CHECKOUT_AFTER_LOGIN_KEY } from './CourseLoginPopup.tsx';
|
||||||
import {
|
import {
|
||||||
getStoredUtmParams,
|
getStoredUtmParams,
|
||||||
triggerUtmRegistration,
|
triggerUtmRegistration,
|
||||||
@@ -12,13 +13,14 @@ import {
|
|||||||
type GoogleButtonProps = {
|
type GoogleButtonProps = {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
setIsDisabled?: (isDisabled: boolean) => void;
|
setIsDisabled?: (isDisabled: boolean) => void;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||||
|
|
||||||
export function GoogleButton(props: GoogleButtonProps) {
|
export function GoogleButton(props: GoogleButtonProps) {
|
||||||
const { isDisabled, setIsDisabled } = props;
|
const { isDisabled, setIsDisabled, className } = props;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -75,6 +77,16 @@ export function GoogleButton(props: GoogleButtonProps) {
|
|||||||
redirectUrl = authRedirectUrl;
|
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_REDIRECT_AT);
|
||||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||||
setAuthToken(response.token);
|
setAuthToken(response.token);
|
||||||
@@ -130,7 +142,10 @@ export function GoogleButton(props: GoogleButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<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}
|
disabled={isLoading || isDisabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
|
@@ -1,21 +1,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Cookies from 'js-cookie';
|
import { cn } from '../../lib/classname.ts';
|
||||||
import { TOKEN_COOKIE_NAME, setAuthToken } from '../../lib/jwt';
|
|
||||||
import { httpGet } from '../../lib/http';
|
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 { 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';
|
import { triggerUtmRegistration } from '../../lib/browser.ts';
|
||||||
|
|
||||||
type LinkedInButtonProps = {
|
type LinkedInButtonProps = {
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
setIsDisabled?: (isDisabled: boolean) => void;
|
setIsDisabled?: (isDisabled: boolean) => void;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||||
|
|
||||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||||
const { isDisabled, setIsDisabled } = props;
|
const { isDisabled, setIsDisabled, className } = props;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -70,6 +72,16 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
|||||||
redirectUrl = authRedirectUrl;
|
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_REDIRECT_AT);
|
||||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||||
setAuthToken(response.token);
|
setAuthToken(response.token);
|
||||||
@@ -125,14 +137,17 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<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}
|
disabled={isLoading || isDisabled}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
<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
|
Continue with LinkedIn
|
||||||
</button>
|
</button>
|
||||||
|
@@ -1,11 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Menu } from 'lucide-react';
|
import { AccountStreak } from '../AccountStreak/AccountStreak';
|
||||||
import Icon from '../AstroIcon.astro';
|
import Icon from '../AstroIcon.astro';
|
||||||
import { NavigationDropdown } from '../NavigationDropdown';
|
import { NavigationDropdown } from '../NavigationDropdown';
|
||||||
import { AccountDropdown } from './AccountDropdown';
|
|
||||||
import NewIndicator from './NewIndicator.astro';
|
|
||||||
import { AccountStreak } from '../AccountStreak/AccountStreak';
|
|
||||||
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
|
import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu';
|
||||||
|
import { AccountDropdown } from './AccountDropdown';
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||||
@@ -48,7 +46,7 @@ import { RoadmapDropdownMenu } from '../RoadmapDropdownMenu/RoadmapDropdownMenu'
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href='/changelog'
|
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
|
Changelog
|
||||||
|
|
||||||
|
28
src/components/ReactIcons/RoadmapLogo.tsx
Normal file
28
src/components/ReactIcons/RoadmapLogo.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
67
src/components/SQLCourse/AccountButton.tsx
Normal file
67
src/components/SQLCourse/AccountButton.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
210
src/components/SQLCourse/BuyButton.tsx
Normal file
210
src/components/SQLCourse/BuyButton.tsx
Normal file
@@ -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"> </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"> </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>
|
||||||
|
);
|
||||||
|
}
|
145
src/components/SQLCourse/ChapterRow.tsx
Normal file
145
src/components/SQLCourse/ChapterRow.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
24
src/components/SQLCourse/CourseAuthor.tsx
Normal file
24
src/components/SQLCourse/CourseAuthor.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
94
src/components/SQLCourse/CourseFeature.tsx
Normal file
94
src/components/SQLCourse/CourseFeature.tsx
Normal file
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
113
src/components/SQLCourse/FAQSection.tsx
Normal file
113
src/components/SQLCourse/FAQSection.tsx
Normal file
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
56
src/components/SQLCourse/FloatingPurchase.tsx
Normal file
56
src/components/SQLCourse/FloatingPurchase.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
415
src/components/SQLCourse/SQLCoursePage.tsx
Normal file
415
src/components/SQLCourse/SQLCoursePage.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
29
src/components/SQLCourse/SectionHeader.tsx
Normal file
29
src/components/SQLCourse/SectionHeader.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
57
src/components/SQLCourse/Spotlight.tsx
Normal file
57
src/components/SQLCourse/Spotlight.tsx
Normal file
@@ -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(
|
useKeydown(
|
||||||
'r',
|
'r',
|
||||||
() => {
|
() => {
|
||||||
console.log(progress);
|
|
||||||
if (progress === 'pending') {
|
if (progress === 'pending') {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
|
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -7,6 +7,7 @@ interface ImportMetaEnv {
|
|||||||
PUBLIC_APP_URL: string;
|
PUBLIC_APP_URL: string;
|
||||||
PUBLIC_AVATAR_BASE_URL: string;
|
PUBLIC_AVATAR_BASE_URL: string;
|
||||||
PUBLIC_EDITOR_APP_URL: string;
|
PUBLIC_EDITOR_APP_URL: string;
|
||||||
|
PUBLIC_COURSE_APP_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
@@ -167,7 +167,6 @@ const gaPageIdentifier = Astro.url.pathname
|
|||||||
|
|
||||||
<slot name='page-footer'>
|
<slot name='page-footer'>
|
||||||
<slot name='changelog-banner'>
|
<slot name='changelog-banner'>
|
||||||
<ChangelogBanner />
|
|
||||||
</slot>
|
</slot>
|
||||||
<slot name='open-source-banner'>
|
<slot name='open-source-banner'>
|
||||||
<OpenSourceBanner />
|
<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 {}
|
export interface Props extends BaseLayoutProps {}
|
||||||
|
|
||||||
|
@@ -33,7 +33,6 @@ export function getUrlUtmParams(): UtmParams {
|
|||||||
|
|
||||||
export function triggerUtmRegistration() {
|
export function triggerUtmRegistration() {
|
||||||
const utmParams = getStoredUtmParams();
|
const utmParams = getStoredUtmParams();
|
||||||
console.log(utmParams);
|
|
||||||
if (!utmParams.utmSource) {
|
if (!utmParams.utmSource) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { siteConfig } from './config.ts';
|
||||||
|
|
||||||
const formatter = Intl.NumberFormat('en-US', {
|
const formatter = Intl.NumberFormat('en-US', {
|
||||||
notation: 'compact',
|
notation: 'compact',
|
||||||
});
|
});
|
||||||
@@ -14,6 +16,16 @@ export async function getDiscordInfo(): Promise<{
|
|||||||
return discordStats;
|
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(
|
const response = await fetch(
|
||||||
'https://discord.com/api/v9/invites/cJpEt5Qbwa?with_counts=true',
|
'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';
|
import type { AllowedOnboardingStatus } from '../api/user';
|
||||||
|
|
||||||
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
|
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
|
||||||
|
export const COURSE_PURCHASE_PARAM = 't';
|
||||||
|
|
||||||
export type TokenPayload = {
|
export type TokenPayload = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@@ -9,3 +9,15 @@ export function formatCommaNumber(number: number): string {
|
|||||||
export function decimalIfNeeded(number: number): string {
|
export function decimalIfNeeded(number: number): string {
|
||||||
return number % 1 === 0 ? number.toString() : number.toFixed(1);
|
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`;
|
||||||
|
}
|
||||||
|
16
src/pages/courses/sql.astro
Normal file
16
src/pages/courses/sql.astro
Normal file
@@ -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
|
<BaseLayout
|
||||||
title={guideData.seo.title}
|
title={replaceVariables(guideData.seo.title)}
|
||||||
description={guideData.seo.description}
|
description={replaceVariables(guideData.seo.description)}
|
||||||
permalink={guideData.excludedBySlug}
|
permalink={guideData.excludedBySlug}
|
||||||
canonicalUrl={guideData.canonicalUrl}
|
canonicalUrl={guideData.canonicalUrl}
|
||||||
ogImageUrl={ogImageUrl}
|
ogImageUrl={ogImageUrl}
|
||||||
|
@@ -40,6 +40,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SectionBadge } from '../components/GetStarted/SectionBadge';
|
import { SectionBadge } from '../components/GetStarted/SectionBadge';
|
||||||
import { TipItem } from '../components/GetStarted/TipItem';
|
import { TipItem } from '../components/GetStarted/TipItem';
|
||||||
|
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -508,4 +509,5 @@ import { TipItem } from '../components/GetStarted/TipItem';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChangelogBanner slot='changelog-banner' />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
@@ -9,6 +9,7 @@ import { getAllGuides } from '../lib/guide';
|
|||||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||||
import { getAllVideos } from '../lib/video';
|
import { getAllVideos } from '../lib/video';
|
||||||
import { getAllQuestionGroups } from '../lib/question-group';
|
import { getAllQuestionGroups } from '../lib/question-group';
|
||||||
|
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||||
|
|
||||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
||||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
||||||
@@ -23,7 +24,7 @@ const projectGroups = [
|
|||||||
title: 'Backend',
|
title: 'Backend',
|
||||||
id: 'backend',
|
id: 'backend',
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const guides = await getAllGuides();
|
const guides = await getAllGuides();
|
||||||
const questionGuides = (await getAllQuestionGroups()).filter(
|
const questionGuides = (await getAllQuestionGroups()).filter(
|
||||||
@@ -107,4 +108,5 @@ const videos = await getAllVideos();
|
|||||||
<FeaturedVideos heading='Videos' videos={videos.slice(0, 7)} />
|
<FeaturedVideos heading='Videos' videos={videos.slice(0, 7)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChangelogBanner slot='changelog-banner' />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
@@ -5,6 +5,7 @@ import GridItem from '../components/GridItem.astro';
|
|||||||
import SimplePageHeader from '../components/SimplePageHeader.astro';
|
import SimplePageHeader from '../components/SimplePageHeader.astro';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||||
|
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
@@ -14,4 +15,5 @@ import { getRoadmapsByTag } from '../lib/roadmap';
|
|||||||
>
|
>
|
||||||
<RoadmapsPageHeader client:load />
|
<RoadmapsPageHeader client:load />
|
||||||
<RoadmapsPage client:load />
|
<RoadmapsPage client:load />
|
||||||
|
<ChangelogBanner slot='changelog-banner' />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
25
src/queries/billing.ts
Normal file
25
src/queries/billing.ts
Normal file
@@ -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}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
41
src/queries/course-progress.ts
Normal file
41
src/queries/course-progress.ts
Normal file
@@ -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,
|
.prose ul li > code:before,
|
||||||
p > code:before,
|
p > code:before,
|
||||||
.prose ul li > code:after,
|
.prose ul li > code:after,
|
||||||
|
.prose ol li > code:before,
|
||||||
|
p > code:before,
|
||||||
|
.prose ol li > code:after,
|
||||||
p > code:after,
|
p > code:after,
|
||||||
a > code:after,
|
a > code:after,
|
||||||
a > code:before {
|
a > code:before {
|
||||||
@@ -113,6 +116,18 @@ a > code:before {
|
|||||||
animation: barberpole 15s linear infinite;
|
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 {
|
@keyframes barberpole {
|
||||||
100% {
|
100% {
|
||||||
background-position: 100% 100%;
|
background-position: 100% 100%;
|
||||||
|
@@ -37,11 +37,22 @@ module.exports = {
|
|||||||
opacity: '1',
|
opacity: '1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
spotlight: {
|
||||||
|
"0%": {
|
||||||
|
opacity: 0,
|
||||||
|
transform: "translate(-72%, -62%) scale(0.5)",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
opacity: 1,
|
||||||
|
transform: "translate(-50%,-40%) scale(1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-slide-up':
|
'fade-slide-up':
|
||||||
'fade-slide-up 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
'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',
|
'fade-in': 'fade-in 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||||
|
spotlight: "spotlight 2s ease 0.25s 1 forwards",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
|
Reference in New Issue
Block a user