pull/7791/head
Arik Chakma 7 months ago
parent 8e5e8ce0b6
commit 840648b9b1
  1. 15
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  2. 48
      src/components/CustomRoadmap/CustomRoadmap.tsx
  3. 49
      src/components/CustomRoadmap/FeaturedListing/FeaturedListingStatus.tsx
  4. 66
      src/components/CustomRoadmap/FeaturedListing/RejectedReason.tsx
  5. 72
      src/components/CustomRoadmap/FeaturedListing/SubmitFeaturedListingWarning.tsx
  6. 5
      src/components/CustomRoadmap/RoadmapHeader.tsx
  7. 5
      src/components/Dashboard/DashboardAiRoadmaps.tsx
  8. 8
      src/components/GenerateRoadmap/PayToBypass.tsx
  9. 41
      src/hooks/use-custom-roadmap.ts
  10. 146
      src/lib/query-http.ts
  11. 11
      src/stores/query-client.ts

@ -26,6 +26,16 @@ export type AllowedCustomRoadmapType =
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
export const allowedRoadmapFeaturedListStatus = [
'idle',
'submitted',
'approved',
'rejected',
'rejected_with_reason',
] as const;
export type AllowedRoadmapFeaturedListStatus =
(typeof allowedRoadmapFeaturedListStatus)[number];
export interface RoadmapDocument {
_id?: string;
title: string;
@ -59,6 +69,11 @@ export interface RoadmapDocument {
};
};
featuredListStatus?: AllowedRoadmapFeaturedListStatus;
featuredListReason?: string;
featuredListSubmittedAt?: Date;
featuredListApprovedAt?: Date;
createdAt: Date;
updatedAt: Date;
}

@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { type AppError, type FetchError, httpGet } from '../../lib/http';
import { RoadmapHeader } from './RoadmapHeader';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { currentRoadmap } from '../../stores/roadmap';
import { RestrictedPage } from './RestrictedPage';
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpGet, type FetchError } from '../../lib/query-http';
import { useCustomRoadmap } from '../../hooks/use-custom-roadmap';
export const allowedLinkTypes = [
'video',
@ -71,43 +74,24 @@ export function CustomRoadmap(props: CustomRoadmapProps) {
const [isLoading, setIsLoading] = useState(true);
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
const [error, setError] = useState<AppError | FetchError | undefined>();
async function getRoadmap() {
setIsLoading(true);
const { data, error } = useCustomRoadmap({
id,
secret,
slug,
});
const roadmapUrl = slug
? new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
)
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
}
const { response, error } = await httpGet<GetRoadmapResponse>(
roadmapUrl.toString(),
);
if (error || !response) {
setError(error);
setIsLoading(false);
useEffect(() => {
if (!data) {
return;
}
document.title = `${response.title} - roadmap.sh`;
setRoadmap(response);
currentRoadmap.set(response);
document.title = `${data.title} - roadmap.sh`;
setRoadmap(data);
currentRoadmap.set(data);
setIsLoading(false);
}
useEffect(() => {
getRoadmap().finally(() => {
hideRoadmapLoader();
});
}, []);
hideRoadmapLoader();
}, [data]);
if (isLoading) {
return null;

@ -0,0 +1,49 @@
import { useState } from 'react';
import { SubmitFeaturedListingWarning } from './SubmitFeaturedListingWarning';
import type { GetRoadmapResponse } from '../CustomRoadmap';
type FeaturedListingStatusProps = {
currentRoadmap: GetRoadmapResponse;
};
export function FeaturedListingStatus(props: FeaturedListingStatusProps) {
const { currentRoadmap } = props;
const { featuredListStatus = 'idle' } = currentRoadmap;
const [showSubmitWarning, setShowSubmitWarning] = useState(false);
return (
<>
{featuredListStatus === 'idle' && (
<>
{showSubmitWarning && (
<SubmitFeaturedListingWarning
onClose={() => setShowSubmitWarning(false)}
/>
)}
<button
className="text-sm"
onClick={() => setShowSubmitWarning(true)}
>
Submit for Featured Listing
</button>
</>
)}
{featuredListStatus === 'submitted' && (
<span className="text-sm">Submitted</span>
)}
{featuredListStatus === 'approved' && (
<span className="text-sm">Approved</span>
)}
{featuredListStatus === 'rejected' && (
<span className="text-sm">Rejected</span>
)}
{featuredListStatus === 'rejected_with_reason' && <></>}
</>
);
}

@ -0,0 +1,66 @@
import { useMutation } from '@tanstack/react-query';
import { Modal } from '../../Modal';
import { queryClient } from '../../../stores/query-client';
import { httpPost } from '../../../lib/query-http';
import { useStore } from '@nanostores/react';
import { currentRoadmap } from '../../../stores/roadmap';
import { useToast } from '../../../hooks/use-toast';
type RejectedReasonProps = {
onClose: () => void;
};
export function RejectedReason(props: RejectedReasonProps) {
const { onClose } = props;
const toast = useToast();
const $currentRoadmap = useStore(currentRoadmap);
const submit = useMutation(
{
mutationFn: async () => {
return httpPost(
`/v1-submit-for-featured-listing/${$currentRoadmap?._id}`,
{},
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['get-roadmap'],
});
onClose();
},
onError: (error) => {
toast.error(error?.message || 'Something went wrong');
},
},
queryClient,
);
return (
<Modal onClose={onClose}>
<div className="p-4">
<h2 className="text-lg font-semibold">Rejected Reason</h2>
<p className="mt-2 text-sm">{$currentRoadmap?.featuredListReason}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center text-sm hover:bg-gray-300"
onClick={onClose}
disabled={submit.isPending}
>
Cancel
</button>
<button
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
disabled={submit.isPending}
onClick={() => submit.mutate()}
>
{submit.isPending ? 'Submitting...' : 'Resubmit'}
</button>
</div>
</div>
</Modal>
);
}

@ -0,0 +1,72 @@
import { useMutation } from '@tanstack/react-query';
import { Modal } from '../../Modal';
import { queryClient } from '../../../stores/query-client';
import { httpPost } from '../../../lib/query-http';
import { useStore } from '@nanostores/react';
import { currentRoadmap } from '../../../stores/roadmap';
import { useToast } from '../../../hooks/use-toast';
type SubmitFeaturedListingWarningProps = {
onClose: () => void;
};
export function SubmitFeaturedListingWarning(
props: SubmitFeaturedListingWarningProps,
) {
const { onClose } = props;
const toast = useToast();
const $currentRoadmap = useStore(currentRoadmap);
const submit = useMutation(
{
mutationFn: async () => {
return httpPost(
`/v1-submit-for-featured-listing/${$currentRoadmap?._id}`,
{},
);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['get-roadmap'],
});
onClose();
},
onError: (error) => {
toast.error(error?.message || 'Something went wrong');
},
},
queryClient,
);
return (
<Modal onClose={onClose}>
<div className="p-4">
<h2 className="text-lg font-semibold">Featured Listing</h2>
<p className="mt-2 text-sm">
Submitting your roadmap for a featured listing will make it visible to
everyone on the platform.{' '}
<span className="font-medium">Are you sure?</span>
</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center text-sm hover:bg-gray-300"
onClick={onClose}
disabled={submit.isPending}
>
Cancel
</button>
<button
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
disabled={submit.isPending}
onClick={() => submit.mutate()}
>
{submit.isPending ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</Modal>
);
}

@ -11,6 +11,7 @@ import { RoadmapActionButton } from './RoadmapActionButton';
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
import { FeaturedListingStatus } from './FeaturedListing/FeaturedListingStatus.tsx';
type RoadmapHeaderProps = {};
@ -150,6 +151,10 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
/>
)}
{$currentRoadmap && (
<FeaturedListingStatus currentRoadmap={$currentRoadmap} />
)}
<RoadmapActionButton
onUpdateSharing={() => setIsSharing(true)}
onCustomize={() => {

@ -27,9 +27,7 @@ export function DashboardAiRoadmaps(props: DashboardAiRoadmapsProps) {
return (
<>
<div className="mb-2 mt-6 flex items-center justify-between gap-2">
<h2 className="text-xs uppercase text-gray-400">
My AI Roadmaps
</h2>
<h2 className="text-xs uppercase text-gray-400">My AI Roadmaps</h2>
<a
href="/ai/explore"
@ -62,6 +60,7 @@ export function DashboardAiRoadmaps(props: DashboardAiRoadmapsProps) {
<>
{roadmaps.map((roadmap) => (
<a
key={roadmap.id}
href={`/ai/${roadmap.slug}`}
className="relative truncate rounded-md border bg-white p-2.5 text-left text-sm shadow-sm hover:border-gray-400 hover:bg-gray-50"
>

@ -105,7 +105,7 @@ export function PayToBypass(props: PayToBypassProps) {
id={roadmapCountId}
name={roadmapCountId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How many roadmaps you will be generating (daily, or monthly)?"
/>
</div>
@ -117,7 +117,7 @@ export function PayToBypass(props: PayToBypassProps) {
id={usageId}
name={usageId}
required
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="How will you be using this"
/>
</div>
@ -131,7 +131,7 @@ export function PayToBypass(props: PayToBypassProps) {
<textarea
id={feedbackId}
name={feedbackId}
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
className="placeholder-text-gray-400 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-sm focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Do you have any feedback?"
/>
</div>
@ -148,7 +148,7 @@ export function PayToBypass(props: PayToBypassProps) {
</button>
<button
type="submit"
className="disbaled:opacity-60 w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed"
className="w-full rounded-lg bg-gray-900 py-2 text-sm text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
setTimeout(() => {
onClose();

@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadmap';
import { httpGet, type FetchError } from '../lib/query-http';
import { queryClient } from '../stores/query-client';
type UseCustomRoadmapOptions = {
slug?: string;
id?: string;
secret?: string;
};
export function useCustomRoadmap(options: UseCustomRoadmapOptions) {
const { slug, id, secret } = options;
return useQuery<GetRoadmapResponse, FetchError>(
{
queryKey: [
'get-roadmap',
{
slug,
id,
},
],
queryFn: async () => {
const roadmapUrl = slug
? new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap-by-slug/${slug}`,
)
: new URL(`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
}
return httpGet(roadmapUrl.toString());
},
enabled: !!(slug || id),
},
queryClient,
);
}

@ -0,0 +1,146 @@
import Cookies from 'js-cookie';
import fp from '@fingerprintjs/fingerprintjs';
import { TOKEN_COOKIE_NAME, removeAuthToken } from './jwt.ts';
type HttpOptionsType = RequestInit;
type AppResponse = Record<string, any>;
export interface FetchError extends Error {
status: number;
message: string;
}
type AppError = {
status: number;
message: string;
errors?: { message: string; location: string }[];
};
type ApiReturn<ResponseType> = ResponseType;
/**
* Wrapper around fetch to make it easy to handle errors
*
* @param url
* @param options
*/
export async function httpCall<ResponseType = AppResponse>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
const fullUrl = url.startsWith('http')
? url
: `${import.meta.env.PUBLIC_API_URL}${url}`;
try {
const fingerprintPromise = await fp.load();
const fingerprint = await fingerprintPromise.get();
const isMultiPartFormData = options?.body instanceof FormData;
const headers = new Headers({
Accept: 'application/json',
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
fp: fingerprint.visitorId,
...(options?.headers ?? {}),
});
if (!isMultiPartFormData) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(fullUrl, {
credentials: 'include',
...options,
headers,
});
// @ts-ignore
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
const data = doesAcceptHtml ? await response.text() : await response.json();
// Logout user if token is invalid
if (data?.status === 401) {
removeAuthToken();
window.location.href = '/login';
return null as unknown as ApiReturn<ResponseType>;
}
if (!response.ok) {
if (data.errors) {
const error = new Error() as FetchError;
error.message = data.message;
error.status = response?.status;
throw error;
} else {
throw new Error('An unexpected error occurred');
}
}
return data as ResponseType;
} catch (error: any) {
throw error;
}
}
export async function httpPost<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
});
}
export async function httpGet<ResponseType = AppResponse>(
url: string,
queryParams?: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
const searchParams = new URLSearchParams(queryParams).toString();
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
return httpCall<ResponseType>(queryUrl, {
credentials: 'include',
method: 'GET',
...options,
});
}
export async function httpPatch<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function httpPut<ResponseType = AppResponse>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function httpDelete<ResponseType = AppResponse>(
url: string,
options?: HttpOptionsType,
): Promise<ApiReturn<ResponseType>> {
return httpCall<ResponseType>(url, {
...options,
method: 'DELETE',
});
}

@ -0,0 +1,11 @@
import { QueryCache, QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
queryCache: new QueryCache({}),
defaultOptions: {
queries: {
retry: false,
enabled: !import.meta.env.SSR,
},
},
});
Loading…
Cancel
Save