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. 320
      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 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 &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 (
<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…
Cancel
Save