diff --git a/src/components/OpenSourceStat.astro b/src/components/OpenSourceStat.astro
index e95a94bd8..f6e2bb063 100644
--- a/src/components/OpenSourceStat.astro
+++ b/src/components/OpenSourceStat.astro
@@ -44,7 +44,7 @@ const isDiscordMembers = text.toLowerCase() === 'discord members';
}
{value}
diff --git a/src/components/Projects/EmptySolutions.tsx b/src/components/Projects/EmptySolutions.tsx
new file mode 100644
index 000000000..dcaf5b591
--- /dev/null
+++ b/src/components/Projects/EmptySolutions.tsx
@@ -0,0 +1,29 @@
+import { Blocks, CodeXml } from 'lucide-react';
+
+type EmptySolutionsProps = {
+ projectId: string;
+};
+
+export function EmptySolutions(props: EmptySolutionsProps) {
+ const { projectId } = props;
+
+ return (
+
+
+
+ No solutions submitted yet
+
+
+ Be the first to submit a solution for this project
+
+
+
+ );
+}
diff --git a/src/components/Projects/LeavingRoadmapWarningModal.tsx b/src/components/Projects/LeavingRoadmapWarningModal.tsx
new file mode 100644
index 000000000..9a48846df
--- /dev/null
+++ b/src/components/Projects/LeavingRoadmapWarningModal.tsx
@@ -0,0 +1,64 @@
+import { ArrowUpRight, X } from 'lucide-react';
+import { Modal } from '../Modal';
+import { SubmissionRequirement } from './SubmissionRequirement.tsx';
+
+type LeavingRoadmapWarningModalProps = {
+ onClose: () => void;
+ onContinue: () => void;
+};
+
+export function LeavingRoadmapWarningModal(
+ props: LeavingRoadmapWarningModalProps,
+) {
+ const { onClose, onContinue } = props;
+
+ return (
+
+ Leaving roadmap.sh
+
+ You are about to visit the project solution on GitHub. We recommend you
+ to follow these tips before you leave.
+
+
+
+
+ Make sure to come back and{' '}
+ leave an upvote{' '}
+ if you liked the solution. It helps the author and the community.
+
+
+
+ If you have feedback on the solution, open an issue or a pull request
+ on the{' '}
+
+ solution repository
+
+ .
+
+
+
+ Downvote the solution if it is{' '}
+
+ incorrect or misleading
+
+ . It helps the community. It helps the community.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Projects/ListProjectSolutions.tsx b/src/components/Projects/ListProjectSolutions.tsx
new file mode 100644
index 000000000..28542a88f
--- /dev/null
+++ b/src/components/Projects/ListProjectSolutions.tsx
@@ -0,0 +1,327 @@
+import { useEffect, useState } from 'react';
+import { useToast } from '../../hooks/use-toast';
+import { httpGet, httpPost } from '../../lib/http';
+import { LoadingSolutions } from './LoadingSolutions';
+import { EmptySolutions } from './EmptySolutions';
+import { ThumbsDown, ThumbsUp } from 'lucide-react';
+import { getRelativeTimeString } from '../../lib/date';
+import { Pagination } from '../Pagination/Pagination';
+import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
+import { pageProgressMessage } from '../../stores/page';
+import { LeavingRoadmapWarningModal } from './LeavingRoadmapWarningModal';
+import { isLoggedIn } from '../../lib/jwt';
+import { showLoginPopup } from '../../lib/popup';
+import { VoteButton } from './VoteButton.tsx';
+import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
+import { cn } from '../../lib/classname.ts';
+
+export interface ProjectStatusDocument {
+ _id?: string;
+
+ userId: string;
+ projectId: string;
+
+ startedAt?: Date;
+ submittedAt?: Date;
+ repositoryUrl?: string;
+
+ upvotes: number;
+ downvotes: number;
+
+ isVisible?: boolean;
+
+ updated1t: Date;
+}
+
+const allowedVoteType = ['upvote', 'downvote'] as const;
+export type AllowedVoteType = (typeof allowedVoteType)[number];
+
+type ListProjectSolutionsResponse = {
+ data: (ProjectStatusDocument & {
+ user: {
+ id: string;
+ name: string;
+ avatar: string;
+ };
+ voteType?: AllowedVoteType | 'none';
+ })[];
+ totalCount: number;
+ totalPages: number;
+ currPage: number;
+ perPage: number;
+};
+
+type QueryParams = {
+ p?: string;
+};
+
+type PageState = {
+ currentPage: number;
+};
+
+const VISITED_SOLUTIONS_KEY = 'visited-project-solutions';
+
+type ListProjectSolutionsProps = {
+ projectId: string;
+};
+
+const submittedAlternatives = [
+ 'submitted their solution',
+ 'got it done',
+ 'submitted their take',
+ 'finished the project',
+ 'submitted their work',
+ 'completed the project',
+ 'got it done',
+ 'delivered their project',
+ 'handed in their solution',
+ 'provided their deliverables',
+ 'submitted their approach',
+ 'sent in their project',
+ 'presented their take',
+ 'shared their completed task',
+ 'submitted their approach',
+ 'completed it',
+ 'finalized their solution',
+ 'delivered their approach',
+ 'turned in their project',
+ 'submitted their final draft',
+ 'delivered their solution',
+];
+
+export function ListProjectSolutions(props: ListProjectSolutionsProps) {
+ const { projectId } = props;
+
+ const toast = useToast();
+ const [pageState, setPageState] = useState
({
+ currentPage: 0,
+ });
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [solutions, setSolutions] = useState();
+ const [alreadyVisitedSolutions, setAlreadyVisitedSolutions] = useState<
+ Record
+ >({});
+ const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState<
+ ListProjectSolutionsResponse['data'][number] | null
+ >(null);
+
+ const loadSolutions = async (page = 1) => {
+ const { response, error } = await httpGet(
+ `${import.meta.env.PUBLIC_API_URL}/v1-list-project-solutions/${projectId}`,
+ {
+ currPage: page,
+ },
+ );
+
+ if (error || !response) {
+ toast.error(error?.message || 'Failed to load project solutions');
+ setIsLoading(false);
+ return;
+ }
+
+ setSolutions(response);
+ };
+
+ const handleSubmitVote = async (
+ solutionId: string,
+ voteType: AllowedVoteType,
+ ) => {
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
+ pageProgressMessage.set('Submitting vote...');
+ const { response, error } = await httpPost(
+ `${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`,
+ {
+ voteType,
+ },
+ );
+
+ if (error || !response) {
+ toast.error(error?.message || 'Failed to submit vote');
+ pageProgressMessage.set('');
+ return;
+ }
+
+ pageProgressMessage.set('');
+ setSolutions((prev) => {
+ if (!prev) {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ data: prev.data.map((solution) => {
+ if (solution._id === solutionId) {
+ return {
+ ...solution,
+ upvotes: response?.upvotes || 0,
+ downvotes: response?.downvotes || 0,
+ voteType,
+ };
+ }
+
+ return solution;
+ }),
+ };
+ });
+ };
+
+ useEffect(() => {
+ const queryParams = getUrlParams() as QueryParams;
+ const alreadyVisitedSolutions = JSON.parse(
+ localStorage.getItem(VISITED_SOLUTIONS_KEY) || '{}',
+ );
+
+ setAlreadyVisitedSolutions(alreadyVisitedSolutions);
+ setPageState({
+ currentPage: +(queryParams.p || '1'),
+ });
+ }, []);
+
+ useEffect(() => {
+ setIsLoading(true);
+ if (!pageState.currentPage) {
+ return;
+ }
+
+ if (pageState.currentPage !== 1) {
+ setUrlParams({
+ p: String(pageState.currentPage),
+ });
+ } else {
+ deleteUrlParam('p');
+ }
+
+ loadSolutions(pageState.currentPage).finally(() => {
+ setIsLoading(false);
+ });
+ }, [pageState]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ const isEmpty = solutions?.data.length === 0;
+ if (isEmpty) {
+ return ;
+ }
+
+ const leavingRoadmapModal = showLeavingRoadmapModal ? (
+ setShowLeavingRoadmapModal(null)}
+ onContinue={() => {
+ const visitedSolutions = {
+ ...alreadyVisitedSolutions,
+ [showLeavingRoadmapModal._id!]: true,
+ };
+ localStorage.setItem(
+ VISITED_SOLUTIONS_KEY,
+ JSON.stringify(visitedSolutions),
+ );
+
+ window.open(showLeavingRoadmapModal.repositoryUrl, '_blank');
+ }}
+ />
+ ) : null;
+
+ return (
+
+ {leavingRoadmapModal}
+
+
+ {solutions?.data.map((solution, counter) => {
+ const isVisited = alreadyVisitedSolutions[solution._id!];
+ const avatar = solution.user.avatar || '';
+
+ return (
+
+
+
+
+ {solution.user.name}
+
+
+ {submittedAlternatives[
+ counter % submittedAlternatives.length
+ ] || 'submitted their solution'}
+ {' '}
+
+ {getRelativeTimeString(solution?.submittedAt!)}
+
+
+
+
+
+ );
+ })}
+
+
+ {(solutions?.totalPages || 0) > 1 && (
+
+
{
+ setPageState({
+ ...pageState,
+ currentPage: page,
+ });
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/components/Projects/LoadingSolutions.tsx b/src/components/Projects/LoadingSolutions.tsx
new file mode 100644
index 000000000..f327fd81e
--- /dev/null
+++ b/src/components/Projects/LoadingSolutions.tsx
@@ -0,0 +1,44 @@
+import { isMobileScreen } from '../../lib/is-mobile.ts';
+
+export function LoadingSolutions() {
+ const totalCount = isMobileScreen() ? 3 : 11;
+
+ const loadingRow = (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ return (
+
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+ {loadingRow}
+
+ );
+}
diff --git a/src/components/Projects/ProjectTabs.tsx b/src/components/Projects/ProjectTabs.tsx
new file mode 100644
index 000000000..9c9931d0d
--- /dev/null
+++ b/src/components/Projects/ProjectTabs.tsx
@@ -0,0 +1,69 @@
+import { cn } from '../../lib/classname';
+import {
+ Blocks,
+ BoxSelect,
+ type LucideIcon,
+ StickyNote,
+ Text,
+} from 'lucide-react';
+
+export const allowedProjectTabs = ['details', 'solutions'] as const;
+export type AllowedProjectTab = (typeof allowedProjectTabs)[number];
+
+type TabButtonProps = {
+ text: string;
+ icon: LucideIcon;
+ smText?: string;
+ isActive?: boolean;
+ href: string;
+};
+
+function TabButton(props: TabButtonProps) {
+ const { text, icon: ButtonIcon, smText, isActive, href } = props;
+
+ return (
+
+ {ButtonIcon && }
+ {text}
+ {smText && {smText}}
+
+ {isActive && (
+
+ )}
+
+ );
+}
+
+type ProjectTabsProps = {
+ activeTab: AllowedProjectTab;
+ projectId: string;
+};
+
+export function ProjectTabs(props: ProjectTabsProps) {
+ const { activeTab, projectId } = props;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/Projects/StartProjectModal.tsx b/src/components/Projects/StartProjectModal.tsx
new file mode 100644
index 000000000..69212ee6e
--- /dev/null
+++ b/src/components/Projects/StartProjectModal.tsx
@@ -0,0 +1,169 @@
+import { Check, CopyIcon, ServerCrash } from 'lucide-react';
+import { Modal } from '../Modal';
+import { getRelativeTimeString } from '../../lib/date';
+import { useEffect, useState } from 'react';
+import { Spinner } from '../ReactIcons/Spinner.tsx';
+import { httpPost } from '../../lib/http.ts';
+import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
+import { useCopyText } from '../../hooks/use-copy-text.ts';
+
+type StepLabelProps = {
+ label: string;
+};
+
+function StepLabel(props: StepLabelProps) {
+ const { label } = props;
+
+ return (
+
+ {label}
+
+ );
+}
+
+type StartProjectModalProps = {
+ projectId: string;
+ onClose: () => void;
+ startedAt?: Date;
+ onStarted: (startedAt: Date) => void;
+};
+
+export function StartProjectModal(props: StartProjectModalProps) {
+ const { onClose, startedAt, onStarted, projectId } = props;
+
+ const [isStartingProject, setIsStartingProject] = useState(true);
+ const [error, setError] = useState();
+
+ const { isCopied, copyText } = useCopyText();
+
+ const projectUrl = `${import.meta.env.PUBLIC_APP_URL}/projects/${projectId}`;
+
+ const formattedStartedAt = startedAt ? getRelativeTimeString(startedAt) : '';
+
+ async function handleStartProject() {
+ if (!projectId || startedAt) {
+ return;
+ }
+
+ setIsStartingProject(true);
+ const { response, error } = await httpPost<{
+ startedAt: Date;
+ }>(`${import.meta.env.PUBLIC_API_URL}/v1-start-project/${projectId}`, {});
+
+ if (error || !response) {
+ setError(error?.message || 'Failed to start project');
+ setIsStartingProject(false);
+ return;
+ }
+
+ onStarted(response.startedAt);
+ }
+
+ useEffect(() => {
+ handleStartProject().finally(() => setIsStartingProject(false));
+ }, []);
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ if (isStartingProject) {
+ return (
+
+
+
+
Starting project ..
+
+
+ );
+ }
+
+ return (
+
+
+
+ Project started{' '}
+ {formattedStartedAt}
+
+
+ Start Building
+
+
+ Follow these steps to complete the project.
+
+
+
+
+ 1. Create a new public repository on GitHub.
+
+
+
+ 2. Complete the project according to the requirements and push your code
+ to the GitHub repository.
+
+
+
+ 3. Add a README file with instructions to run the project and the{' '}
+
+
+
+ 4. Once done, submit your solution to help the others learn and get feedback
+ from the community.
+
+
+
+
+
+ If you get stuck, you can always ask for help in the community{' '}
+
+ chat on discord
+
+ .
+
+
+
+
+
+ );
+}
diff --git a/src/components/Projects/StatusStepper/MilestoneStep.tsx b/src/components/Projects/StatusStepper/MilestoneStep.tsx
new file mode 100644
index 000000000..cb7f965e5
--- /dev/null
+++ b/src/components/Projects/StatusStepper/MilestoneStep.tsx
@@ -0,0 +1,37 @@
+import { Check, type LucideIcon } from 'lucide-react';
+
+type MilestoneStepProps = {
+ icon: LucideIcon;
+ text: string;
+ isCompleted?: boolean;
+ isActive?: boolean;
+};
+
+export function MilestoneStep(props: MilestoneStepProps) {
+ const { icon: DisplayIcon, text, isActive = false, isCompleted } = props;
+
+ if (isActive) {
+ return (
+
+
+ {text}
+
+ );
+ }
+
+ if (isCompleted) {
+ return (
+
+
+ {text}
+
+ );
+ }
+
+ return (
+
+
+ {text}
+
+ );
+}
diff --git a/src/components/Projects/StatusStepper/ProjectStepper.tsx b/src/components/Projects/StatusStepper/ProjectStepper.tsx
new file mode 100644
index 000000000..dee1a485d
--- /dev/null
+++ b/src/components/Projects/StatusStepper/ProjectStepper.tsx
@@ -0,0 +1,245 @@
+import { Flag, Play, Send } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { cn } from '../../../lib/classname.ts';
+import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx';
+import { StepperAction } from './StepperAction.tsx';
+import { StepperStepSeparator } from './StepperStepSeparator.tsx';
+import { MilestoneStep } from './MilestoneStep.tsx';
+import { httpGet } from '../../../lib/http.ts';
+import { StartProjectModal } from '../StartProjectModal.tsx';
+import { getRelativeTimeString } from '../../../lib/date.ts';
+import { isLoggedIn } from '../../../lib/jwt.ts';
+import { showLoginPopup } from '../../../lib/popup.ts';
+import { SubmitProjectModal } from '../SubmitProjectModal.tsx';
+
+type ProjectStatusResponse = {
+ id?: string;
+
+ startedAt?: Date;
+ submittedAt?: Date;
+ repositoryUrl?: string;
+
+ upvotes: number;
+ downvotes: number;
+};
+
+type ProjectStepperProps = {
+ projectId: string;
+};
+
+export function ProjectStepper(props: ProjectStepperProps) {
+ const { projectId } = props;
+
+ const stickyElRef = useRef(null);
+ const isSticky = useStickyStuck(stickyElRef, 8);
+
+ const [isStartingProject, setIsStartingProject] = useState(false);
+ const [isSubmittingProject, setIsSubmittingProject] = useState(false);
+
+ const [error, setError] = useState(null);
+ const [activeStep, setActiveStep] = useState(0);
+ const [isLoadingStatus, setIsLoadingStatus] = useState(true);
+ const [projectStatus, setProjectStatus] = useState({
+ upvotes: 0,
+ downvotes: 0,
+ });
+
+ async function loadProjectStatus() {
+ setIsLoadingStatus(true);
+
+ const { response, error } = await httpGet(
+ `${import.meta.env.PUBLIC_API_URL}/v1-project-status/${projectId}`,
+ {},
+ );
+
+ if (error || !response) {
+ setError(error?.message || 'Error loading project status');
+ setIsLoadingStatus(false);
+ return;
+ }
+
+ const { startedAt, submittedAt, upvotes } = response;
+
+ if (upvotes >= 10) {
+ setActiveStep(4);
+ } else if (upvotes >= 5) {
+ setActiveStep(3);
+ } else if (submittedAt) {
+ setActiveStep(2);
+ } else if (startedAt) {
+ setActiveStep(1);
+ }
+
+ setProjectStatus(response);
+ setIsLoadingStatus(false);
+ }
+
+ useEffect(() => {
+ loadProjectStatus().finally(() => {});
+ }, []);
+
+ return (
+
+ {isSubmittingProject && (
+
setIsSubmittingProject(false)}
+ projectId={projectId}
+ onSubmit={(response) => {
+ const { repositoryUrl, submittedAt } = response;
+
+ setProjectStatus({
+ ...projectStatus,
+ repositoryUrl,
+ submittedAt,
+ });
+
+ setActiveStep(2);
+ }}
+ repositoryUrl={projectStatus.repositoryUrl}
+ />
+ )}
+ {isStartingProject && (
+ {
+ setProjectStatus({
+ ...projectStatus,
+ startedAt,
+ });
+ setActiveStep(1);
+ }}
+ startedAt={projectStatus?.startedAt}
+ onClose={() => setIsStartingProject(false)}
+ />
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+ {isLoadingStatus && (
+
+ )}
+
+ {activeStep === 0 && (
+ <>
+ Start building, submit solution and get feedback from the community.
+ >
+ )}
+ {activeStep === 1 && (
+ <>
+ Started working{' '}
+
+ {getRelativeTimeString(projectStatus.startedAt!)}
+
+ . Follow{' '}
+ {' '}
+ to get most out of it.
+ >
+ )}
+ {activeStep >= 2 && (
+ <>
+ Congrats on submitting your solution.{' '}
+
+ >
+ )}
+
+
+
+ 0}
+ icon={Play}
+ text={activeStep > 0 ? 'Started Working' : 'Start Working'}
+ number={1}
+ onClick={() => {
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
+ setIsStartingProject(true);
+ }}
+ />
+ 0} />
+ 1}
+ icon={Send}
+ onClick={() => {
+ if (!isLoggedIn()) {
+ showLoginPopup();
+ return;
+ }
+
+ setIsSubmittingProject(true);
+ }}
+ text={activeStep > 1 ? 'Submitted' : 'Submit Solution'}
+ number={2}
+ />
+ 1} />
+ 2}
+ icon={Flag}
+ text={
+ activeStep == 2
+ ? `${projectStatus.upvotes} / 5 upvotes`
+ : `5 upvotes`
+ }
+ />
+ 2} />
+ 3}
+ icon={Flag}
+ text={
+ activeStep == 3
+ ? `${projectStatus.upvotes} / 10 upvotes`
+ : activeStep > 3
+ ? `${projectStatus.upvotes} upvotes`
+ : `10 upvotes`
+ }
+ />
+
+
+ );
+}
diff --git a/src/components/Projects/StatusStepper/StepperAction.tsx b/src/components/Projects/StatusStepper/StepperAction.tsx
new file mode 100644
index 000000000..a6555fe13
--- /dev/null
+++ b/src/components/Projects/StatusStepper/StepperAction.tsx
@@ -0,0 +1,51 @@
+import { Check, type LucideIcon } from 'lucide-react';
+
+type StepperActionProps = {
+ isActive?: boolean;
+ isCompleted?: boolean;
+ onClick?: () => void;
+ icon: LucideIcon;
+ text: string;
+ number: number;
+};
+
+export function StepperAction(props: StepperActionProps) {
+ const {
+ isActive,
+ onClick = () => null,
+ isCompleted,
+ icon: DisplayIcon,
+ text,
+ number,
+ } = props;
+
+ if (isActive) {
+ return (
+
+ );
+ }
+
+ if (isCompleted) {
+ return (
+
+
+ {text}
+
+ );
+ }
+
+ return (
+
+
+ {number}
+
+ {text}
+
+ );
+}
diff --git a/src/components/Projects/StatusStepper/StepperStepSeparator.tsx b/src/components/Projects/StatusStepper/StepperStepSeparator.tsx
new file mode 100644
index 000000000..b169daab6
--- /dev/null
+++ b/src/components/Projects/StatusStepper/StepperStepSeparator.tsx
@@ -0,0 +1,17 @@
+import { cn } from '../../../lib/classname.ts';
+
+type StepperStepSeparatorProps = {
+ isActive: boolean;
+};
+
+export function StepperStepSeparator(props: StepperStepSeparatorProps) {
+ const { isActive } = props;
+
+ return (
+
+ );
+}
diff --git a/src/components/Projects/SubmissionRequirement.tsx b/src/components/Projects/SubmissionRequirement.tsx
new file mode 100644
index 000000000..778efc9a1
--- /dev/null
+++ b/src/components/Projects/SubmissionRequirement.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from 'react';
+import { cn } from '../../lib/classname.ts';
+import { CheckIcon, CircleDashed, Loader, Loader2, X } from 'lucide-react';
+import { Spinner } from '../ReactIcons/Spinner.tsx';
+
+type SubmissionRequirementProps = {
+ status: 'pending' | 'success' | 'error';
+ children: ReactNode;
+ isLoading?: boolean;
+};
+
+export function SubmissionRequirement(props: SubmissionRequirementProps) {
+ const { status, isLoading = false, children } = props;
+
+ return (
+
+ {!isLoading && (
+ <>
+ {status === 'pending' ? (
+
+ ) : status === 'success' ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {isLoading && (
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/Projects/SubmitProjectModal.tsx b/src/components/Projects/SubmitProjectModal.tsx
new file mode 100644
index 000000000..ac8b84e4c
--- /dev/null
+++ b/src/components/Projects/SubmitProjectModal.tsx
@@ -0,0 +1,299 @@
+import { CheckIcon, CopyIcon, X } from 'lucide-react';
+import { CheckIcon as ReactCheckIcon } from '../ReactIcons/CheckIcon.tsx';
+import { Modal } from '../Modal';
+import { type FormEvent, useState } from 'react';
+import { httpPost } from '../../lib/http';
+import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
+import { SubmissionRequirement } from './SubmissionRequirement.tsx';
+import { useCopyText } from '../../hooks/use-copy-text.ts';
+
+type SubmitProjectResponse = {
+ repositoryUrl: string;
+ submittedAt: Date;
+};
+
+type VerificationChecksType = {
+ repositoryExists: 'pending' | 'success' | 'error';
+ readmeExists: 'pending' | 'success' | 'error';
+ projectUrlExists: 'pending' | 'success' | 'error';
+};
+
+type SubmitProjectModalProps = {
+ onClose: () => void;
+ projectId: string;
+ repositoryUrl?: string;
+ onSubmit: (response: SubmitProjectResponse) => void;
+};
+
+export function SubmitProjectModal(props: SubmitProjectModalProps) {
+ const {
+ onClose,
+ projectId,
+ onSubmit,
+ repositoryUrl: defaultRepositoryUrl = '',
+ } = props;
+
+ const { isCopied, copyText } = useCopyText();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [successMessage, setSuccessMessage] = useState('');
+ const [repoUrl, setRepoUrl] = useState(defaultRepositoryUrl);
+ const [verificationChecks, setVerificationChecks] =
+ useState({
+ repositoryExists: defaultRepositoryUrl ? 'success' : 'pending',
+ readmeExists: defaultRepositoryUrl ? 'success' : 'pending',
+ projectUrlExists: defaultRepositoryUrl ? 'success' : 'pending',
+ });
+
+ const projectUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}`;
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ try {
+ setVerificationChecks({
+ repositoryExists: 'pending',
+ readmeExists: 'pending',
+ projectUrlExists: 'pending',
+ });
+
+ setIsLoading(true);
+ setError('');
+ setSuccessMessage('');
+
+ if (!repoUrl) {
+ setVerificationChecks({
+ repositoryExists: 'error',
+ readmeExists: 'pending',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Repository URL is required');
+ }
+
+ const repoUrlParts = repoUrl
+ .replace(/https?:\/\/(www\.)?github\.com/, '')
+ .split('/');
+ const username = repoUrlParts[1];
+ const repoName = repoUrlParts[2];
+
+ if (!username || !repoName) {
+ setVerificationChecks({
+ repositoryExists: 'error',
+ readmeExists: 'pending',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Invalid GitHub repository URL');
+ }
+
+ const mainApiUrl = `https://api.github.com/repos/${username}/${repoName}`;
+
+ const allContentsUrl = `${mainApiUrl}/contents`;
+ const allContentsResponse = await fetch(allContentsUrl);
+ if (!allContentsResponse.ok) {
+ setVerificationChecks({
+ repositoryExists: 'error',
+ readmeExists: 'pending',
+ projectUrlExists: 'pending',
+ });
+
+ if (allContentsResponse?.status === 404) {
+ throw new Error(
+ 'Repository not found. Make sure it exists and is public.',
+ );
+ }
+
+ throw new Error('Failed to fetch repository contents');
+ }
+
+ const allContentsData = await allContentsResponse.json();
+ if (!Array.isArray(allContentsData)) {
+ setVerificationChecks({
+ repositoryExists: 'error',
+ readmeExists: 'pending',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Failed to fetch repository contents');
+ }
+
+ const readmeFile = allContentsData.find(
+ (file) => file.name.toLowerCase() === 'readme.md',
+ );
+ if (!readmeFile || !readmeFile.url) {
+ setVerificationChecks({
+ repositoryExists: 'success',
+ readmeExists: 'error',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Readme file not found');
+ }
+
+ const readmeUrl = readmeFile.url;
+ const response = await fetch(readmeUrl);
+ if (!response.ok || response.status === 404) {
+ setVerificationChecks({
+ repositoryExists: 'success',
+ readmeExists: 'error',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Readme file not found');
+ }
+
+ const data = await response.json();
+ if (!data.content) {
+ setVerificationChecks({
+ repositoryExists: 'success',
+ readmeExists: 'error',
+ projectUrlExists: 'pending',
+ });
+
+ throw new Error('Readme file not found');
+ }
+
+ const readmeContent = window.atob(data.content);
+ if (!readmeContent.includes(projectUrl)) {
+ setVerificationChecks({
+ repositoryExists: 'success',
+ readmeExists: 'success',
+ projectUrlExists: 'error',
+ });
+
+ throw new Error('Add the project page URL to the readme file');
+ }
+
+ setVerificationChecks({
+ repositoryExists: 'success',
+ readmeExists: 'success',
+ projectUrlExists: 'success',
+ });
+
+ const submitProjectUrl = `${import.meta.env.PUBLIC_API_URL}/v1-submit-project/${projectId}`;
+ const { response: submitResponse, error } =
+ await httpPost(submitProjectUrl, {
+ repositoryUrl: repoUrl,
+ });
+
+ if (error || !submitResponse) {
+ throw new Error(
+ error?.message || 'Error submitting project. Please try again!',
+ );
+ }
+
+ setSuccessMessage('Solution submitted successfully!');
+ setIsLoading(false);
+
+ onSubmit(submitResponse);
+ } catch (error: any) {
+ console.error(error);
+ setError(error?.message || 'Failed to verify repository');
+ setIsLoading(false);
+ }
+ };
+
+ if (successMessage) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ Submit Solution URL
+
+
+ Submit the URL of your GitHub repository with the solution.
+
+
+
+
+ URL must point to a public GitHub repository
+
+
+ Repository must contain a README file
+
+
+ README file must contain the{' '}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Projects/VoteButton.tsx b/src/components/Projects/VoteButton.tsx
new file mode 100644
index 000000000..3b047ae79
--- /dev/null
+++ b/src/components/Projects/VoteButton.tsx
@@ -0,0 +1,30 @@
+import { cn } from '../../lib/classname.ts';
+import { type LucideIcon, ThumbsUp } from 'lucide-react';
+
+type VoteButtonProps = {
+ icon: LucideIcon;
+ isActive: boolean;
+ count: number;
+ onClick: () => void;
+};
+export function VoteButton(props: VoteButtonProps) {
+ const { icon: VoteIcon, isActive, count, onClick } = props;
+ return (
+
+ );
+}
diff --git a/src/components/TeamDropdown/TeamDropdown.tsx b/src/components/TeamDropdown/TeamDropdown.tsx
index e4979d72b..9d6c1c82e 100644
--- a/src/components/TeamDropdown/TeamDropdown.tsx
+++ b/src/components/TeamDropdown/TeamDropdown.tsx
@@ -32,24 +32,6 @@ export function TeamDropdown() {
const user = useAuth();
const { teamId } = useTeamId();
- const [shouldShowTeamsIndicator, setShouldShowTeamsIndicator] =
- useState(false);
-
- useEffect(() => {
- // Show team dropdown "New" indicator to first 3 refreshes
- const viewedTeamsCount = localStorage.getItem('viewedTeamsCount');
- const viewedTeamsCountNumber = parseInt(viewedTeamsCount || '0', 10);
- const shouldShowTeamIndicator = viewedTeamsCountNumber < 5;
-
- setShouldShowTeamsIndicator(shouldShowTeamIndicator);
- if (shouldShowTeamIndicator) {
- localStorage.setItem(
- 'viewedTeamsCount',
- (viewedTeamsCountNumber + 1).toString(),
- );
- }
- }, []);
-
const teamList = useStore($teamList);
const currentTeam = useStore($currentTeam);
@@ -102,15 +84,6 @@ export function TeamDropdown() {
Choose Team
-
- {shouldShowTeamsIndicator && (
-
-
-
-
-
-
- )}