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