diff --git a/package.json b/package.json index dc5ef9fb1..28b522487 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@nanostores/react": "^0.8.0", "@napi-rs/image": "^1.9.2", "@resvg/resvg-js": "^2.6.2", + "@tanstack/react-query": "^5.59.16", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "astro": "^4.16.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac1af87ab..c65650762 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 + '@tanstack/react-query': + specifier: ^5.59.16 + version: 5.59.16(react@18.3.1) '@types/react': specifier: ^18.3.11 version: 18.3.11 @@ -1198,6 +1201,14 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + '@tanstack/query-core@5.59.16': + resolution: {integrity: sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==} + + '@tanstack/react-query@5.59.16': + resolution: {integrity: sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -4278,6 +4289,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.13 + '@tanstack/query-core@5.59.16': {} + + '@tanstack/react-query@5.59.16(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.59.16 + react: 18.3.1 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.7.0 diff --git a/src/components/RoadmapHeader.astro b/src/components/RoadmapHeader.astro index 103f3dbda..2948b7ad4 100644 --- a/src/components/RoadmapHeader.astro +++ b/src/components/RoadmapHeader.astro @@ -12,6 +12,7 @@ import { MarkFavorite } from './FeaturedItems/MarkFavorite'; import { type RoadmapFrontmatter } from '../lib/roadmap'; import { ShareRoadmapButton } from './ShareRoadmapButton'; import { DownloadRoadmapButton } from './DownloadRoadmapButton'; +import { ConnectCalendarButton } from './Schedule/ConnectCalendarButton'; export interface Props { title: string; @@ -127,6 +128,11 @@ const hasTnsBanner = !!tnsBannerLink; isActive={activeTab === 'projects'} badgeText={projectCount > 0 ? 'new' : 'soon'} /> + <ConnectCalendarButton + resourceId={roadmapId} + resourceType='roadmap' + client:load + /> </div> <TabLink @@ -135,6 +141,7 @@ const hasTnsBanner = !!tnsBannerLink; text='Suggest Changes' isExternal={true} hideTextOnMobile={true} + isActive={false} /> </div> </div> diff --git a/src/components/Schedule/ConnectCalendarButton.tsx b/src/components/Schedule/ConnectCalendarButton.tsx new file mode 100644 index 000000000..be20b41e4 --- /dev/null +++ b/src/components/Schedule/ConnectCalendarButton.tsx @@ -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> + </> + ); +} diff --git a/src/components/Schedule/ScheduleEventModal.tsx b/src/components/Schedule/ScheduleEventModal.tsx new file mode 100644 index 000000000..fd33f1164 --- /dev/null +++ b/src/components/Schedule/ScheduleEventModal.tsx @@ -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> + ); +} diff --git a/src/hooks/use-schedule.ts b/src/hooks/use-schedule.ts new file mode 100644 index 000000000..ea10e5ac6 --- /dev/null +++ b/src/hooks/use-schedule.ts @@ -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, + ); +} diff --git a/src/lib/query-http.ts b/src/lib/query-http.ts new file mode 100644 index 000000000..64ba4db7c --- /dev/null +++ b/src/lib/query-http.ts @@ -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', + }); +} diff --git a/src/stores/query-client.ts b/src/stores/query-client.ts new file mode 100644 index 000000000..210d1d733 --- /dev/null +++ b/src/stores/query-client.ts @@ -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, + }, + }, +});