parent
13855f06dd
commit
e11ac4bf84
11 changed files with 519 additions and 183 deletions
@ -0,0 +1,37 @@ |
||||
import { Modal } from '../Modal'; |
||||
|
||||
type NextLessonAlertModalProps = { |
||||
onClose: () => void; |
||||
onContinue: () => void; |
||||
}; |
||||
|
||||
export function NextLessonAlertModal(props: NextLessonAlertModalProps) { |
||||
const { onClose, onContinue } = props; |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={onClose} |
||||
bodyClassName="h-auto p-4 bg-zinc-900 border border-zinc-700 text-white" |
||||
> |
||||
<h2 className="text-lg font-semibold">Warning</h2> |
||||
<p className="mt-2"> |
||||
Please submit your answer before moving to the next lesson. |
||||
</p> |
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2"> |
||||
<button |
||||
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm leading-none" |
||||
onClick={onClose} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
className="rounded-lg bg-zinc-800 px-4 py-2 text-sm leading-none text-zinc-50" |
||||
onClick={onContinue} |
||||
> |
||||
Continue |
||||
</button> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,52 @@ |
||||
import { useMutation, useQuery } from '@tanstack/react-query'; |
||||
import { queryClient } from '../stores/query-client'; |
||||
import { isLoggedIn } from '../lib/jwt'; |
||||
import { httpGet, httpPost } from '../lib/query-http'; |
||||
|
||||
export interface CourseProgressDocument { |
||||
_id: string; |
||||
userId: string; |
||||
courseId: string; |
||||
completed: { |
||||
chapterId: string; |
||||
lessonId: string; |
||||
completedAt: Date; |
||||
}[]; |
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
export type CourseProgressResponse = { |
||||
completed: CourseProgressDocument['completed']; |
||||
}; |
||||
|
||||
export function useCourseProgress(courseId: string) { |
||||
return useQuery( |
||||
{ |
||||
queryKey: ['course-progress', courseId], |
||||
queryFn: async () => { |
||||
return httpGet<CourseProgressResponse>( |
||||
`/v1-course-progress/${courseId}`, |
||||
); |
||||
}, |
||||
enabled: !!courseId && isLoggedIn(), |
||||
}, |
||||
queryClient, |
||||
); |
||||
} |
||||
|
||||
export function useCompleteLessonMutation(courseId: string) { |
||||
return useMutation( |
||||
{ |
||||
mutationFn: async (data: { chapterId: string; lessonId: string }) => { |
||||
return httpPost(`/v1-complete-lesson/${courseId}`, data); |
||||
}, |
||||
onSettled: () => { |
||||
queryClient.invalidateQueries({ |
||||
queryKey: ['course-progress', courseId], |
||||
}); |
||||
}, |
||||
}, |
||||
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,4 @@ |
||||
import { atom } from 'nanostores'; |
||||
|
||||
export type LessonSubmitStatus = 'idle' | 'submitting' | 'submitted' | 'wrong'; |
||||
export const lessonSubmitStatus = atom<LessonSubmitStatus>('idle'); |
Loading…
Reference in new issue