From 18d4d5c052bf4d3bb739a20558f109d389ecde7c Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Fri, 25 Oct 2024 00:33:20 +0600 Subject: [PATCH] feat: add calendar scheduling --- .../ReactIcons/AppleCalendarIcon.tsx | 40 +++ src/components/ReactIcons/FileIcon.tsx | 22 ++ .../ReactIcons/GoogleCalendarIcon.tsx | 40 +++ .../ReactIcons/OutlookCalendarIcon.tsx | 40 +++ src/components/RoadmapHeader.astro | 5 +- ...tCalendarButton.tsx => ScheduleButton.tsx} | 11 +- .../Schedule/ScheduleEventModal.tsx | 320 +++++++++++++++--- src/hooks/use-schedule.ts | 31 -- src/lib/query-http.ts | 146 -------- src/stores/query-client.ts | 11 - 10 files changed, 424 insertions(+), 242 deletions(-) create mode 100644 src/components/ReactIcons/AppleCalendarIcon.tsx create mode 100644 src/components/ReactIcons/FileIcon.tsx create mode 100644 src/components/ReactIcons/GoogleCalendarIcon.tsx create mode 100644 src/components/ReactIcons/OutlookCalendarIcon.tsx rename src/components/Schedule/{ConnectCalendarButton.tsx => ScheduleButton.tsx} (74%) delete mode 100644 src/hooks/use-schedule.ts delete mode 100644 src/lib/query-http.ts delete mode 100644 src/stores/query-client.ts diff --git a/src/components/ReactIcons/AppleCalendarIcon.tsx b/src/components/ReactIcons/AppleCalendarIcon.tsx new file mode 100644 index 000000000..b531926a7 --- /dev/null +++ b/src/components/ReactIcons/AppleCalendarIcon.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react'; + +export function AppleCalendarIcon(props: SVGProps) { + return ( + + + + + + + + + ); +} diff --git a/src/components/ReactIcons/FileIcon.tsx b/src/components/ReactIcons/FileIcon.tsx new file mode 100644 index 000000000..72634e66d --- /dev/null +++ b/src/components/ReactIcons/FileIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from 'react'; + +export function FileIcon(props: SVGProps) { + return ( + + + + + + ); +} diff --git a/src/components/ReactIcons/GoogleCalendarIcon.tsx b/src/components/ReactIcons/GoogleCalendarIcon.tsx new file mode 100644 index 000000000..2540d23da --- /dev/null +++ b/src/components/ReactIcons/GoogleCalendarIcon.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react'; + +export function GoogleCalendarIcon(props: SVGProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/components/ReactIcons/OutlookCalendarIcon.tsx b/src/components/ReactIcons/OutlookCalendarIcon.tsx new file mode 100644 index 000000000..6a7e4a058 --- /dev/null +++ b/src/components/ReactIcons/OutlookCalendarIcon.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react'; + +export function OutlookCalendarIcon(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/RoadmapHeader.astro b/src/components/RoadmapHeader.astro index 2948b7ad4..2dc8fda1b 100644 --- a/src/components/RoadmapHeader.astro +++ b/src/components/RoadmapHeader.astro @@ -12,7 +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'; +import { ScheduleButton } from './Schedule/ScheduleButton'; export interface Props { title: string; @@ -128,9 +128,10 @@ const hasTnsBanner = !!tnsBannerLink; isActive={activeTab === 'projects'} badgeText={projectCount > 0 ? 'new' : 'soon'} /> - diff --git a/src/components/Schedule/ConnectCalendarButton.tsx b/src/components/Schedule/ScheduleButton.tsx similarity index 74% rename from src/components/Schedule/ConnectCalendarButton.tsx rename to src/components/Schedule/ScheduleButton.tsx index be20b41e4..122ec4b32 100644 --- a/src/components/Schedule/ConnectCalendarButton.tsx +++ b/src/components/Schedule/ScheduleButton.tsx @@ -4,13 +4,14 @@ import type { ResourceType } from '../../lib/resource-progress'; import { ScheduleEventModal } from './ScheduleEventModal'; import { useState } from 'react'; -type ConnectCalendarButtonProps = { +type ScheduleButtonProps = { resourceId: string; resourceType: ResourceType; + resourceTitle: string; }; -export function ConnectCalendarButton(props: ConnectCalendarButtonProps) { - const { resourceId, resourceType } = props; +export function ScheduleButton(props: ScheduleButtonProps) { + const { resourceId, resourceType, resourceTitle } = props; const [isModalOpen, setIsModalOpen] = useState(false); @@ -21,6 +22,8 @@ export function ConnectCalendarButton(props: ConnectCalendarButtonProps) { onClose={() => { setIsModalOpen(false); }} + roadmapId={resourceId} + roadmapTitle={resourceTitle} /> )} @@ -33,7 +36,7 @@ export function ConnectCalendarButton(props: ConnectCalendarButtonProps) { }} > - Schedule Learning + Schedule ); diff --git a/src/components/Schedule/ScheduleEventModal.tsx b/src/components/Schedule/ScheduleEventModal.tsx index fd33f1164..d958284b8 100644 --- a/src/components/Schedule/ScheduleEventModal.tsx +++ b/src/components/Schedule/ScheduleEventModal.tsx @@ -1,67 +1,291 @@ -import { useMutation } from '@tanstack/react-query'; -import { useListConnectedCalenders } from '../../hooks/use-schedule'; +import { DateTime } from 'luxon'; 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'; +import { ChevronRight, type LucideIcon, X } from 'lucide-react'; +import { useState, type ReactNode, type SVGProps } from 'react'; +import { GoogleCalendarIcon } from '../ReactIcons/GoogleCalendarIcon'; +import { OutlookCalendarIcon } from '../ReactIcons/OutlookCalendarIcon'; +import { AppleCalendarIcon } from '../ReactIcons/AppleCalendarIcon'; +import { FileIcon } from '../ReactIcons/FileIcon'; -type ConnectGoogleCalendarResponse = { - redirectUrl: string; -}; +function generateRoadmapIcsFile( + title: string, + details: string, + location: string, + startDate: Date, + endDate: Date, +) { + const ics = ` +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:${title} +DESCRIPTION:${details} +LOCATION:${location} +DTSTART:${startDate.toISOString().replace(/-|:|\.\d+/g, '')} +DTEND:${endDate.toISOString().replace(/-|:|\.\d+/g, '')} +RRULE:FREQ=DAILY + +BEGIN:VALARM +TRIGGER:-PT30M +ACTION:DISPLAY +DESCRIPTION:Reminder: ${title} starts in 30 minutes +END:VALARM + +BEGIN:VALARM +TRIGGER:-PT15M +ACTION:DISPLAY +DESCRIPTION:Reminder: ${title} starts in 15 minutes +END:VALARM + +END:VEVENT +END:VCALENDAR + `.trim(); + + return new Blob([ics], { type: 'text/calendar' }); +} type ScheduleEventModalProps = { + roadmapTitle: string; + roadmapId: string; 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'); - }, + const { onClose, roadmapId, roadmapTitle } = props; + + const [selectedCalendar, setSelectedCalendar] = useState< + 'apple' | 'outlook' | null + >(null); + const [isLoading, setIsLoading] = useState(false); + + const location = `https://roadmap.sh/${roadmapId}`; + const title = `Learn ${roadmapTitle}`; + const details = ` +Learn ${roadmapTitle} on roadmap.sh + +For more details, visit: https://roadmap.sh/${roadmapId} + `.trim(); + + const downloadICS = () => { + setIsLoading(true); + + const startDate = DateTime.now().minus({ + minutes: DateTime.now().minute % 30, + }); + const endDate = startDate.plus({ hours: 1 }); + const blob = generateRoadmapIcsFile( + title, + details, + location, + startDate.toJSDate(), + endDate.toJSDate(), + ); + + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `${roadmapTitle}.ics`; + a.click(); + + setIsLoading(false); + URL.revokeObjectURL(url); + }; + + const handleGoogleCalendar = () => { + setIsLoading(true); + const baseURL = + 'https://calendar.google.com/calendar/render?action=TEMPLATE'; + + const startDate = DateTime.now().minus({ + minutes: DateTime.now().minute % 30, + }); + const endDate = startDate.plus({ hours: 1 }); + + const eventDetails = new URLSearchParams({ + text: title, + dates: `${startDate.toISO().replace(/-|:|\.\d+/g, '')}/${endDate.toISO().replace(/-|:|\.\d+/g, '')}`, + details, + location, + recur: 'RRULE:FREQ=DAILY', + }).toString(); + + setIsLoading(false); + window.open(`${baseURL}&${eventDetails}`, '_blank'); + }; + + const steps = { + apple: { + title: 'Add to Apple Calendar', + steps: [ + 'Download the iCS File', + 'Open the downloaded file, and it will automatically open your default calendar app.', + <> + If Apple Calendar is not your default calendar app, open Apple + Calendar, go to File > Import, and choose the + downloaded file. + , + ], + }, + outlook: { + title: 'Add to Outlook Calendar', + steps: [ + 'Download the iCS File', + <> + Open Outlook and go to{' '} + File > Open & Export > Import/Export. + , + <> + In the Import and Export Wizard select{' '} + Import an iCalendar (.ics) or vCalendar file (.vcs). + You can then choose to keep it a separate calendar or make it a new + calendar. + , + ], }, - queryClient, + }; + + return ( + + + +
+ {selectedCalendar && ( + { + setSelectedCalendar(null); + }} + isLoading={isLoading} + /> + )} + + {!selectedCalendar && ( + <> +

+ Add to Your Calendar +

+

+ Export the event to your calendar of choice. Future changes to + either the original or the copy will not be reflected in the + other. +

+ +
+ + { + setSelectedCalendar('apple'); + }} + /> + { + setSelectedCalendar('outlook'); + }} + /> + + +
+ + )} +
+
); +} + +type SVGIcon = (props: SVGProps) => ReactNode; + +type CalendarButtonProps = { + icon: LucideIcon | SVGIcon; + label: string; + isLoading?: boolean; + onClick: () => void; +}; + +function CalendarButton(props: CalendarButtonProps) { + const { icon: Icon, label, isLoading, onClick } = props; return ( - -
- -

- Learning Calendar -

-

- Link your Google Calendar to start scheduling your learning sessions. -

+ + ); +} +type CalendarStepsProps = { + title: string; + steps: (string | ReactNode)[]; + onDownloadICS: () => void; + isLoading?: boolean; + onCancel: () => void; +}; + +export function CalendarSteps(props: CalendarStepsProps) { + const { steps, onDownloadICS, onCancel, title, isLoading } = props; + + return ( +
+

{title}

+ +
+ {steps.map((step, index) => ( +
+
+ {index + 1} +
+
+

{step}

+
+
+ ))} +
+ +
+
- +
); } diff --git a/src/hooks/use-schedule.ts b/src/hooks/use-schedule.ts deleted file mode 100644 index ea10e5ac6..000000000 --- a/src/hooks/use-schedule.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 64ba4db7c..000000000 --- a/src/lib/query-http.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 210d1d733..000000000 --- a/src/stores/query-client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { QueryCache, QueryClient } from '@tanstack/react-query'; - -export const queryClient = new QueryClient({ - queryCache: new QueryCache({}), - defaultOptions: { - queries: { - retry: false, - enabled: !import.meta.env.SSR, - }, - }, -});