diff --git a/apps/web/package.json b/apps/web/package.json index 7c3d35252..bf8cf3383 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -152,6 +152,7 @@ "unified": "10.1.2", "usehooks-ts": "2.9.1", "uuid": "9.0.1", + "virtua": "^0.41.5", "web-vitals": "5.0.3", "zod": "^3.22.4" }, diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/[slug]/page.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/(question)/[slug]/page.tsx similarity index 100% rename from apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/[slug]/page.tsx rename to apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/(question)/[slug]/page.tsx diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/layout.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/(question)/layout.tsx similarity index 52% rename from apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/layout.tsx rename to apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/(question)/layout.tsx index 1bdbad19b..f1f5460a6 100644 --- a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/layout.tsx +++ b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/(question)/layout.tsx @@ -1,4 +1,3 @@ -import FeedbackWidget from '~/components/global/feedback/FeedbackWidget'; import QuestionsQuizContentLayout from '~/components/interviews/questions/content/quiz/QuestionsQuizContentLayout'; type Props = Readonly<{ @@ -6,10 +5,5 @@ type Props = Readonly<{ }>; export default async function QuizContentLayout({ children }: Props) { - return ( - - {children} - - - ); + return {children}; } diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/css-interview-questions/page.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/css-interview-questions/page.tsx new file mode 100644 index 000000000..945e44220 --- /dev/null +++ b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/css-interview-questions/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; +import QuestionQuizScrollableList from '~/components/interviews/questions/content/quiz/QuestionQuizScrollableList'; + +import { fetchInterviewsQuestionQuizScrollScrollableContent } from '~/db/contentlayer/InterviewsQuestionQuizScrollableContentReader'; +import { readQuestionQuizContentsAll } from '~/db/QuestionsContentsReader'; +import { fetchQuestionsList } from '~/db/QuestionsListReader'; +import { roundQuestionCountToNearestTen } from '~/db/QuestionsUtils'; +import { getIntlServerOnly } from '~/i18n'; +import defaultMetadata from '~/seo/defaultMetadata'; + +type Props = Readonly<{ + params: Readonly<{ + locale: string; + }>; +}>; + +const listType: QuestionListTypeData = { + tab: 'quiz', + type: 'language', + value: 'css', +}; + +const category = 'CSS'; + +export const dynamic = 'force-static'; + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = params; + const [intl, { questions }] = await Promise.all([ + getIntlServerOnly(locale), + fetchQuestionsList(listType, locale), + ]); + + const seoTitle = intl.formatMessage( + { + defaultMessage: '{category} Interview Questions and Answers', + description: 'SEO title of quiz scrolling mode page', + id: 'bYOK+T', + }, + { category }, + ); + + return defaultMetadata({ + description: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ most important {category} interview questions and answers in quiz-style format, answered by ex-FAANG interviewers', + description: 'Description of quiz scrolling mode page', + id: 'xtk4b8', + }, + { + category, + questionCount: roundQuestionCountToNearestTen(questions.length), + }, + ), + locale, + ogImagePageType: intl.formatMessage({ + defaultMessage: 'Frameworks or languages', + description: 'OG image page title of framework and language page', + id: '+XLpUw', + }), + ogImageTitle: seoTitle, + pathname: `/questions/quiz/css-interview-questions`, + socialTitle: `${seoTitle} | GreatFrontEnd`, + title: intl.formatMessage( + { + defaultMessage: + '{category} Interview Questions and Answers | by Ex-FAANG interviewers', + description: 'Title of quiz scrolling mode page', + id: '2HQlrS', + }, + { category }, + ), + }); +} + +export default async function Page({ params }: Props) { + const { locale } = params; + + const [intl, quizQuestions, longDescription] = await Promise.all([ + getIntlServerOnly(locale), + readQuestionQuizContentsAll(listType, locale), + fetchInterviewsQuestionQuizScrollScrollableContent('css', locale), + ]); + + if (quizQuestions == null || longDescription == null) { + return notFound(); + } + + return ( + item.question)} + title={intl.formatMessage( + { + defaultMessage: '{category} Interview Questions', + description: 'Title of scrolling mode page', + id: '4gumtt', + }, + { category }, + )} + /> + ); +} diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/html-interview-questions/page.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/html-interview-questions/page.tsx new file mode 100644 index 000000000..9ee0a9f88 --- /dev/null +++ b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/html-interview-questions/page.tsx @@ -0,0 +1,120 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; +import QuestionQuizScrollableList from '~/components/interviews/questions/content/quiz/QuestionQuizScrollableList'; + +import { fetchInterviewsQuestionQuizScrollScrollableContent } from '~/db/contentlayer/InterviewsQuestionQuizScrollableContentReader'; +import { readQuestionQuizContentsAll } from '~/db/QuestionsContentsReader'; +import { fetchQuestionsList } from '~/db/QuestionsListReader'; +import { roundQuestionCountToNearestTen } from '~/db/QuestionsUtils'; +import { getIntlServerOnly } from '~/i18n'; +import defaultMetadata from '~/seo/defaultMetadata'; + +type Props = Readonly<{ + params: Readonly<{ + locale: string; + }>; +}>; + +const listType: QuestionListTypeData = { + tab: 'quiz', + type: 'language', + value: 'html', +}; + +const category = 'HTML'; + +export const dynamic = 'force-static'; + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = params; + const [intl, { questions }] = await Promise.all([ + getIntlServerOnly(locale), + fetchQuestionsList(listType, locale), + ]); + + const seoTitle = intl.formatMessage( + { + defaultMessage: '{category} Interview Questions and Answers', + description: 'SEO title of quiz scrolling mode page', + id: 'bYOK+T', + }, + { category }, + ); + + return defaultMetadata({ + description: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ most important {category} interview questions and answers in quiz-style format, answered by ex-FAANG interviewers', + description: 'Description of quiz scrolling mode page', + id: 'xtk4b8', + }, + { + category, + questionCount: roundQuestionCountToNearestTen(questions.length), + }, + ), + locale, + ogImagePageType: intl.formatMessage({ + defaultMessage: 'Frameworks or languages', + description: 'OG image page title of framework and language page', + id: '+XLpUw', + }), + ogImageTitle: seoTitle, + pathname: `/questions/quiz/html-interview-questions`, + socialTitle: `${seoTitle} | GreatFrontEnd`, + title: intl.formatMessage( + { + defaultMessage: + '{category} Interview Questions and Answers | by Ex-FAANG interviewers', + description: 'Title of quiz scrolling mode page', + id: '2HQlrS', + }, + { category }, + ), + }); +} +export default async function Page({ params }: Props) { + const { locale } = params; + + const [intl, quizQuestions, longDescription] = await Promise.all([ + getIntlServerOnly(locale), + readQuestionQuizContentsAll(listType, locale), + fetchInterviewsQuestionQuizScrollScrollableContent('html', locale), + ]); + + if (quizQuestions == null || longDescription == null) { + return notFound(); + } + + return ( + item.question)} + title={intl.formatMessage( + { + defaultMessage: '{category} Interview Questions', + description: 'Title of scrolling mode page', + id: '4gumtt', + }, + { category }, + )} + /> + ); +} diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/javascript-interview-questions/page.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/javascript-interview-questions/page.tsx new file mode 100644 index 000000000..beef4d099 --- /dev/null +++ b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/javascript-interview-questions/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; +import QuestionQuizScrollableList from '~/components/interviews/questions/content/quiz/QuestionQuizScrollableList'; + +import { fetchInterviewsQuestionQuizScrollScrollableContent } from '~/db/contentlayer/InterviewsQuestionQuizScrollableContentReader'; +import { readQuestionQuizContentsAll } from '~/db/QuestionsContentsReader'; +import { fetchQuestionsList } from '~/db/QuestionsListReader'; +import { roundQuestionCountToNearestTen } from '~/db/QuestionsUtils'; +import { getIntlServerOnly } from '~/i18n'; +import defaultMetadata from '~/seo/defaultMetadata'; + +type Props = Readonly<{ + params: Readonly<{ + locale: string; + }>; +}>; + +const listType: QuestionListTypeData = { + tab: 'quiz', + type: 'language', + value: 'js', +}; + +const category = 'JavaScript'; + +export const dynamic = 'force-static'; + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = params; + const [intl, { questions }] = await Promise.all([ + getIntlServerOnly(locale), + fetchQuestionsList(listType, locale), + ]); + + const seoTitle = intl.formatMessage( + { + defaultMessage: '{category} Interview Questions and Answers', + description: 'SEO title of quiz scrolling mode page', + id: 'bYOK+T', + }, + { category }, + ); + + return defaultMetadata({ + description: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ most important JavaScript interview questions and answers, covering everything from core concepts to advanced JavaScript features', + description: 'Description of quiz scrolling mode page', + id: 'nnXIFO', + }, + { + category, + questionCount: roundQuestionCountToNearestTen(questions.length), + }, + ), + locale, + ogImagePageType: intl.formatMessage({ + defaultMessage: 'Frameworks or languages', + description: 'OG image page title of framework and language page', + id: '+XLpUw', + }), + ogImageTitle: seoTitle, + pathname: `/questions/quiz/javascript-interview-questions`, + socialTitle: `${seoTitle} | GreatFrontEnd`, + title: intl.formatMessage( + { + defaultMessage: + '{category} Interview Questions and Answers | by Ex-FAANG interviewers', + description: 'Title of quiz scrolling mode page', + id: '2HQlrS', + }, + { category }, + ), + }); +} + +export default async function Page({ params }: Props) { + const { locale } = params; + + const [intl, quizQuestions, longDescription] = await Promise.all([ + getIntlServerOnly(locale), + readQuestionQuizContentsAll(listType, locale), + fetchInterviewsQuestionQuizScrollScrollableContent('js', locale), + ]); + + if (quizQuestions == null || longDescription == null) { + return notFound(); + } + + return ( + item.question)} + title={intl.formatMessage( + { + defaultMessage: '{category} Interview Questions', + description: 'Title of scrolling mode page', + id: '4gumtt', + }, + { category }, + )} + /> + ); +} diff --git a/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/react-interview-questions/page.tsx b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/react-interview-questions/page.tsx new file mode 100644 index 000000000..ab6605f0d --- /dev/null +++ b/apps/web/src/app/[locale]/(interviews)/(sidebarless)/(quiz)/questions/quiz/react-interview-questions/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; +import QuestionQuizScrollableList from '~/components/interviews/questions/content/quiz/QuestionQuizScrollableList'; + +import { fetchInterviewsQuestionQuizScrollScrollableContent } from '~/db/contentlayer/InterviewsQuestionQuizScrollableContentReader'; +import { readQuestionQuizContentsAll } from '~/db/QuestionsContentsReader'; +import { fetchQuestionsList } from '~/db/QuestionsListReader'; +import { roundQuestionCountToNearestTen } from '~/db/QuestionsUtils'; +import { getIntlServerOnly } from '~/i18n'; +import defaultMetadata from '~/seo/defaultMetadata'; + +type Props = Readonly<{ + params: Readonly<{ + locale: string; + }>; +}>; + +const listType: QuestionListTypeData = { + tab: 'quiz', + type: 'framework', + value: 'react', +}; + +const category = 'React'; + +export const dynamic = 'force-static'; + +export async function generateMetadata({ params }: Props): Promise { + const { locale } = params; + const [intl, { questions }] = await Promise.all([ + getIntlServerOnly(locale), + fetchQuestionsList(listType, locale), + ]); + + const seoTitle = intl.formatMessage( + { + defaultMessage: '{category} Interview Questions and Answers', + description: 'SEO title of quiz scrolling mode page', + id: 'bYOK+T', + }, + { category }, + ); + + return defaultMetadata({ + description: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ most important {category} interview questions and answers in quiz-style format, answered by ex-FAANG interviewers', + description: 'Description of quiz scrolling mode page', + id: 'xtk4b8', + }, + { + category, + questionCount: roundQuestionCountToNearestTen(questions.length), + }, + ), + locale, + ogImagePageType: intl.formatMessage({ + defaultMessage: 'Frameworks or languages', + description: 'OG image page title of framework and language page', + id: '+XLpUw', + }), + ogImageTitle: seoTitle, + pathname: `/questions/quiz/javascript-interview-questions`, + socialTitle: `${seoTitle} | GreatFrontEnd`, + title: intl.formatMessage( + { + defaultMessage: + '{category} Interview Questions and Answers | by Ex-FAANG interviewers', + description: 'Title of quiz scrolling mode page', + id: '2HQlrS', + }, + { category }, + ), + }); +} + +export default async function Page({ params }: Props) { + const { locale } = params; + + const [intl, quizQuestions, longDescription] = await Promise.all([ + getIntlServerOnly(locale), + readQuestionQuizContentsAll(listType, locale), + fetchInterviewsQuestionQuizScrollScrollableContent('react', locale), + ]); + + if (quizQuestions == null || longDescription == null) { + return notFound(); + } + + return ( + item.question)} + title={intl.formatMessage( + { + defaultMessage: '{category} Interview Questions', + description: 'Title of scrolling mode page', + id: '4gumtt', + }, + { category }, + )} + /> + ); +} diff --git a/apps/web/src/components/interviews/common/InterviewsPageFeatures.tsx b/apps/web/src/components/interviews/common/InterviewsPageFeatures.tsx index 98e4d55ff..e70dee59f 100644 --- a/apps/web/src/components/interviews/common/InterviewsPageFeatures.tsx +++ b/apps/web/src/components/interviews/common/InterviewsPageFeatures.tsx @@ -12,11 +12,7 @@ type Props = Readonly<{ export default function InterviewsPageFeatures({ features }: Props) { return ( -
+
{features.map(({ icon: FeatureIcon, label }) => (
diff --git a/apps/web/src/components/interviews/common/InterviewsPageHeader.tsx b/apps/web/src/components/interviews/common/InterviewsPageHeader.tsx index d9f55c0c4..b96127bb2 100644 --- a/apps/web/src/components/interviews/common/InterviewsPageHeader.tsx +++ b/apps/web/src/components/interviews/common/InterviewsPageHeader.tsx @@ -15,6 +15,7 @@ type Props = Readonly<{ children?: ReactNode; className?: string; description: ReactNode; + descriptionWidthFull?: boolean; features: ReadonlyArray<{ icon: (props: React.ComponentProps<'svg'>) => JSX.Element; label: string; @@ -37,6 +38,7 @@ export default function InterviewsPageHeader({ children, className, description, + descriptionWidthFull, features, headingAddOnElement, logoImgSrc, @@ -95,7 +97,10 @@ export default function InterviewsPageHeader({
diff --git a/apps/web/src/components/interviews/questions/common/QuestionHrefUtils.ts b/apps/web/src/components/interviews/questions/common/QuestionHrefUtils.ts index 0d542660a..284956834 100644 --- a/apps/web/src/components/interviews/questions/common/QuestionHrefUtils.ts +++ b/apps/web/src/components/interviews/questions/common/QuestionHrefUtils.ts @@ -1,3 +1,10 @@ +import url from 'url'; + +import { + QuestionFrameworkRawToSEOMapping, + QuestionLanguageRawToSEOMapping, +} from '~/data/QuestionCategories'; + import { getSiteOrigin } from '~/seo/siteUrl'; import type { @@ -52,6 +59,7 @@ function questionHrefFrameworkSpecific( export function questionHrefWithListType( href: string, listType?: QuestionListTypeData | null, + questionMetadata?: QuestionMetadata, ): string { if (listType == null) { return href; @@ -82,6 +90,45 @@ export function questionHrefWithListType( urlObject.searchParams.set('title', listType.title); } + // Special URLs for quiz questions that support scrolling mode + if (questionMetadata?.format === 'quiz') { + switch (listType.type) { + case 'framework': { + switch (listType.value) { + case 'react': + return ( + `/questions/quiz/${QuestionFrameworkRawToSEOMapping.react}` + + urlObject.search + + `#${questionMetadata.slug}` + ); + } + break; + } + case 'language': { + switch (listType.value) { + case 'js': + return ( + `/questions/quiz/${QuestionLanguageRawToSEOMapping.js}` + + urlObject.search + + `#${questionMetadata.slug}` + ); + case 'html': + return ( + `/questions/quiz/${QuestionLanguageRawToSEOMapping.html}` + + urlObject.search + + `#${questionMetadata.slug}` + ); + case 'css': + return ( + `/questions/quiz/${QuestionLanguageRawToSEOMapping.css}` + + urlObject.search + + `#${questionMetadata.slug}` + ); + } + } + } + } + return urlObject.pathname + urlObject.search + urlObject.hash; } @@ -90,11 +137,36 @@ export function questionHrefFrameworkSpecificAndListType( listType?: QuestionListTypeData | null, framework?: QuestionFramework, ): string { - const maybeFrameworkHref = questionHrefFrameworkSpecific( + const hrefWithMaybeFramework = questionHrefFrameworkSpecific( questionMetadata, listType, framework, ); - return questionHrefWithListType(maybeFrameworkHref, listType); + const hrefWithListType = questionHrefWithListType( + hrefWithMaybeFramework, + listType, + questionMetadata, + ); + + return questionHrefStripSamePathnameAndSearch(hrefWithListType); +} + +function questionHrefStripSamePathnameAndSearch(href: string): string { + if (typeof window === 'undefined') { + return href; + } + + const urlObj = url.parse(href); + + // Leave only the hash if the current URL is the same as the href + // Next.js has problems pushing to the same URL with a hash + if ( + window.location.pathname === urlObj.pathname && + window.location.search === urlObj.search + ) { + return urlObj.hash || '#'; + } + + return href; } diff --git a/apps/web/src/components/interviews/questions/common/QuestionProgressAction.tsx b/apps/web/src/components/interviews/questions/common/QuestionProgressAction.tsx index 133267718..ed9a959b8 100644 --- a/apps/web/src/components/interviews/questions/common/QuestionProgressAction.tsx +++ b/apps/web/src/components/interviews/questions/common/QuestionProgressAction.tsx @@ -33,6 +33,8 @@ type Props = Readonly<{ studyListKey?: string; }>; +const ButtonIcon = FaCheck; + export default function QuestionProgressAction({ metadata, signInModalContents, @@ -41,6 +43,11 @@ export default function QuestionProgressAction({ const intl = useIntl(); const pathname = usePathname(); const user = useUser(); + const buttonLabelMarkComplete = intl.formatMessage({ + defaultMessage: 'Mark complete', + description: 'Mark question as complete', + id: 'C4am9n', + }); const [isLoginDialogShown, setIsLoginDialogShown] = useState(false); const markCompleteMutation = useMutationQuestionProgressAdd(); @@ -54,6 +61,10 @@ export default function QuestionProgressAction({ studyListKey ?? null, ); + if (data?.isQuestionLockedForViewer) { + return null; + } + if (user == null) { if (metadata.access === 'premium') { return null; @@ -63,12 +74,8 @@ export default function QuestionProgressAction({ <>
+ + + + ); +} + +function useCodingFeatures(languageOrFramework: LanguageOrFramework) { + const intl = useIntl(); + const labels: Record = { + ...QuestionLanguageLabels, + ...QuestionFrameworkLabels, + }; + const questionCount: Record = { + css: QuestionCountCSSCoding, + html: QuestionCountHTMLCoding, + js: QuestionCountJavaScriptCoding, + react: QuestionCountReactCoding, + ts: QuestionCountTypeScriptCoding, + }; + + return { + 'browser-coding': { + key: 'browser-coding', + label: intl.formatMessage({ + defaultMessage: + 'An in-browser coding workspace that mimics real interview conditions', + description: 'Label for browser-based coding environment', + id: 'fbZwwj', + }), + }, + 'code-preview': { + key: 'ui-questions-preview', + label: intl.formatMessage({ + defaultMessage: 'Instantly preview your code for UI questions', + description: 'Label for code preview features', + id: 'zI3w61', + }), + }, + 'js-browser-coding': { + key: 'js-browser-coding', + label: intl.formatMessage({ + defaultMessage: + 'In-browser coding workspace similar to real interview environment', + description: 'Label for browser-based coding environment', + id: 'aA3ERo', + }), + }, + 'js-questions': { + key: 'js-questions', + label: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ {languageOrFramework} coding questions', + description: 'Number of questions in coding section', + id: 'wG+caE', + }, + { + languageOrFramework: labels[languageOrFramework], + questionCount: roundQuestionCountToNearestTen( + questionCount[languageOrFramework], + ), + }, + ), + }, + 'js-solutions': { + key: 'js-solutions', + label: intl.formatMessage({ + defaultMessage: 'Reference solutions from Big Tech Ex-interviewers', + description: 'Label for JavaScript solutions in coding questions', + id: 'JnZMCy', + }), + }, + 'js-test-cases': { + key: 'automated-test-cases', + label: intl.formatMessage({ + defaultMessage: 'Automated test cases', + description: 'Label for automated test cases in coding questions', + id: 'VWfO56', + }), + }, + questions: { + key: 'questions', + label: intl.formatMessage( + { + defaultMessage: + '{questionCount}+ {languageOrFramework} coding interview questions', + description: 'Number of questions in coding section', + id: 'ej+6cH', + }, + { + languageOrFramework: labels[languageOrFramework], + questionCount: roundQuestionCountToNearestTen( + questionCount[languageOrFramework], + ), + }, + ), + }, + 'related-ui-preview': { + key: 'ui-questions-preview', + label: intl.formatMessage({ + defaultMessage: 'Instant UI preview for UI-related questions', + description: 'Label for related UI preview features', + id: 'A5JdsM', + }), + }, + solutions: { + key: 'solutions', + label: intl.formatMessage({ + defaultMessage: + 'Reference solutions from ex-interviewers at Big Tech companies', + description: 'Label for coding questions solution', + id: '71Hlcg', + }), + }, + 'test-cases': { + key: 'test-cases', + label: intl.formatMessage({ + defaultMessage: 'One-click automated, transparent test cases', + description: 'Label for test cases in coding questions', + id: 'Q891Ht', + }), + }, + 'ui-preview': { + key: 'ui-questions-preview', + label: intl.formatMessage({ + defaultMessage: 'Instant UI preview for UI questions', + description: 'Label for UI preview features', + id: 'tmk5rv', + }), + }, + }; +} diff --git a/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollModeToggle.tsx b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollModeToggle.tsx new file mode 100644 index 000000000..add39121d --- /dev/null +++ b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollModeToggle.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react'; +import { RiPagesLine, RiTerminalWindowLine } from 'react-icons/ri'; +import url from 'url'; +import { useMediaQuery } from 'usehooks-ts'; + +import { + QuestionFrameworkRawToSEOMapping, + QuestionLanguageRawToSEOMapping, +} from '~/data/QuestionCategories'; + +import type { + QuestionFramework, + QuestionLanguage, +} from '~/components/interviews/questions/common/QuestionsTypes'; +import { useIntl } from '~/components/intl'; +import DropdownMenu from '~/components/ui/DropdownMenu'; + +type Props = Readonly<{ + isScrollModeValue: boolean; + slug: string; +}>; + +export default function QuestionQuizScrollModeToggle({ + isScrollModeValue, + slug, +}: Props) { + const intl = useIntl(); + const [isScrollMode, setIsScrollMode] = useState(isScrollModeValue); + const [questionHref, setQuestionHref] = useState({ + pageByPage: '#', + scroll: '#', + }); + const [renderToggleButton, setRenderToggleButton] = useState(false); + const isSmallTablet = useMediaQuery( + '(min-width: 641px) and (max-width: 768px)', + ); + const isMobile = useMediaQuery('(max-width: 640px)'); + + useEffect(() => { + function getPageURL() { + if (typeof window === 'undefined') { + return '#'; + } + + const urlObject = new URL(window.location.href); + + const language = urlObject.searchParams.get( + 'language', + ) as QuestionLanguage; + const framework = urlObject.searchParams.get( + 'framework', + ) as QuestionFramework; + + // Becasue quiz scroll mode is only supported for languages and frameworks + // Also don't show when the language is ts + setRenderToggleButton( + (language !== null && language !== 'ts') || framework !== null, + ); + if (isScrollModeValue) { + urlObject.pathname = `/questions/quiz/${slug}`; + urlObject.hash = ''; + } else { + urlObject.hash = slug; + if (framework) { + urlObject.pathname = `/questions/quiz/${QuestionFrameworkRawToSEOMapping[framework as QuestionFramework]}`; + } else { + urlObject.pathname = `/questions/quiz/${QuestionLanguageRawToSEOMapping[language as QuestionLanguage]}`; + } + } + + const newURL = url.format({ + hash: urlObject.hash, + pathname: urlObject.pathname, + search: urlObject.search, + }); + const oldURL = url.format({ + hash: window.location.hash, + pathname: window.location.pathname, + search: window.location.search, + }); + + if (isScrollModeValue) { + setQuestionHref({ + pageByPage: newURL, + scroll: oldURL, + }); + } else { + setQuestionHref({ + pageByPage: oldURL, + scroll: newURL, + }); + } + } + getPageURL(); + }, [slug, isScrollModeValue]); + + const options = [ + { + href: questionHref.scroll, + icon: RiPagesLine, + isSelected: isScrollMode, + label: intl.formatMessage({ + defaultMessage: 'Scrolling view', + description: 'Label for quiz scroll mode toggle button', + id: '/mpSVj', + }), + value: 'scroll', + }, + { + href: questionHref.pageByPage, + icon: RiTerminalWindowLine, + isSelected: !isScrollMode, + label: intl.formatMessage({ + defaultMessage: 'Page-by-page', + description: 'Label for quiz scroll mode toggle button', + id: 'rCqW54', + }), + value: 'page-by-page', + }, + ]; + const selectedOption = options.filter((item) => item.isSelected)[0]; + + if (!renderToggleButton) { + return null; + } + + return ( + + {options.map(({ href, icon, isSelected, label, value }) => ( + setIsScrollMode(value === 'scroll')} + /> + ))} + + ); +} diff --git a/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableList.tsx b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableList.tsx new file mode 100644 index 000000000..f2ed81c2b --- /dev/null +++ b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableList.tsx @@ -0,0 +1,222 @@ +'use client'; + +import clsx from 'clsx'; +import type { InterviewsQuestionQuizScrollableContent } from 'contentlayer/generated'; +import { useEffect, useRef, useState } from 'react'; +import type { VListHandle } from 'virtua'; +import { WindowVirtualizer } from 'virtua'; + +import { useEnterViewport } from '~/hooks/useEnterViewport'; +import useHashChange from '~/hooks/useHashChange'; + +import type { + QuestionFramework, + QuestionLanguage, + QuestionListTypeData, + QuestionQuiz, +} from '~/components/interviews/questions/common/QuestionsTypes'; +import QuestionsQuizContentLayout from '~/components/interviews/questions/content/quiz/QuestionsQuizContentLayout'; +import useQuestionCodingSorting from '~/components/interviews/questions/listings/filters/hooks/useQuestionCodingSorting'; +import { sortQuestionsMultiple } from '~/components/interviews/questions/listings/filters/QuestionsProcessor'; +import useQuestionsWithCompletionStatus from '~/components/interviews/questions/listings/items/useQuestionsWithCompletionStatus'; +import InterviewsStudyListBottomBar from '~/components/interviews/questions/listings/study-list/InterviewsStudyListBottomBar'; +import Divider from '~/components/ui/Divider'; +import Section from '~/components/ui/Heading/HeadingContext'; + +import useQuestionsAutoMarkAsComplete from '../../common/useQuestionsAutoMarkAsComplete'; +import QuestionQuizItem from './QuestionQuizItem'; +import QuestionQuizPageHeader from './QuestionQuizPageHeader'; +import QuestionQuizScrollableListIntroduction from './QuestionQuizScrollableListIntroduction'; +import QuestionQuizScrollModeToggle from './QuestionQuizScrollModeToggle'; + +type Props = Readonly<{ + description: string; + languageOrFramework: Extract | QuestionLanguage; + listType: QuestionListTypeData; + longDescription: InterviewsQuestionQuizScrollableContent; + questionsList: ReadonlyArray; + title: string; +}>; + +function QuestionQuizScrollableListItem({ + onEnterViewport, + question, +}: { + onEnterViewport: (isInView: boolean) => void; + question: QuestionQuiz; +}) { + const ref = useEnterViewport((isInView) => { + onEnterViewport(isInView); + }); + + return ; +} + +export default function QuestionQuizScrollableList({ + description, + languageOrFramework, + listType, + longDescription, + questionsList, + title, +}: Props) { + const timeoutRef = useRef(null); + const currentHash = useHashChange(); + const virtuaContainerRef = useRef(null); + const [isIntroductionSectionInView, setIsIntroductionSectionInView] = + useState(false); + + const isUserScroll = useRef(false); + + const questionsWithCompletionStatus = useQuestionsWithCompletionStatus( + questionsList.map((item) => item.metadata), + ); + + // Sorting. + const { sortFields } = useQuestionCodingSorting({ + listType, + }); + + // Processing. + const questions = sortQuestionsMultiple( + questionsWithCompletionStatus, + sortFields, + ); + + const currentQuestionIndex = questions.findIndex( + (question) => currentHash?.substring(1) === question.slug, + ); + const questionIndex = currentQuestionIndex === -1 ? 0 : currentQuestionIndex; + + useEffect(() => { + // Don't scroll to current question if hash change is triggered by user scrolling + if (currentQuestionIndex === -1 || isUserScroll.current) { + return; + } + + virtuaContainerRef.current?.scrollToIndex(currentQuestionIndex, { + offset: -120, + }); + }, [currentQuestionIndex, currentHash]); + + const currentQuestion = questions[questionIndex]; + const questionsMap = questionsList.reduce( + (acc, item) => { + acc[item.metadata.slug] = item; + + return acc; + }, + {} as Record, + ); + + // TODO: Later update with useDebounceCallback from 'useHooks-ts' when we upgrade the library + const debouncedHashChange = (hash: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + history.replaceState(null, '', `#${hash}`); + + // Dispatch hashChange event manually to trigger any listeners because replaceState + // does not trigger it. + window.dispatchEvent(new Event('hashchange')); + + isUserScroll.current = true; + setTimeout(() => { + isUserScroll.current = false; + }, 100); + }, 250); + }; + + useQuestionsAutoMarkAsComplete(currentQuestion); + + return ( + { + if (tab !== 'quiz') { + return null; + } + + return ( + + ); + }}> +
+
+ { + setIsIntroductionSectionInView(isInView); + if (!isInView) { + return; + } + debouncedHashChange('introduction'); + }} + /> + +
+ {}}> + {questions.map((question, index) => ( +
+ { + if (isIntroductionSectionInView || !isInView) { + return; + } + debouncedHashChange(question.slug); + }} + /> + {index !== questions.length - 1 && ( + + )} +
+ ))} +
+
+
+ + } + listIsShownInSidebarOnDesktop={true} + metadata={currentQuestion} + questionTitle={currentQuestion.title} + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE={null} + /> +
+
+ ); +} diff --git a/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableListIntroduction.tsx b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableListIntroduction.tsx new file mode 100644 index 000000000..79f0c8b5c --- /dev/null +++ b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizScrollableListIntroduction.tsx @@ -0,0 +1,99 @@ +import clsx from 'clsx'; +import { useEffect, useRef } from 'react'; +import url from 'url'; + +import { + QuestionFrameworkRawToSEOMapping, + QuestionLanguageRawToSEOMapping, +} from '~/data/QuestionCategories'; + +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; +import { useIntl } from '~/components/intl'; +import Anchor from '~/components/ui/Anchor'; +import Text from '~/components/ui/Text'; +import { + themeBackgroundElementEmphasizedStateColor, + themeBackgroundElementEmphasizedStateColor_Hover, +} from '~/components/ui/theme'; + +type QuestionClickEvent = Parameters< + NonNullable['onClick']> +>[0]; + +type Props = Readonly<{ + isActive: boolean; + listType: QuestionListTypeData; + onClick?: (event: QuestionClickEvent, href: string) => void; +}>; + +export default function QuestionQuizScrollableListIntroduction({ + isActive, + listType, + onClick, +}: Props) { + const ref = useRef(null); + const intl = useIntl(); + + const href = + listType.type === 'framework' + ? `/questions/quiz/${QuestionFrameworkRawToSEOMapping[listType.value]}` + : listType.type === 'language' + ? `/questions/quiz/${QuestionLanguageRawToSEOMapping[listType.value]}` + : '#'; + + const finalHref = url.format({ + pathname: href, + query: { + ...(listType.type === 'framework' + ? { framework: listType.value } + : listType.type === 'language' + ? { language: listType.value } + : {}), + tab: 'quiz', + }, + }); + + useEffect(() => { + if (!isActive) { + return; + } + + ref.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }, [isActive]); + + return ( +
+ { + onClick?.(event, finalHref); + }}> + {/* Extend touch target to entire panel */} + +
+ ); +} diff --git a/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizSidebarQuestionList.tsx b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizSidebarQuestionList.tsx index d07cfcbc3..9d6df331c 100644 --- a/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizSidebarQuestionList.tsx +++ b/apps/web/src/components/interviews/questions/content/quiz/QuestionQuizSidebarQuestionList.tsx @@ -1,35 +1,68 @@ import clsx from 'clsx'; import { useSelectedLayoutSegment } from 'next/navigation'; +import type { ReactNode } from 'react'; import { Suspense, useState } from 'react'; -import { questionListFilterNamespace } from '~/components/interviews/questions/common/QuestionHrefUtils'; +import useHashChange from '~/hooks/useHashChange'; + +import { + questionListFilterNamespace, + QuestionListTypeDefault, +} from '~/components/interviews/questions/common/QuestionHrefUtils'; +import type { + QuestionListTypeData, + QuestionPracticeFormat, +} from '~/components/interviews/questions/common/QuestionsTypes'; import InterviewsQuestionsListSlideOutSwitcher from '~/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutSwitcher'; import { useQuestionsListDataForType, useQuestionsListTypeCurrent, } from '~/components/interviews/questions/listings/utils/useQuestionsListDataForType'; -import { themeBorderColor } from '~/components/ui/theme'; +import type Anchor from '~/components/ui/Anchor'; import { hashQuestion } from '~/db/QuestionsUtils'; import InterviewsQuestionsListSlideOutContents from '../../listings/slideout/InterviewsQuestionsListSlideOutContents'; import type { QuestionListTypeWithLabel } from '../../listings/slideout/InterviewsQuestionsListSlideOutSwitcher'; -export default function QuestionQuizSidebarQuestionList() { +type QuestionClickEvent = Parameters< + NonNullable['onClick']> +>[0]; + +type Props = Readonly<{ + initialListType?: QuestionListTypeData; + renderQuestionsListTopAddOnItem?: ({ + listType, + onClick, + tab, + }: { + listType: QuestionListTypeData; + onClick?: (event: QuestionClickEvent, href: string) => void; + tab: QuestionPracticeFormat | undefined; + }) => ReactNode; +}>; + +export default function QuestionQuizSidebarQuestionList(props: Props) { // Because useQuestionsListTypeCurrent() uses useSearchParams() // Because of nuqs return ( - + ); } -function QuestionQuizSidebarQuestionListLoader() { +function QuestionQuizSidebarQuestionListLoader({ + initialListType: initialListTypeParam, + renderQuestionsListTopAddOnItem, +}: Props) { const studyListKey = undefined; const framework = undefined; - const listType = useQuestionsListTypeCurrent(studyListKey, framework); + const listType = + useQuestionsListTypeCurrent(studyListKey, framework) ?? + initialListTypeParam ?? + QuestionListTypeDefault; const { data, isLoading } = useQuestionsListDataForType(listType); const hidden = isLoading || data == null; @@ -41,16 +74,29 @@ function QuestionQuizSidebarQuestionListLoader() { const initialListType = { ...data.listType, label: data.title }; return ( - + ); } function QuestionsQuizSidebarQuestionListImpl({ initialListType, + renderQuestionsListTopAddOnItem, }: Readonly<{ initialListType: QuestionListTypeWithLabel; + renderQuestionsListTopAddOnItem?: ({ + listType, + tab, + }: { + listType: QuestionListTypeData; + onClick?: (event: QuestionClickEvent, href: string) => void; + tab: QuestionPracticeFormat | undefined; + }) => ReactNode; }>) { const segment = useSelectedLayoutSegment(); + const hash = useHashChange(); const [currentListType, setCurrentListType] = useState(initialListType); @@ -74,7 +120,7 @@ function QuestionsQuizSidebarQuestionListImpl({ })); } - const questionSlug = segment; + const questionSlug = (hash || segment || '').replace(/#/g, ''); const currentQuestionHash = questionSlug ? hashQuestion({ @@ -87,32 +133,41 @@ function QuestionsQuizSidebarQuestionListImpl({ initialListType.type !== currentListType.type || initialListType.value !== currentListType.value; + function handleOnClickQuestion(event: QuestionClickEvent, href: string) { + if (hasChangedList) { + event.preventDefault(); + setShowSwitchQuestionListDialog({ + href, + show: true, + type: 'question-click', + }); + } + } + return (
-
+
-
+
+ renderQuestionsListTopAddOnItem?.({ + listType, + onClick: handleOnClickQuestion, + tab, + }) + } showSwitchQuestionListDialog={showSwitchQuestionListDialog} - onClickQuestion={(event, href: string) => { - if (hasChangedList) { - event.preventDefault(); - setShowSwitchQuestionListDialog({ - href, - show: true, - type: 'question-click', - }); - } - }} + onClickQuestion={handleOnClickQuestion} onCloseSwitchQuestionListDialog={onCloseSwitchQuestionListDialog} onListTabChange={(newTab) => setCurrentListType({ diff --git a/apps/web/src/components/interviews/questions/content/quiz/QuestionsQuizContentLayout.tsx b/apps/web/src/components/interviews/questions/content/quiz/QuestionsQuizContentLayout.tsx index a841f758c..3ef5982af 100644 --- a/apps/web/src/components/interviews/questions/content/quiz/QuestionsQuizContentLayout.tsx +++ b/apps/web/src/components/interviews/questions/content/quiz/QuestionsQuizContentLayout.tsx @@ -9,14 +9,23 @@ import QuestionsQuizSidebarCollapser from '~/components/interviews/questions/con import Section from '~/components/ui/Heading/HeadingContext'; import { themeBorderColor } from '~/components/ui/theme'; +import type { QuestionListTypeData } from '../../common/QuestionsTypes'; import QuestionsQuizSidebarQuestionList from './QuestionQuizSidebarQuestionList'; import useQuestionsQuizSidebarExpanded from './useQuestionsQuizSidebarExpanded'; type Props = Readonly<{ children: React.ReactNode; + initialListType?: QuestionListTypeData; + renderQuestionsListTopAddOnItem?: React.ComponentProps< + typeof QuestionsQuizSidebarQuestionList + >['renderQuestionsListTopAddOnItem']; }>; -export default function QuestionsQuizContentLayout({ children }: Props) { +export default function QuestionsQuizContentLayout({ + children, + initialListType, + renderQuestionsListTopAddOnItem, +}: Props) { const [questionsQuizSidebarExpanded] = useQuestionsQuizSidebarExpanded(); const pathname = usePathname(); @@ -32,11 +41,16 @@ export default function QuestionsQuizContentLayout({ children }: Props) { {questionsQuizSidebarExpanded && (
- +
)} diff --git a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOut.tsx b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOut.tsx index f7ba1b128..f80e2d78c 100644 --- a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOut.tsx +++ b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOut.tsx @@ -34,7 +34,7 @@ type Props = Readonly<{ listIsShownInSidebarOnDesktop: boolean; listTabs?: ReadonlyArray; processedQuestions: ReadonlyArray; - slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE: string; + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE: string | null; title?: string; }>; @@ -64,12 +64,21 @@ function InterviewsQuestionsListSlideOutImpl({ // Have to be controlled because we don't want to // fetch the question lists for nothing - const [isSlideOutShown, setIsSlideOutShown] = useQueryState( - slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE, - parseAsBoolean.withDefault(false), - ); + const [isSlideOutShownQueryState, setIsSlideOutShownQueryState] = + useQueryState( + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE ?? '', + parseAsBoolean.withDefault(false), + ); + const [isSlideOutShownState, setIsSlideOutShownState] = useState(false); + const isSlideOutShown = slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE + ? isSlideOutShownQueryState + : isSlideOutShownState; + const setIsSlideOutShown = slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE + ? setIsSlideOutShownQueryState + : setIsSlideOutShownState; + const isMobile = useMediaQuery('(max-width: 640px)'); - const isDesktop = useMediaQuery('(min-width: 1367px)'); + const isDesktop = useMediaQuery('(min-width: 1025px)'); const router = useRouter(); const [currentListType, setCurrentListType] = useState(initialListType); diff --git a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutButton.tsx b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutButton.tsx index 74d2f09f2..6bb7580d9 100644 --- a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutButton.tsx +++ b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutButton.tsx @@ -4,10 +4,14 @@ import clsx from 'clsx'; import { Suspense } from 'react'; import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri'; -import { questionHrefFrameworkSpecificAndListType } from '~/components/interviews/questions/common/QuestionHrefUtils'; +import { + questionHrefFrameworkSpecificAndListType, + QuestionListTypeDefault, +} from '~/components/interviews/questions/common/QuestionHrefUtils'; import type { QuestionFramework, QuestionHash, + QuestionListTypeData, QuestionMetadataWithCompletedStatus, } from '~/components/interviews/questions/common/QuestionsTypes'; import useQuestionCodingSorting from '~/components/interviews/questions/listings/filters/hooks/useQuestionCodingSorting'; @@ -32,8 +36,9 @@ import type { QuestionListTypeWithLabel } from './InterviewsQuestionsListSlideOu type Props = Readonly<{ currentQuestionHash: QuestionHash; framework?: QuestionFramework; + initialListType?: QuestionListTypeData; listIsShownInSidebarOnDesktop: boolean; - slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE: string; + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE: string | null; studyListKey?: string; }>; @@ -48,11 +53,15 @@ export default function InterviewsQuestionsListSlideOutButton(props: Props) { function InterviewsQuestionsListSlideOutButtonWithLoader({ currentQuestionHash, framework, + initialListType, listIsShownInSidebarOnDesktop, slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE, studyListKey, }: Props) { - const listType = useQuestionsListTypeCurrent(studyListKey, framework); + const listType = + useQuestionsListTypeCurrent(studyListKey, framework) ?? + initialListType ?? + QuestionListTypeDefault; const { data, isLoading } = useQuestionsListDataForType(listType); const questionsWithCompletionStatus = useQuestionsWithCompletionStatus( diff --git a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutContents.tsx b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutContents.tsx index 8a92434f4..0a33ac776 100644 --- a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutContents.tsx +++ b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutContents.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx'; import { useRouter } from 'next/navigation'; +import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { RiArrowDownSLine, @@ -188,6 +189,13 @@ type Props = Readonly<{ >['onClickQuestion']; onCloseSwitchQuestionListDialog: () => void; onListTabChange?: (newTab: QuestionPracticeFormat) => void; + renderQuestionsListTopAddOnItem?: ({ + listType, + tab, + }: { + listType: QuestionListTypeData; + tab: QuestionPracticeFormat | undefined; + }) => ReactNode; setFirstQuestionHref?: (href: string) => void; showSwitchQuestionListDialog: Readonly<{ href: string | null; @@ -206,6 +214,7 @@ export default function InterviewsQuestionsListSlideOutContents({ onClickQuestion, onCloseSwitchQuestionListDialog, onListTabChange, + renderQuestionsListTopAddOnItem, setFirstQuestionHref, showSwitchQuestionListDialog, }: Props) { @@ -389,13 +398,13 @@ export default function InterviewsQuestionsListSlideOutContents({ return ( <> -
+
{ event.preventDefault(); }}> -
+
{listTabs && ( -
+
{ const labels: Record = { @@ -482,7 +492,7 @@ export default function InterviewsQuestionsListSlideOutContents({
)} {questionAttributesUnion.formats.size > 1 && ( -
+
+ renderQuestionsListTopAddOnItem?.({ + listType, + tab: data?.listType.tab, + }) + } showCompanyPaywall={showCompanyPaywall} onClickQuestion={onClickQuestion} /> )}
- { - onCloseSwitchQuestionListDialog(); - onCancelSwitchStudyList?.(); - }} - onClose={() => { - onCloseSwitchQuestionListDialog(); - }} - onConfirm={() => { - if (!showSwitchQuestionListDialog.href) { - return; - } + {/* Wrapped with showSwitchQuestionListDialog.show condition to avoid + dialog appearing twice when we trigger this */} + {showSwitchQuestionListDialog.show && ( + { + onCloseSwitchQuestionListDialog(); + onCancelSwitchStudyList?.(); + }} + onClose={() => { + onCloseSwitchQuestionListDialog(); + }} + onConfirm={() => { + if (!showSwitchQuestionListDialog.href) { + return; + } - onCloseSwitchQuestionListDialog(); - router.push(showSwitchQuestionListDialog.href); - }}> - {showSwitchQuestionListDialog.type === 'question-click' ? ( - - ) : ( - - )} - + onCloseSwitchQuestionListDialog(); + router.push(showSwitchQuestionListDialog.href); + }}> + {showSwitchQuestionListDialog.type === 'question-click' ? ( + + ) : ( + + )} + + )} ); } diff --git a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionList.tsx b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionList.tsx index de6f4996a..230dde915 100644 --- a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionList.tsx +++ b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionList.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx'; +import type { ReactNode } from 'react'; import VignetteOverlay from '~/components/common/VignetteOverlay'; import { questionHrefFrameworkSpecificAndListType } from '~/components/interviews/questions/common/QuestionHrefUtils'; @@ -29,6 +30,7 @@ type Props = Readonly<{ typeof InterviewsQuestionsListSlideOutQuestionListItem >['onClick']; questions: ReadonlyArray; + renderQuestionsListTopAddOnItem?: () => ReactNode; showCompanyPaywall?: boolean; }>; @@ -43,6 +45,7 @@ export default function InterviewsQuestionsListSlideOutQuestionList< mode, onClickQuestion, questions, + renderQuestionsListTopAddOnItem, showCompanyPaywall, }: Props) { const intl = useIntl(); @@ -74,7 +77,7 @@ export default function InterviewsQuestionsListSlideOutQuestionList< ); return ( -
+
)} + {renderQuestionsListTopAddOnItem && renderQuestionsListTopAddOnItem()} {questions.map((questionMetadata, index) => { const hasCompletedQuestion = checkIfCompletedQuestion?.(questionMetadata); diff --git a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionListItem.tsx b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionListItem.tsx index 5416c4288..c28bdecd6 100644 --- a/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionListItem.tsx +++ b/apps/web/src/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutQuestionListItem.tsx @@ -85,8 +85,8 @@ export default function InterviewsQuestionsListSlideOutQuestionListItem< themeBackgroundElementEmphasizedStateColor_Hover, isActiveQuestion && themeBackgroundElementEmphasizedStateColor, )}> -
-
+
+
{checkIfCompletedQuestion != null && ( ; @@ -38,6 +49,8 @@ function DropdownContent({ openPricingDialog: (feature: InterviewsPurchasePremiumFeature) => void; }>) { const intl = useIntl(); + const searchParams = useSearchParams(); + const currentTab = searchParams?.get('tab'); const { data: questionLists, isLoading } = trpc.questionLists.get.useQuery(); const { userProfile } = useUserProfile(); const formatData = useQuestionFormatsData(); @@ -154,6 +167,9 @@ function DropdownContent({ ({ menuType: 'item', ...item, + tab: questionsFrameworkTabs(item.value)?.includes('quiz') + ? ((currentTab || item.tab) as QuestionPracticeFormat) + : item.tab, }) as const, ), { menuType: 'divider', value: 'divider-1' }, @@ -171,6 +187,9 @@ function DropdownContent({ ({ menuType: 'item', ...item, + tab: questionsLanguageTabs().includes('quiz') + ? ((currentTab || item.tab) as QuestionPracticeFormat) + : item.tab, }) as const, ), ], @@ -182,6 +201,54 @@ function DropdownContent({ }), menuType: 'list', }, + { + items: [ + ...questionLists.languages + .filter((item) => item.value !== 'ts') + .map( + (item) => + ({ + ...item, + label: intl.formatMessage( + { + defaultMessage: '{category} Quiz Questions', + description: 'Label for Quiz question', + id: '3A1yG3', + }, + { + category: QuestionLanguageLabels[item.value], + }, + ), + menuType: 'item', + tab: 'quiz', + type: 'language', + }) as const, + ), + { + label: intl.formatMessage( + { + defaultMessage: '{category} Quiz Questions', + description: 'Label for Quiz question', + id: '3A1yG3', + }, + { + category: 'React', + }, + ), + menuType: 'item', + tab: 'quiz', + type: 'framework', + value: 'react', + }, + ], + key: 'quiz', + label: intl.formatMessage({ + defaultMessage: 'Quizzes', + description: 'Tile for quiz question type', + id: 'QqddKP', + }), + menuType: 'list', + }, ]; function activeAccordionItem() { diff --git a/apps/web/src/components/interviews/questions/listings/stats/QuestionCount.tsx b/apps/web/src/components/interviews/questions/listings/stats/QuestionCount.tsx index 1ad776aa4..d8c898708 100644 --- a/apps/web/src/components/interviews/questions/listings/stats/QuestionCount.tsx +++ b/apps/web/src/components/interviews/questions/listings/stats/QuestionCount.tsx @@ -4,3 +4,8 @@ export const QuestionCountReact = 100; export const QuestionCountCoding = 251; export const QuestionCountSystemDesign = 19; export const QuestionCountQuiz = 283; +export const QuestionCountReactCoding = 91; +export const QuestionCountJavaScriptCoding = 288; +export const QuestionCountTypeScriptCoding = 288; +export const QuestionCountHTMLCoding = 70; +export const QuestionCountCSSCoding = 43; diff --git a/apps/web/src/components/interviews/questions/listings/study-list/InterviewsStudyListBottomBar.tsx b/apps/web/src/components/interviews/questions/listings/study-list/InterviewsStudyListBottomBar.tsx index d986b56bc..49d722031 100644 --- a/apps/web/src/components/interviews/questions/listings/study-list/InterviewsStudyListBottomBar.tsx +++ b/apps/web/src/components/interviews/questions/listings/study-list/InterviewsStudyListBottomBar.tsx @@ -1,82 +1,90 @@ 'use client'; -import { useUser } from '@supabase/auth-helpers-react'; import clsx from 'clsx'; +import type { ReactNode } from 'react'; import { Suspense } from 'react'; import QuestionProgressAction from '~/components/interviews/questions/common/QuestionProgressAction'; import QuestionReportIssueButton from '~/components/interviews/questions/common/QuestionReportIssueButton'; +import type { QuestionListTypeData } from '~/components/interviews/questions/common/QuestionsTypes'; import InterviewsQuestionsListSlideOutButton from '~/components/interviews/questions/listings/slideout/InterviewsQuestionsListSlideOutButton'; -import { - themeBackgroundDarkColor, - themeBorderColor, -} from '~/components/ui/theme'; +import Text from '~/components/ui/Text'; +import { themeBackgroundColor, themeBorderColor } from '~/components/ui/theme'; -import { useQueryQuestionProgress } from '~/db/QuestionsProgressClient'; import { hashQuestion } from '~/db/QuestionsUtils'; type Props = Readonly<{ allowMarkComplete?: boolean; + initialListType?: QuestionListTypeData; + leftAddOnItem?: ReactNode; listIsShownInSidebarOnDesktop: boolean; metadata: React.ComponentProps['metadata']; + questionTitle?: string; + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE?: string | null; studyListKey?: string; }>; export default function InterviewsStudyListBottomBar({ allowMarkComplete = true, + initialListType, + leftAddOnItem, listIsShownInSidebarOnDesktop, metadata, + questionTitle, + slideOutSearchParam_MUST_BE_UNIQUE_ON_PAGE = 'qns_slideout', studyListKey, }: Props) { - const user = useUser(); - const { isLoading } = useQueryQuestionProgress( - metadata, - studyListKey ?? null, - ); - return (
-
- - + {questionTitle} + + )} +
+
+ + + +
+
+ - -
-
- -
-
- - {allowMarkComplete && ( - - )} + {leftAddOnItem} +
+
+
+ + {leftAddOnItem} +
+ {allowMarkComplete && ( + + )} +
); diff --git a/apps/web/src/components/interviews/questions/listings/utils/QuestionsListTabsConfig.ts b/apps/web/src/components/interviews/questions/listings/utils/QuestionsListTabsConfig.ts index 1e7206012..8b8a4d970 100644 --- a/apps/web/src/components/interviews/questions/listings/utils/QuestionsListTabsConfig.ts +++ b/apps/web/src/components/interviews/questions/listings/utils/QuestionsListTabsConfig.ts @@ -1,8 +1,24 @@ import type { + QuestionFramework, QuestionListTypeData, QuestionPracticeFormat, } from '~/components/interviews/questions/common/QuestionsTypes'; +export function questionsLanguageTabs() { + return ['coding', 'quiz'] as const; +} + +export function questionsFrameworkTabs(framework: QuestionFramework) { + switch (framework) { + case 'react': { + return ['coding', 'quiz'] as const; + } + default: { + return null; + } + } +} + export function questionsListTabsConfig( listType: QuestionListTypeData | null | undefined, ): ReadonlyArray | null { @@ -15,12 +31,12 @@ export function questionsListTabsConfig( return ['coding', 'system-design', 'quiz']; } case 'language': { - return listType.tab ? ['coding', 'quiz'] : null; + return listType.tab ? questionsLanguageTabs() : null; } case 'framework': { switch (listType.value) { case 'react': { - return listType.tab ? ['coding', 'quiz'] : null; + return listType.tab ? questionsFrameworkTabs('react') : null; } default: { return null; diff --git a/apps/web/src/components/interviews/questions/listings/utils/useQuestionsListDataForType.ts b/apps/web/src/components/interviews/questions/listings/utils/useQuestionsListDataForType.ts index 8b76488a1..ec36b760f 100644 --- a/apps/web/src/components/interviews/questions/listings/utils/useQuestionsListDataForType.ts +++ b/apps/web/src/components/interviews/questions/listings/utils/useQuestionsListDataForType.ts @@ -2,7 +2,6 @@ import { useSearchParams } from 'next/navigation'; import { trpc } from '~/hooks/trpc'; -import { QuestionListTypeDefault } from '~/components/interviews/questions/common/QuestionHrefUtils'; import type { QuestionFormatForList, QuestionFramework, @@ -78,7 +77,7 @@ export function useQuestionsListTypeCurrent( }; } - return QuestionListTypeDefault; + return null; } export function useQuestionsListDataForType( diff --git a/apps/web/src/components/interviews/questions/metadata/QuestionMetadataSection.tsx b/apps/web/src/components/interviews/questions/metadata/QuestionMetadataSection.tsx index 3133bd667..fbc8583da 100644 --- a/apps/web/src/components/interviews/questions/metadata/QuestionMetadataSection.tsx +++ b/apps/web/src/components/interviews/questions/metadata/QuestionMetadataSection.tsx @@ -21,6 +21,7 @@ type MetadataElement = | 'users_completed'; type Props = Readonly<{ + className?: string; elements?: ReadonlyArray; justify?: 'center' | 'start'; metadata: QuestionMetadata; @@ -36,6 +37,7 @@ const DEFAULT_ELEMENTS: ReadonlyArray = [ ]; export default function QuestionMetadataSection({ + className, elements = DEFAULT_ELEMENTS, justify = 'start', metadata, @@ -47,7 +49,8 @@ export default function QuestionMetadataSection({ 'flex flex-wrap items-center', justify === 'start' && 'justify-start', justify === 'center' && 'justify-center', - 'gap-x-6 gap-y-4 ', + 'gap-x-6 gap-y-4', + className, )}> {elements.includes('author') && metadata.author && ( diff --git a/apps/web/src/components/mdx/MDXComponentsForQuiz.tsx b/apps/web/src/components/mdx/MDXComponentsForQuiz.tsx index 7666954a5..58e1a8f17 100644 --- a/apps/web/src/components/mdx/MDXComponentsForQuiz.tsx +++ b/apps/web/src/components/mdx/MDXComponentsForQuiz.tsx @@ -12,4 +12,24 @@ const MDXComponentsForQuiz = Object.freeze({ pre: (props: ComponentProps<'pre'>) => , }); -export default MDXComponentsForQuiz; +const MDXComponentsForScrollableQuiz = Object.freeze({ + ...MDXComponentsForQuiz, + h1: (props: ComponentProps) => ( + + ), + h2: (props: ComponentProps) => ( + + ), + h3: (props: ComponentProps) => ( + + ), + h4: (props: ComponentProps) => ( + + ), + h5: (props: ComponentProps) => ( + + ), + pre: (props: ComponentProps<'pre'>) => , +}); + +export { MDXComponentsForQuiz, MDXComponentsForScrollableQuiz }; diff --git a/apps/web/src/components/ui/Anchor/Anchor.tsx b/apps/web/src/components/ui/Anchor/Anchor.tsx index 86999ebd9..75ee2c70a 100644 --- a/apps/web/src/components/ui/Anchor/Anchor.tsx +++ b/apps/web/src/components/ui/Anchor/Anchor.tsx @@ -61,6 +61,7 @@ function Anchor( typeof href === 'string' ? /^(http|mailto)/.test(href ?? '') : false; const finalHref = href ?? '#'; + const rel = relProp ?? (isExternalURL ? 'noreferrer noopener' : undefined); const className = anchorVariants({ className: clsx( diff --git a/apps/web/src/components/ui/Tabs/TabsUnderline.tsx b/apps/web/src/components/ui/Tabs/TabsUnderline.tsx index f2360f3a0..bef5f28ce 100644 --- a/apps/web/src/components/ui/Tabs/TabsUnderline.tsx +++ b/apps/web/src/components/ui/Tabs/TabsUnderline.tsx @@ -27,6 +27,7 @@ type TabAlignment = 'start' | 'stretch'; type Props = Readonly<{ alignment?: TabAlignment; + className?: string; display?: TabDisplay; label: string; onSelect?: (value: T) => void; @@ -76,6 +77,7 @@ const sizeClasses: Record< function TabsUnderline( { alignment = 'start', + className, display = 'block', label, onSelect, @@ -95,6 +97,7 @@ function TabsUnderline( className={clsx('overflow-y-hidden', displayClasses[display], [ 'border-b', themeBorderElementColor, + className, ])}>