feat: add calendar scheduling

feat/calendar
Arik Chakma 4 weeks ago
parent 0848ca1833
commit 18d4d5c052
  1. 40
      src/components/ReactIcons/AppleCalendarIcon.tsx
  2. 22
      src/components/ReactIcons/FileIcon.tsx
  3. 40
      src/components/ReactIcons/GoogleCalendarIcon.tsx
  4. 40
      src/components/ReactIcons/OutlookCalendarIcon.tsx
  5. 5
      src/components/RoadmapHeader.astro
  6. 11
      src/components/Schedule/ScheduleButton.tsx
  7. 306
      src/components/Schedule/ScheduleEventModal.tsx
  8. 31
      src/hooks/use-schedule.ts
  9. 146
      src/lib/query-http.ts
  10. 11
      src/stores/query-client.ts

@ -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>
);
}

@ -12,7 +12,7 @@ import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { type RoadmapFrontmatter } from '../lib/roadmap'; import { type RoadmapFrontmatter } from '../lib/roadmap';
import { ShareRoadmapButton } from './ShareRoadmapButton'; import { ShareRoadmapButton } from './ShareRoadmapButton';
import { DownloadRoadmapButton } from './DownloadRoadmapButton'; import { DownloadRoadmapButton } from './DownloadRoadmapButton';
import { ConnectCalendarButton } from './Schedule/ConnectCalendarButton'; import { ScheduleButton } from './Schedule/ScheduleButton';
export interface Props { export interface Props {
title: string; title: string;
@ -128,9 +128,10 @@ const hasTnsBanner = !!tnsBannerLink;
isActive={activeTab === 'projects'} isActive={activeTab === 'projects'}
badgeText={projectCount > 0 ? 'new' : 'soon'} badgeText={projectCount > 0 ? 'new' : 'soon'}
/> />
<ConnectCalendarButton <ScheduleButton
resourceId={roadmapId} resourceId={roadmapId}
resourceType='roadmap' resourceType='roadmap'
resourceTitle={title}
client:load client:load
/> />
</div> </div>

@ -4,13 +4,14 @@ import type { ResourceType } from '../../lib/resource-progress';
import { ScheduleEventModal } from './ScheduleEventModal'; import { ScheduleEventModal } from './ScheduleEventModal';
import { useState } from 'react'; import { useState } from 'react';
type ConnectCalendarButtonProps = { type ScheduleButtonProps = {
resourceId: string; resourceId: string;
resourceType: ResourceType; resourceType: ResourceType;
resourceTitle: string;
}; };
export function ConnectCalendarButton(props: ConnectCalendarButtonProps) { export function ScheduleButton(props: ScheduleButtonProps) {
const { resourceId, resourceType } = props; const { resourceId, resourceType, resourceTitle } = props;
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@ -21,6 +22,8 @@ export function ConnectCalendarButton(props: ConnectCalendarButtonProps) {
onClose={() => { onClose={() => {
setIsModalOpen(false); setIsModalOpen(false);
}} }}
roadmapId={resourceId}
roadmapTitle={resourceTitle}
/> />
)} )}
@ -33,7 +36,7 @@ export function ConnectCalendarButton(props: ConnectCalendarButtonProps) {
}} }}
> >
<Calendar className="h-4 w-4 flex-shrink-0" /> <Calendar className="h-4 w-4 flex-shrink-0" />
Schedule Learning <span className="hidden sm:inline">Schedule</span>
</button> </button>
</> </>
); );

@ -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 [isLoading, setIsLoading] = useState(false);
const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>( const location = `https://roadmap.sh/${roadmapId}`;
{ const title = `Learn ${roadmapTitle}`;
mutationFn: async () => { const details = `
return httpPost('/v1-connect-google-calendar', {}); Learn ${roadmapTitle} on roadmap.sh
},
onSuccess(data) {
const { redirectUrl } = data;
if (!redirectUrl) {
return;
}
window.location.href = redirectUrl; For more details, visit: https://roadmap.sh/${roadmapId}
}, `.trim();
onError(error) {
toast.error(error?.message || 'Failed to connect Google Calendar'); 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 &gt; 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 &gt; Open & Export &gt; 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 ( return (
<Modal onClose={onClose}> <Modal onClose={onClose} wrapperClassName="max-w-lg">
<div className="flex max-w-md flex-col items-center p-4 py-10 text-center"> <button
<BookOpen className="text-primary h-14 w-14" /> className="absolute right-4 top-4 text-gray-400 hover:text-black"
<h2 className="mt-4 text-xl font-bold tracking-tight"> onClick={onClose}
Learning Calendar >
<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> </h2>
<p className="mt-1 text-balance text-sm text-gray-600"> <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> </p>
<button <div className="mt-6 flex w-full flex-col gap-1">
className="mt-4 flex items-center gap-2 rounded-full bg-black px-5 py-3 leading-none text-white disabled:opacity-60" <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={() => { 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]" /> Download
Connect Calendar
</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…
Cancel
Save