feat/calendar
Arik Chakma 6 months ago
parent 687d3ee4da
commit 0848ca1833
  1. 1
      package.json
  2. 18
      pnpm-lock.yaml
  3. 7
      src/components/RoadmapHeader.astro
  4. 40
      src/components/Schedule/ConnectCalendarButton.tsx
  5. 67
      src/components/Schedule/ScheduleEventModal.tsx
  6. 31
      src/hooks/use-schedule.ts
  7. 146
      src/lib/query-http.ts
  8. 11
      src/stores/query-client.ts

@ -37,6 +37,7 @@
"@nanostores/react": "^0.8.0",
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
"@tanstack/react-query": "^5.59.16",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"astro": "^4.16.1",

@ -32,6 +32,9 @@ importers:
'@resvg/resvg-js':
specifier: ^2.6.2
version: 2.6.2
'@tanstack/react-query':
specifier: ^5.59.16
version: 5.59.16(react@18.3.1)
'@types/react':
specifier: ^18.3.11
version: 18.3.11
@ -1198,6 +1201,14 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
'@tanstack/query-core@5.59.16':
resolution: {integrity: sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==}
'@tanstack/react-query@5.59.16':
resolution: {integrity: sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -4278,6 +4289,13 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.13
'@tanstack/query-core@5.59.16': {}
'@tanstack/react-query@5.59.16(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.59.16
react: 18.3.1
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.7.0

@ -12,6 +12,7 @@ import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { type RoadmapFrontmatter } from '../lib/roadmap';
import { ShareRoadmapButton } from './ShareRoadmapButton';
import { DownloadRoadmapButton } from './DownloadRoadmapButton';
import { ConnectCalendarButton } from './Schedule/ConnectCalendarButton';
export interface Props {
title: string;
@ -127,6 +128,11 @@ const hasTnsBanner = !!tnsBannerLink;
isActive={activeTab === 'projects'}
badgeText={projectCount > 0 ? 'new' : 'soon'}
/>
<ConnectCalendarButton
resourceId={roadmapId}
resourceType='roadmap'
client:load
/>
</div>
<TabLink
@ -135,6 +141,7 @@ const hasTnsBanner = !!tnsBannerLink;
text='Suggest Changes'
isExternal={true}
hideTextOnMobile={true}
isActive={false}
/>
</div>
</div>

@ -0,0 +1,40 @@
import { Calendar } from 'lucide-react';
import { cn } from '../../lib/classname';
import type { ResourceType } from '../../lib/resource-progress';
import { ScheduleEventModal } from './ScheduleEventModal';
import { useState } from 'react';
type ConnectCalendarButtonProps = {
resourceId: string;
resourceType: ResourceType;
};
export function ConnectCalendarButton(props: ConnectCalendarButtonProps) {
const { resourceId, resourceType } = props;
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
{isModalOpen && (
<ScheduleEventModal
onClose={() => {
setIsModalOpen(false);
}}
/>
)}
<button
className={cn(
'group inline-flex items-center gap-1.5 border-b-2 border-b-transparent px-2 pb-2.5 text-sm font-normal text-gray-400 transition-colors hover:text-gray-700',
)}
onClick={() => {
setIsModalOpen(true);
}}
>
<Calendar className="h-4 w-4 flex-shrink-0" />
Schedule Learning
</button>
</>
);
}

@ -0,0 +1,67 @@
import { useMutation } from '@tanstack/react-query';
import { useListConnectedCalenders } from '../../hooks/use-schedule';
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';
type ConnectGoogleCalendarResponse = {
redirectUrl: string;
};
type ScheduleEventModalProps = {
onClose: () => void;
};
export function ScheduleEventModal(props: ScheduleEventModalProps) {
const { onClose } = props;
const toast = useToast();
const { isLoading } = useListConnectedCalenders();
const connectGoogleCalendar = useMutation<ConnectGoogleCalendarResponse>(
{
mutationFn: async () => {
return httpPost('/v1-connect-google-calendar', {});
},
onSuccess(data) {
const { redirectUrl } = data;
if (!redirectUrl) {
return;
}
window.location.href = redirectUrl;
},
onError(error) {
toast.error(error?.message || 'Failed to connect Google Calendar');
},
},
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
</h2>
<p className="mt-1 text-balance text-sm text-gray-600">
Link your Google Calendar to start scheduling your learning sessions.
</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"
onClick={() => {
connectGoogleCalendar.mutate();
}}
disabled={connectGoogleCalendar.isPending}
>
<Calendar className="h-4 w-4 shrink-0 stroke-[2.5]" />
Connect Calendar
</button>
</div>
</Modal>
);
}

@ -0,0 +1,31 @@
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,
);
}

@ -0,0 +1,146 @@
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',
});
}

@ -0,0 +1,11 @@
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