parent
8e5e8ce0b6
commit
840648b9b1
11 changed files with 427 additions and 39 deletions
@ -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> |
||||
); |
||||
} |
@ -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…
Reference in new issue