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'} /> + 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 && ( + { + setIsModalOpen(false); + }} + /> + )} + + { + setIsModalOpen(true); + }} + > + + Schedule Learning + + > + ); +} 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( + { + 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 ( + + + + + Learning Calendar + + + Link your Google Calendar to start scheduling your learning sessions. + + + { + connectGoogleCalendar.mutate(); + }} + disabled={connectGoogleCalendar.isPending} + > + + Connect Calendar + + + + ); +} 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[]; + +export function useListConnectedCalenders() { + return useQuery( + { + 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; + +export interface FetchError extends Error { + status: number; + message: string; +} + +type AppError = { + status: number; + message: string; + errors?: { message: string; location: string }[]; +}; + +type ApiReturn = ResponseType; + +/** + * Wrapper around fetch to make it easy to handle errors + * + * @param url + * @param options + */ +export async function httpCall( + url: string, + options?: HttpOptionsType, +): Promise> { + 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; + } + + 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( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'POST', + body: body instanceof FormData ? body : JSON.stringify(body), + }); +} + +export async function httpGet( + url: string, + queryParams?: Record, + options?: HttpOptionsType, +): Promise> { + const searchParams = new URLSearchParams(queryParams).toString(); + const queryUrl = searchParams ? `${url}?${searchParams}` : url; + + return httpCall(queryUrl, { + credentials: 'include', + method: 'GET', + ...options, + }); +} + +export async function httpPatch( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export async function httpPut( + url: string, + body: Record, + options?: HttpOptionsType, +): Promise> { + return httpCall(url, { + ...options, + method: 'PUT', + body: JSON.stringify(body), + }); +} + +export async function httpDelete( + url: string, + options?: HttpOptionsType, +): Promise> { + return httpCall(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, + }, + }, +});
+ Link your Google Calendar to start scheduling your learning sessions. +