parent
0848ca1833
commit
18d4d5c052
10 changed files with 424 additions and 242 deletions
@ -0,0 +1,40 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function AppleCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width={100} |
||||
height={100} |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#eceff1" |
||||
d="M15.556,43h16.889C38.274,43,43,38.274,43,32.444V15.556C43,9.726,38.274,5,32.444,5H15.556 C9.726,5,5,9.726,5,15.556v16.889C5,38.274,9.726,43,15.556,43z" |
||||
/> |
||||
<path |
||||
fill="#ff3d00" |
||||
d="M20.868,13.77L21.437,10h1.199l-1.067,6h-1.215l-0.7-3.536L18.96,16h-1.22l-1.071-6h1.207 l0.565,3.766L19.145,10h1.018L20.868,13.77z" |
||||
/> |
||||
<path |
||||
fill="#ff3d00" |
||||
d="M26.39,13.404h-1.887v1.591h2.234V16h-3.446v-6h3.437v1.009h-2.225v1.418h1.887V13.404z" |
||||
/> |
||||
<path |
||||
fill="#ff3d00" |
||||
d="M27.433,16v-6h1.587c0.7,0,1.259,0.223,1.675,0.667c0.416,0.445,0.628,1.055,0.636,1.83v0.973 c0,0.788-0.208,1.407-0.625,1.857C30.291,15.775,29.717,16,28.986,16H27.433z M28.645,11.009v3.985h0.363 c0.404,0,0.688-0.106,0.853-0.319c0.165-0.213,0.252-0.58,0.26-1.102V12.53c0-0.56-0.079-0.951-0.235-1.173 c-0.157-0.221-0.423-0.337-0.8-0.348H28.645z" |
||||
/> |
||||
<path |
||||
fill="#424242" |
||||
d="M23.211,36.004h-9.884V33.7l4.539-5.771c0.576-0.799,1-1.5,1.273-2.103 c0.273-0.603,0.409-1.181,0.409-1.734c0-0.745-0.128-1.329-0.386-1.751c-0.257-0.422-0.628-0.633-1.112-0.633 c-0.53,0-0.95,0.246-1.261,0.737c-0.311,0.492-0.467,1.183-0.467,2.074H13.05c0-1.029,0.213-1.97,0.639-2.823 c0.426-0.853,1.025-1.515,1.797-1.987C16.258,19.236,17.132,19,18.107,19c1.498,0,2.659,0.413,3.485,1.239 c0.826,0.826,1.239,1.999,1.239,3.52c0,0.944-0.229,1.903-0.685,2.874c-0.457,0.972-1.285,2.168-2.483,3.588l-2.154,3.076h5.702 V36.004z" |
||||
/> |
||||
<path |
||||
fill="#424242" |
||||
d="M34.662,23.689c0,0.814-0.173,1.536-0.519,2.166c-0.346,0.63-0.822,1.133-1.428,1.509 c0.691,0.392,1.237,0.931,1.636,1.619c0.399,0.687,0.599,1.496,0.599,2.425c0,1.49-0.43,2.667-1.29,3.531 c-0.86,0.864-2.032,1.296-3.514,1.296s-2.661-0.432-3.537-1.296c-0.875-0.864-1.313-2.041-1.313-3.531 c0-0.929,0.199-1.739,0.599-2.43c0.399-0.691,0.949-1.229,1.648-1.613c-0.615-0.376-1.094-0.879-1.44-1.509 c-0.346-0.63-0.519-1.351-0.519-2.166c0-1.467,0.411-2.615,1.233-3.445C27.638,19.415,28.741,19,30.123,19 c1.398,0,2.504,0.419,3.318,1.256C34.255,21.093,34.662,22.237,34.662,23.689z M30.146,33.527c0.491,0,0.87-0.21,1.135-0.628 c0.265-0.418,0.397-1,0.397-1.745s-0.138-1.328-0.415-1.751s-0.657-0.634-1.14-0.634c-0.484,0-0.866,0.211-1.146,0.634 s-0.421,1.006-0.421,1.751s0.14,1.327,0.421,1.745C29.257,33.318,29.647,33.527,30.146,33.527z M31.413,23.861 c0-0.653-0.106-1.175-0.317-1.566c-0.211-0.392-0.535-0.588-0.973-0.588c-0.415,0-0.73,0.19-0.944,0.57 c-0.215,0.38-0.323,0.908-0.323,1.584c0,0.661,0.108,1.192,0.323,1.596c0.215,0.403,0.537,0.605,0.967,0.605 c0.43,0,0.749-0.202,0.956-0.605C31.309,25.054,31.413,24.522,31.413,23.861z" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,22 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function FileIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width={100} |
||||
height={100} |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<path fill="#90CAF9" d="M40 45L8 45 8 3 30 3 40 13z" /> |
||||
<path fill="#E1F5FE" d="M38.5 14L29 14 29 4.5z" /> |
||||
<path |
||||
fill="#1976D2" |
||||
d="M16 21H33V23H16zM16 25H29V27H16zM16 29H33V31H16zM16 33H29V35H16z" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function GoogleCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width="100" |
||||
height="100" |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<rect width="22" height="22" x="13" y="13" fill="#fff"></rect> |
||||
<polygon |
||||
fill="#1e88e5" |
||||
points="25.68,20.92 26.688,22.36 28.272,21.208 28.272,29.56 30,29.56 30,18.616 28.56,18.616" |
||||
></polygon> |
||||
<path |
||||
fill="#1e88e5" |
||||
d="M22.943,23.745c0.625-0.574,1.013-1.37,1.013-2.249c0-1.747-1.533-3.168-3.417-3.168 c-1.602,0-2.972,1.009-3.33,2.453l1.657,0.421c0.165-0.664,0.868-1.146,1.673-1.146c0.942,0,1.709,0.646,1.709,1.44 c0,0.794-0.767,1.44-1.709,1.44h-0.997v1.728h0.997c1.081,0,1.993,0.751,1.993,1.64c0,0.904-0.866,1.64-1.931,1.64 c-0.962,0-1.784-0.61-1.914-1.418L17,26.802c0.262,1.636,1.81,2.87,3.6,2.87c2.007,0,3.64-1.511,3.64-3.368 C24.24,25.281,23.736,24.363,22.943,23.745z" |
||||
></path> |
||||
<polygon |
||||
fill="#fbc02d" |
||||
points="34,42 14,42 13,38 14,34 34,34 35,38" |
||||
></polygon> |
||||
<polygon |
||||
fill="#4caf50" |
||||
points="38,35 42,34 42,14 38,13 34,14 34,34" |
||||
></polygon> |
||||
<path |
||||
fill="#1e88e5" |
||||
d="M34,14l1-4l-1-4H9C7.343,6,6,7.343,6,9v25l4,1l4-1V14H34z" |
||||
></path> |
||||
<polygon fill="#e53935" points="34,34 34,42 42,34"></polygon> |
||||
<path fill="#1565c0" d="M39,6h-5v8h8V9C42,7.343,40.657,6,39,6z"></path> |
||||
<path fill="#1565c0" d="M9,42h5v-8H6v5C6,40.657,7.343,42,9,42z"></path> |
||||
</svg> |
||||
); |
||||
} |
@ -0,0 +1,40 @@ |
||||
import type { SVGProps } from 'react'; |
||||
|
||||
export function OutlookCalendarIcon(props: SVGProps<SVGSVGElement>) { |
||||
return ( |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
x="0px" |
||||
y="0px" |
||||
width={100} |
||||
height={100} |
||||
viewBox="0 0 48 48" |
||||
{...props} |
||||
> |
||||
<path |
||||
fill="#1976d2" |
||||
d="M28,13h14.533C43.343,13,44,13.657,44,14.467v19.066C44,34.343,43.343,35,42.533,35H28V13z" |
||||
/> |
||||
<rect width={14} height="15.542" x={28} y="17.958" fill="#fff" /> |
||||
<polygon fill="#1976d2" points="27,44 4,39.5 4,8.5 27,4" /> |
||||
<path |
||||
fill="#fff" |
||||
d="M15.25,16.5c-3.176,0-5.75,3.358-5.75,7.5s2.574,7.5,5.75,7.5S21,28.142,21,24 S18.426,16.5,15.25,16.5z M15,28.5c-1.657,0-3-2.015-3-4.5s1.343-4.5,3-4.5s3,2.015,3,4.5S16.657,28.5,15,28.5z" |
||||
/> |
||||
<rect width="2.7" height="2.9" x="28.047" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="29.737" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="28.047" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="26.159" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="28.047" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="22.706" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="31.448" y="19.112" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="34.849" y="19.112" fill="#1976d2" /> |
||||
<rect width="2.7" height="2.9" x="38.25" y="19.112" fill="#1976d2" /> |
||||
</svg> |
||||
); |
||||
} |
@ -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 { onClose, roadmapId, roadmapTitle } = props; |
||||
|
||||
const toast = useToast(); |
||||
const { isLoading } = useListConnectedCalenders(); |
||||
const [selectedCalendar, setSelectedCalendar] = useState< |
||||
'apple' | 'outlook' | null |
||||
>(null); |
||||
const [isLoading, setIsLoading] = useState(false); |
||||
|
||||
const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>( |
||||
{ |
||||
mutationFn: async () => { |
||||
return httpPost('/v1-connect-google-calendar', {}); |
||||
}, |
||||
onSuccess(data) { |
||||
const { redirectUrl } = data; |
||||
if (!redirectUrl) { |
||||
return; |
||||
} |
||||
const location = `https://roadmap.sh/${roadmapId}`; |
||||
const title = `Learn ${roadmapTitle}`; |
||||
const details = ` |
||||
Learn ${roadmapTitle} on roadmap.sh |
||||
|
||||
window.location.href = redirectUrl; |
||||
}, |
||||
onError(error) { |
||||
toast.error(error?.message || 'Failed to connect Google Calendar'); |
||||
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 <strong>File > Import</strong>, and choose the |
||||
downloaded file. |
||||
</>, |
||||
], |
||||
}, |
||||
outlook: { |
||||
title: 'Add to Outlook Calendar', |
||||
steps: [ |
||||
'Download the iCS File', |
||||
<> |
||||
Open Outlook and go to{' '} |
||||
<strong>File > Open & Export > Import/Export</strong>. |
||||
</>, |
||||
<> |
||||
In the Import and Export Wizard select{' '} |
||||
<strong>Import an iCalendar (.ics) or vCalendar file (.vcs)</strong>. |
||||
You can then choose to keep it a separate calendar or make it a new |
||||
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 |
||||
<Modal onClose={onClose} wrapperClassName="max-w-lg"> |
||||
<button |
||||
className="absolute right-4 top-4 text-gray-400 hover:text-black" |
||||
onClick={onClose} |
||||
> |
||||
<X className="h-4 w-4 stroke-[2.5]" /> |
||||
</button> |
||||
|
||||
<div className="flex flex-col items-center p-4 py-6 text-center"> |
||||
{selectedCalendar && ( |
||||
<CalendarSteps |
||||
title={steps[selectedCalendar].title} |
||||
steps={steps[selectedCalendar].steps} |
||||
onDownloadICS={downloadICS} |
||||
onCancel={() => { |
||||
setSelectedCalendar(null); |
||||
}} |
||||
isLoading={isLoading} |
||||
/> |
||||
)} |
||||
|
||||
{!selectedCalendar && ( |
||||
<> |
||||
<h2 className="text-2xl font-medium tracking-wide"> |
||||
Add to Your Calendar |
||||
</h2> |
||||
<p className="mt-1 text-balance text-sm text-gray-600"> |
||||
Link your Google Calendar to start scheduling your learning sessions. |
||||
Export the event to your calendar of choice. Future changes to |
||||
either the original or the copy will not be reflected in the |
||||
other. |
||||
</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" |
||||
<div className="mt-6 flex w-full flex-col gap-1"> |
||||
<CalendarButton |
||||
icon={GoogleCalendarIcon} |
||||
label="Google Calendar" |
||||
onClick={handleGoogleCalendar} |
||||
isLoading={isLoading} |
||||
/> |
||||
<CalendarButton |
||||
icon={AppleCalendarIcon} |
||||
label="Apple Calendar" |
||||
onClick={() => { |
||||
setSelectedCalendar('apple'); |
||||
}} |
||||
/> |
||||
<CalendarButton |
||||
icon={OutlookCalendarIcon} |
||||
label="Outlook Calendar" |
||||
onClick={() => { |
||||
connectGoogleCalendar.mutate(); |
||||
setSelectedCalendar('outlook'); |
||||
}} |
||||
disabled={connectGoogleCalendar.isPending} |
||||
/> |
||||
|
||||
<CalendarButton |
||||
icon={FileIcon} |
||||
label="Download File (.ics)" |
||||
onClick={downloadICS} |
||||
/> |
||||
</div> |
||||
</> |
||||
)} |
||||
</div> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
type SVGIcon = (props: SVGProps<SVGSVGElement>) => ReactNode; |
||||
|
||||
type CalendarButtonProps = { |
||||
icon: LucideIcon | SVGIcon; |
||||
label: string; |
||||
isLoading?: boolean; |
||||
onClick: () => void; |
||||
}; |
||||
|
||||
function CalendarButton(props: CalendarButtonProps) { |
||||
const { icon: Icon, label, isLoading, onClick } = props; |
||||
|
||||
return ( |
||||
<button |
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-3 leading-none hover:bg-gray-100 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
data-loading={isLoading} |
||||
disabled={isLoading} |
||||
onClick={onClick} |
||||
> |
||||
<div className="flex items-center gap-2"> |
||||
<Icon className="h-4 w-4 shrink-0 stroke-[2.5]" /> |
||||
{label} |
||||
</div> |
||||
<ChevronRight className="h-4 w-4 stroke-[2.5]" /> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<div className="flex flex-col"> |
||||
<h2 className="text-2xl font-medium tracking-wide">{title}</h2> |
||||
|
||||
<div className="mt-6 flex flex-col gap-2"> |
||||
{steps.map((step, index) => ( |
||||
<div key={index} className="flex items-center gap-3"> |
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-600"> |
||||
{index + 1} |
||||
</div> |
||||
<div className="flex flex-col gap-1"> |
||||
<p className="text-left text-sm text-gray-800">{step}</p> |
||||
</div> |
||||
</div> |
||||
))} |
||||
</div> |
||||
|
||||
<div className="mt-6 flex gap-2"> |
||||
<button |
||||
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
onClick={onCancel} |
||||
disabled={isLoading} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
className="flex-1 rounded-lg bg-blue-600 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60 data-[loading='true']:cursor-progress" |
||||
onClick={onDownloadICS} |
||||
disabled={isLoading} |
||||
data-loading={isLoading} |
||||
> |
||||
<Calendar className="h-4 w-4 shrink-0 stroke-[2.5]" /> |
||||
Connect Calendar |
||||
Download |
||||
</button> |
||||
</div> |
||||
</Modal> |
||||
</div> |
||||
); |
||||
} |
||||
|
@ -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<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, |
||||
); |
||||
} |
@ -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<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', |
||||
}); |
||||
} |
@ -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, |
||||
}, |
||||
}, |
||||
}); |
Loading…
Reference in new issue