diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index abf6885712..b0879dc6de 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -61,6 +61,7 @@ "next-auth": "^4.22.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "qs-stringify": "^1.2.1", "querystring": "^0.2.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts index c2a74e1421..0ed0e30c18 100644 --- a/apps/api/v2/src/modules/apps/apps.repository.ts +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -1,7 +1,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; -import { App } from "@prisma/client"; +import { App, Prisma } from "@prisma/client"; @Injectable() export class AppsRepository { @@ -10,4 +10,15 @@ export class AppsRepository { async getAppBySlug(slug: string): Promise { return await this.dbRead.prisma.app.findUnique({ where: { slug } }); } + + async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) { + return this.dbWrite.prisma.credential.create({ + data: { + type: type, + key: key, + userId: userId, + appId: appId, + }, + }); + } } diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index eb6ed4876a..6401726b20 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -3,6 +3,7 @@ import { AtomsModule } from "@/modules/atoms/atoms.module"; import { BillingModule } from "@/modules/billing/billing.module"; import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module"; import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; import { TimezoneModule } from "@/modules/timezones/timezones.module"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; @@ -20,6 +21,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; WebhooksModule, DestinationCalendarsModule, AtomsModule, + StripeModule, ], }) export class EndpointsModule implements NestModule { diff --git a/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts new file mode 100644 index 0000000000..cbf6b1b6e4 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/controllers/stripe.controller.ts @@ -0,0 +1,101 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + StripConnectOutputDto, + StripConnectOutputResponseDto, + StripCredentialsCheckOutputResponseDto, + StripCredentialsSaveOutputResponseDto, +} from "@/modules/stripe/outputs/stripe.output"; +import { StripeService } from "@/modules/stripe/stripe.service"; +import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Controller, + Query, + UseGuards, + Get, + HttpCode, + HttpStatus, + Redirect, + Req, + BadRequestException, + Headers, +} from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; +import { Request } from "express"; +import { stringify } from "querystring"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/stripe", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Stripe") +export class StripeController { + constructor(private readonly stripeService: StripeService) {} + + @Get("/connect") + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @GetUser() user: UserWithProfile, + @Query("redir") redir?: string | null, + @Query("errorRedir") errorRedir?: string | null + ): Promise { + const origin = req.headers.origin; + const accessToken = authorization.replace("Bearer ", ""); + + const state = { + onErrorReturnTo: !!errorRedir ? errorRedir : origin, + fromApp: false, + returnTo: !!redir ? redir : origin, + accessToken, + }; + + const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl( + JSON.stringify(state), + user.email, + user.name + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }), + }; + } + + @Get("/save") + @UseGuards() + @Redirect(undefined, 301) + async save( + @Query("state") state: string, + @Query("code") code: string, + @Query("error") error: string | undefined, + @Query("error_description") error_description: string | undefined + ): Promise { + const accessToken = JSON.parse(state).accessToken; + + // user cancels flow + if (error === "access_denied") { + return { url: getOnErrorReturnToValueFromQueryState(state) }; + } + + if (error) { + throw new BadRequestException(stringify({ error, error_description })); + } + + return await this.stripeService.saveStripeAccount(state, code, accessToken); + } + + @Get("/check") + @UseGuards(ApiAuthGuard) + @HttpCode(HttpStatus.OK) + async check(@GetUser() user: UserWithProfile): Promise { + return await this.stripeService.checkIfStripeAccountConnected(user.id); + } +} diff --git a/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts b/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts new file mode 100644 index 0000000000..0590eff1c2 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/inputs/stripe.input.ts @@ -0,0 +1,7 @@ +import { IsString, IsOptional } from "class-validator"; + +export class StripeConnectQueryParamsInputDto { + @IsString() + @IsOptional() + readonly redir?: string; +} diff --git a/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts new file mode 100644 index 0000000000..ca9cb0a263 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/outputs/stripe.output.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class StripConnectOutputDto { + @IsString() + @Expose() + readonly authUrl!: string; +} + +export class StripConnectOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => StripConnectOutputDto) + data!: StripConnectOutputDto; +} + +export class StripCredentialsCheckOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS }) + status!: typeof SUCCESS_STATUS; +} + +export class StripCredentialsSaveOutputResponseDto { + @IsString() + @Expose() + readonly url!: string; +} diff --git a/apps/api/v2/src/modules/stripe/stripe.module.ts b/apps/api/v2/src/modules/stripe/stripe.module.ts index 2452e10b6e..0191c2fad7 100644 --- a/apps/api/v2/src/modules/stripe/stripe.module.ts +++ b/apps/api/v2/src/modules/stripe/stripe.module.ts @@ -1,10 +1,16 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { StripeController } from "@/modules/stripe/controllers/stripe.controller"; import { StripeService } from "@/modules/stripe/stripe.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, PrismaModule], exports: [StripeService], - providers: [StripeService], + providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository], + controllers: [StripeController], }) export class StripeModule {} diff --git a/apps/api/v2/src/modules/stripe/stripe.service.ts b/apps/api/v2/src/modules/stripe/stripe.service.ts index b93f9f5e9a..a1bf625578 100644 --- a/apps/api/v2/src/modules/stripe/stripe.service.ts +++ b/apps/api/v2/src/modules/stripe/stripe.service.ts @@ -1,15 +1,130 @@ import { AppConfig } from "@/config/type"; -import { Injectable } from "@nestjs/common"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState"; +import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance"; +import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import type { Prisma } from "@prisma/client"; import Stripe from "stripe"; +import { z } from "zod"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas"; + +import stringify = require("qs-stringify"); @Injectable() export class StripeService { public stripe: Stripe; + private redirectUri = `${this.config.get("api.url")}/stripe/save`; - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private readonly config: ConfigService, + private readonly appsRepository: AppsRepository, + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository + ) { this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", { apiVersion: "2020-08-27", }); } + + async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) { + const { client_id } = await this.getStripeAppKeys(); + + const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = { + client_id, + scope: "read_write", + response_type: "code", + stripe_user: { + email: userEmail, + first_name: userName || undefined, + /** We need this so E2E don't fail for international users */ + country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined, + }, + redirect_uri: this.redirectUri, + state: state, + }; + + const params = z.record(z.any()).parse(stripeConnectParams); + const query = stringify(params); + const url = `https://connect.stripe.com/oauth/authorize?${query}`; + + return url; + } + + async getStripeAppKeys() { + const app = await this.appsRepository.getAppBySlug("stripe"); + + const { client_id, client_secret } = stripeKeysResponseSchema.parse(app?.keys); + + if (!client_id) { + throw new NotFoundException("Stripe app not found"); + } + + if (!client_secret) { + throw new NotFoundException("Stripe app not found"); + } + + return { client_id, client_secret }; + } + + async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> { + const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!userId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const response = await stripeInstance.oauth.token({ + grant_type: "authorization_code", + code: code?.toString(), + }); + + const data: StripeData = { ...response, default_currency: "" }; + if (response["stripe_user_id"]) { + const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]); + data["default_currency"] = account.default_currency; + } + + await this.appsRepository.createAppCredential( + "stripe_payment", + data as unknown as Prisma.InputJsonObject, + userId, + "stripe" + ); + + return { url: getReturnToValueFromQueryState(state) }; + } + + async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId); + + if (!stripeCredentials) { + throw new NotFoundException("Credentials for stripe not found."); + } + + if (stripeCredentials.invalid) { + throw new BadRequestException("Invalid stripe credentials."); + } + + const stripeKey = JSON.stringify(stripeCredentials.key); + const stripeKeyObject = JSON.parse(stripeKey); + + const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id); + + // both of these should be true for an account to be fully active + if (!stripeAccount.payouts_enabled || !stripeAccount.charges_enabled) { + throw new BadRequestException("Stripe account is not an active account"); + } + + return { + status: SUCCESS_STATUS, + }; + } } diff --git a/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts b/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts new file mode 100644 index 0000000000..a905d153b0 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/getReturnToValueFromQueryState.ts @@ -0,0 +1,19 @@ +export const getReturnToValueFromQueryState = (queryState: string | string[] | undefined) => { + let returnTo = ""; + try { + returnTo = JSON.parse(`${queryState}`).returnTo; + } catch (error) { + console.info("No 'returnTo' in req.query.state"); + } + return returnTo; +}; + +export const getOnErrorReturnToValueFromQueryState = (queryState: string | string[] | undefined) => { + let returnTo = ""; + try { + returnTo = JSON.parse(`${queryState}`).onErrorReturnTo; + } catch (error) { + console.info("No 'onErrorReturnTo' in req.query.state"); + } + return returnTo; +}; diff --git a/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts b/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts new file mode 100644 index 0000000000..1784b78305 --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/newStripeInstance.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe"; + +const stripeApiKey = process.env.STRIPE_API_KEY || ""; +export const stripeInstance = new Stripe(stripeApiKey, { + apiVersion: "2020-08-27", +}); diff --git a/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts b/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts new file mode 100644 index 0000000000..77fada616c --- /dev/null +++ b/apps/api/v2/src/modules/stripe/utils/stripeDataSchemas.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const stripeOAuthTokenSchema = z.object({ + access_token: z.string().optional(), + scope: z.string().optional(), + livemode: z.boolean().optional(), + token_type: z.literal("bearer").optional(), + refresh_token: z.string().optional(), + stripe_user_id: z.string().optional(), + stripe_publishable_key: z.string().optional(), +}); + +export const stripeDataSchema = stripeOAuthTokenSchema.extend({ + default_currency: z.string(), +}); + +export type StripeData = z.infer; + +export const stripeKeysResponseSchema = z.object({ + client_id: z.string().startsWith("ca_").min(1), + client_secret: z.string().startsWith("sk_").min(1), + public_key: z.string().startsWith("pk_").min(1), + webhook_secret: z.string().startsWith("whsec_").min(1), +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ece75ba18d..bfe7464c1f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2605,6 +2605,9 @@ "outlook_connect_atom_label": "Connect Outlook Calendar", "outlook_connect_atom_already_connected_label": "Connected Outlook Calendar", "outlook_connect_atom_loading_label": "Checking Outlook Calendar", + "stripe_connect_atom_label": "Connect to Stripe", + "stripe_connect_atom_already_connected_label": "Connected Stripe", + "stripe_connect_atom_loading_label": "Checking Stripe credentials", "booking_question_response_variables": "Booking question response variables", "managed_by_teamAdmins": "Managed by {{teamAdmins}}", "number_of_options":"{{count}} options", diff --git a/packages/platform/atoms/connect/stripe/StripeConnect.tsx b/packages/platform/atoms/connect/stripe/StripeConnect.tsx new file mode 100644 index 0000000000..ac369a9cc5 --- /dev/null +++ b/packages/platform/atoms/connect/stripe/StripeConnect.tsx @@ -0,0 +1,72 @@ +import type { FC } from "react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui"; + +import type { OnCheckErrorType, UseCheckProps } from "../../hooks/connect/useCheck"; +import { useCheck } from "../../hooks/stripe/useCheck"; +import { useConnect } from "../../hooks/stripe/useConnect"; +import { AtomsWrapper } from "../../src/components/atoms-wrapper"; +import { cn } from "../../src/lib/utils"; + +type StripeConnectProps = { + className?: string; + label: string; + alreadyConnectedLabel: string; + loadingLabel: string; + onCheckError?: OnCheckErrorType; + redir?: string; + errorRedir?: string; + initialData: UseCheckProps["initialData"]; + onCheckSuccess?: () => void; +}; + +export const StripeConnect: FC> = ({ + label, + className, + loadingLabel, + alreadyConnectedLabel, + redir, + errorRedir, + onCheckError, + initialData, + onCheckSuccess, +}) => { + const { t } = useLocale(); + const { connect } = useConnect(redir, errorRedir); + const { allowConnect, checked } = useCheck({ + onCheckError, + onCheckSuccess, + initialData, + }); + + let displayedLabel = label || t("stripe_connect_atom_label"); + + const isChecking = !checked; + const isDisabled = isChecking || !allowConnect; + + if (isChecking) { + displayedLabel = loadingLabel || t("stripe_connect_atom_loading_label"); + } else if (!allowConnect) { + displayedLabel = alreadyConnectedLabel || t("stripe_connect_atom_already_connected_label"); + } + + return ( + + + + ); +}; diff --git a/packages/platform/atoms/hooks/stripe/useCheck.ts b/packages/platform/atoms/hooks/stripe/useCheck.ts new file mode 100644 index 0000000000..23eda3d77b --- /dev/null +++ b/packages/platform/atoms/hooks/stripe/useCheck.ts @@ -0,0 +1,62 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { CALENDARS } from "@calcom/platform-constants"; +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; + +import http from "../../lib/http"; +import { useAtomsContext } from "../useAtomsContext"; + +export interface UseCheckProps { + onCheckError?: OnCheckErrorType; + onCheckSuccess?: () => void; + initialData?: { + status: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + data: { + allowConnect: boolean; + checked: boolean; + }; + }; +} +const stripeQueryKey = ["get-stripe-check"]; +export type OnCheckErrorType = (err: ApiErrorResponse) => void; +export const getQueryKey = (calendar: (typeof CALENDARS)[number]) => [`get-${calendar}-check`]; + +export const useCheck = ({ onCheckError, initialData, onCheckSuccess }: UseCheckProps) => { + const { isInit, accessToken } = useAtomsContext(); + const queryClient = useQueryClient(); + + const { data: check, refetch } = useQuery({ + queryKey: stripeQueryKey, + staleTime: 6000, + enabled: isInit && !!accessToken, + queryFn: () => { + return http + ?.get>(`/stripe/check`) + .then(({ data: responseBody }) => { + if (responseBody.status === SUCCESS_STATUS) { + onCheckSuccess(); + return { status: SUCCESS_STATUS, data: { allowConnect: false, checked: true } }; + } + onCheckError?.(responseBody); + return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } }; + }) + .catch((err) => { + onCheckError?.(err); + return { status: ERROR_STATUS, data: { allowConnect: true, checked: true } }; + }); + }, + initialData, + }); + return { + allowConnect: check?.data?.allowConnect ?? false, + checked: check?.data?.checked ?? false, + refetch: () => { + queryClient.setQueryData(stripeQueryKey, { + status: SUCCESS_STATUS, + data: { allowConnect: false, checked: false }, + }); + refetch(); + }, + }; +}; diff --git a/packages/platform/atoms/hooks/stripe/useConnect.ts b/packages/platform/atoms/hooks/stripe/useConnect.ts new file mode 100644 index 0000000000..4102d63dd0 --- /dev/null +++ b/packages/platform/atoms/hooks/stripe/useConnect.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse } from "@calcom/platform-types"; + +import http from "../../lib/http"; + +export const useGetRedirectUrl = (redir?: string, errorRedir?: string) => { + const authUrl = useQuery({ + queryKey: ["get-stripe-connect-redirect-uri"], + staleTime: Infinity, + enabled: false, + queryFn: () => { + return http + ?.get>( + `/stripe/connect${redir ? `?redir=${encodeURIComponent(redir)}` : "?redir="}${ + errorRedir ? `&errorRedir=${encodeURIComponent(errorRedir)}` : "" + }` + ) + .then(({ data: responseBody }) => { + if (responseBody.status === SUCCESS_STATUS) { + return responseBody.data.authUrl; + } + if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message); + return ""; + }); + }, + }); + + return authUrl; +}; + +export const useConnect = (redir?: string, errorRedir?: string) => { + const { refetch } = useGetRedirectUrl(redir, errorRedir); + + const connect = async () => { + const redirectUri = await refetch(); + + if (redirectUri.data) { + window.location.href = redirectUri.data; + } + }; + + return { connect }; +}; diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 10e469dab0..651ca848f6 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -25,3 +25,4 @@ export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettin export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index"; export type { UpdateScheduleInput_2024_06_11 as UpdateScheduleBody } from "@calcom/platform-types"; export { EventTypePlatformWrapper as EventTypeSettings } from "./event-types/wrappers/EventTypePlatformWrapper"; +export { StripeConnect } from "./connect/stripe/StripeConnect"; diff --git a/packages/platform/examples/base/src/pages/index.tsx b/packages/platform/examples/base/src/pages/index.tsx index a754a5fff4..c30f972654 100644 --- a/packages/platform/examples/base/src/pages/index.tsx +++ b/packages/platform/examples/base/src/pages/index.tsx @@ -1,7 +1,7 @@ import { Navbar } from "@/components/Navbar"; import { Inter, Poppins } from "next/font/google"; -import { GcalConnect, Connect } from "@calcom/atoms"; +import { Connect, StripeConnect } from "@calcom/atoms"; const inter = Inter({ subsets: ["latin"] }); const poppins = Poppins({ subsets: ["latin"], weight: ["400", "800"] }); @@ -20,7 +20,7 @@ export default function Home(props: { calUsername: string; calEmail: string }) { To get started, connect your google calendar.

- @@ -33,6 +33,13 @@ export default function Home(props: { calUsername: string; calEmail: string }) { isMultiCalendar={true} className="h-[40px] bg-gradient-to-r from-[#8A2387] via-[#E94057] to-[#F27121] text-center text-base font-semibold text-transparent text-white hover:bg-orange-700" /> + { + console.log("stripe account connected successfully".toLocaleUpperCase()); + }} + />