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 { DateTime } from 'luxon'; |
||||||
import { useListConnectedCalenders } from '../../hooks/use-schedule'; |
|
||||||
import { Modal } from '../Modal'; |
import { Modal } from '../Modal'; |
||||||
import { Calendar, Loader2, Plus, BookOpen, Trash2 } from 'lucide-react'; |
import { ChevronRight, type LucideIcon, X } from 'lucide-react'; |
||||||
import { httpPost } from '../../lib/query-http'; |
import { useState, type ReactNode, type SVGProps } from 'react'; |
||||||
import { queryClient } from '../../stores/query-client'; |
import { GoogleCalendarIcon } from '../ReactIcons/GoogleCalendarIcon'; |
||||||
import { useToast } from '../../hooks/use-toast'; |
import { OutlookCalendarIcon } from '../ReactIcons/OutlookCalendarIcon'; |
||||||
|
import { AppleCalendarIcon } from '../ReactIcons/AppleCalendarIcon'; |
||||||
|
import { FileIcon } from '../ReactIcons/FileIcon'; |
||||||
|
|
||||||
type ConnectGoogleCalendarResponse = { |
function generateRoadmapIcsFile( |
||||||
redirectUrl: string; |
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 = { |
type ScheduleEventModalProps = { |
||||||
|
roadmapTitle: string; |
||||||
|
roadmapId: string; |
||||||
onClose: () => void; |
onClose: () => void; |
||||||
}; |
}; |
||||||
|
|
||||||
export function ScheduleEventModal(props: ScheduleEventModalProps) { |
export function ScheduleEventModal(props: ScheduleEventModalProps) { |
||||||
const { onClose } = props; |
const { onClose, roadmapId, roadmapTitle } = props; |
||||||
|
|
||||||
const toast = useToast(); |
const [selectedCalendar, setSelectedCalendar] = useState< |
||||||
const { isLoading } = useListConnectedCalenders(); |
'apple' | 'outlook' | null |
||||||
|
>(null); |
||||||
const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>( |
const [isLoading, setIsLoading] = useState(false); |
||||||
{ |
|
||||||
mutationFn: async () => { |
const location = `https://roadmap.sh/${roadmapId}`; |
||||||
return httpPost('/v1-connect-google-calendar', {}); |
const title = `Learn ${roadmapTitle}`; |
||||||
}, |
const details = ` |
||||||
onSuccess(data) { |
Learn ${roadmapTitle} on roadmap.sh |
||||||
const { redirectUrl } = data; |
|
||||||
if (!redirectUrl) { |
For more details, visit: https://roadmap.sh/${roadmapId}
|
||||||
return; |
`.trim();
|
||||||
} |
|
||||||
|
const downloadICS = () => { |
||||||
window.location.href = redirectUrl; |
setIsLoading(true); |
||||||
}, |
|
||||||
onError(error) { |
const startDate = DateTime.now().minus({ |
||||||
toast.error(error?.message || 'Failed to connect Google Calendar'); |
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} 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"> |
||||||
|
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> |
||||||
|
|
||||||
|
<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={() => { |
||||||
|
setSelectedCalendar('outlook'); |
||||||
|
}} |
||||||
|
/> |
||||||
|
|
||||||
|
<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 ( |
return ( |
||||||
<Modal onClose={onClose}> |
<button |
||||||
<div className="flex max-w-md flex-col items-center p-4 py-10 text-center"> |
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" |
||||||
<BookOpen className="text-primary h-14 w-14" /> |
data-loading={isLoading} |
||||||
<h2 className="mt-4 text-xl font-bold tracking-tight"> |
disabled={isLoading} |
||||||
Learning Calendar |
onClick={onClick} |
||||||
</h2> |
> |
||||||
<p className="mt-1 text-balance text-sm text-gray-600"> |
<div className="flex items-center gap-2"> |
||||||
Link your Google Calendar to start scheduling your learning sessions. |
<Icon className="h-4 w-4 shrink-0 stroke-[2.5]" /> |
||||||
</p> |
{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 |
<button |
||||||
className="mt-4 flex items-center gap-2 rounded-full bg-black px-5 py-3 leading-none text-white disabled:opacity-60" |
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={() => { |
onClick={onCancel} |
||||||
connectGoogleCalendar.mutate(); |
disabled={isLoading} |
||||||
}} |
|
||||||
disabled={connectGoogleCalendar.isPending} |
|
||||||
> |
> |
||||||
<Calendar className="h-4 w-4 shrink-0 stroke-[2.5]" /> |
Cancel |
||||||
Connect Calendar |
</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} |
||||||
|
> |
||||||
|
Download |
||||||
</button> |
</button> |
||||||
</div> |
</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