parent
687d3ee4da
commit
0848ca1833
8 changed files with 321 additions and 0 deletions
@ -0,0 +1,40 @@ |
||||
import { Calendar } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import type { ResourceType } from '../../lib/resource-progress'; |
||||
import { ScheduleEventModal } from './ScheduleEventModal'; |
||||
import { useState } from 'react'; |
||||
|
||||
type ConnectCalendarButtonProps = { |
||||
resourceId: string; |
||||
resourceType: ResourceType; |
||||
}; |
||||
|
||||
export function ConnectCalendarButton(props: ConnectCalendarButtonProps) { |
||||
const { resourceId, resourceType } = props; |
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
{isModalOpen && ( |
||||
<ScheduleEventModal |
||||
onClose={() => { |
||||
setIsModalOpen(false); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<button |
||||
className={cn( |
||||
'group inline-flex items-center gap-1.5 border-b-2 border-b-transparent px-2 pb-2.5 text-sm font-normal text-gray-400 transition-colors hover:text-gray-700', |
||||
)} |
||||
onClick={() => { |
||||
setIsModalOpen(true); |
||||
}} |
||||
> |
||||
<Calendar className="h-4 w-4 flex-shrink-0" /> |
||||
Schedule Learning |
||||
</button> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,67 @@ |
||||
import { useMutation } from '@tanstack/react-query'; |
||||
import { useListConnectedCalenders } from '../../hooks/use-schedule'; |
||||
import { Modal } from '../Modal'; |
||||
import { Calendar, Loader2, Plus, BookOpen, Trash2 } from 'lucide-react'; |
||||
import { httpPost } from '../../lib/query-http'; |
||||
import { queryClient } from '../../stores/query-client'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
|
||||
type ConnectGoogleCalendarResponse = { |
||||
redirectUrl: string; |
||||
}; |
||||
|
||||
type ScheduleEventModalProps = { |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
export function ScheduleEventModal(props: ScheduleEventModalProps) { |
||||
const { onClose } = props; |
||||
|
||||
const toast = useToast(); |
||||
const { isLoading } = useListConnectedCalenders(); |
||||
|
||||
const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>( |
||||
{ |
||||
mutationFn: async () => { |
||||
return httpPost('/v1-connect-google-calendar', {}); |
||||
}, |
||||
onSuccess(data) { |
||||
const { redirectUrl } = data; |
||||
if (!redirectUrl) { |
||||
return; |
||||
} |
||||
|
||||
window.location.href = redirectUrl; |
||||
}, |
||||
onError(error) { |
||||
toast.error(error?.message || 'Failed to connect Google Calendar'); |
||||
}, |
||||
}, |
||||
queryClient, |
||||
); |
||||
|
||||
return ( |
||||
<Modal onClose={onClose}> |
||||
<div className="flex max-w-md flex-col items-center p-4 py-10 text-center"> |
||||
<BookOpen className="text-primary h-14 w-14" /> |
||||
<h2 className="mt-4 text-xl font-bold tracking-tight"> |
||||
Learning Calendar |
||||
</h2> |
||||
<p className="mt-1 text-balance text-sm text-gray-600"> |
||||
Link your Google Calendar to start scheduling your learning sessions. |
||||
</p> |
||||
|
||||
<button |
||||
className="mt-4 flex items-center gap-2 rounded-full bg-black px-5 py-3 leading-none text-white disabled:opacity-60" |
||||
onClick={() => { |
||||
connectGoogleCalendar.mutate(); |
||||
}} |
||||
disabled={connectGoogleCalendar.isPending} |
||||
> |
||||
<Calendar className="h-4 w-4 shrink-0 stroke-[2.5]" /> |
||||
Connect Calendar |
||||
</button> |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,31 @@ |
||||
import { useMutation, useQuery } from '@tanstack/react-query'; |
||||
import { queryClient } from '../stores/query-client'; |
||||
import { httpGet, httpPost } from '../lib/query-http'; |
||||
import { isLoggedIn } from '../lib/jwt'; |
||||
|
||||
export interface CalendarDocument { |
||||
_id: string; |
||||
|
||||
userId: string; |
||||
externalId: string; |
||||
credentials: {}; |
||||
|
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
type ListConnectedCalendarsResponse = Omit<CalendarDocument, 'credentials'>[]; |
||||
|
||||
export function useListConnectedCalenders() { |
||||
return useQuery<ListConnectedCalendarsResponse>( |
||||
{ |
||||
queryKey: ['connected-calendars'], |
||||
queryFn: async () => { |
||||
return httpGet('/v1-list-connected-calendars'); |
||||
}, |
||||
enabled: !!isLoggedIn(), |
||||
staleTime: 1000 * 60 * 5, |
||||
}, |
||||
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