feat: payment card atom (#17203)
* init stripe payment card atom * cleanup * update platform libraries * fix import paths * add function to get user payment info * fixup * resolve merge conflicts * fixup fixup * resolve merge conflicts * fixup * update payments page * add props for event payment success and failure * update props and fixup * bump platform libraries * fixup! bump platform libraries * fixup! fixup! bump platform libraries * resolve merge conflicts * fix: add isActive to paymentsTab --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
co-authored by
Morgan
Morgan Vernay
parent
a5806cb100
commit
fee3b73bda
@@ -1,5 +1,6 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { paymentDataSelect } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class AtomsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getRawPayment(uid: string) {
|
||||
return await this.dbRead.prisma.payment.findFirst({
|
||||
where: { uid },
|
||||
select: paymentDataSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,18 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("payment/:uid")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
|
||||
const data = await this.eventTypesService.getUserPaymentInfo(uid);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getEnabledAppsFromCredentials,
|
||||
getAppFromSlug,
|
||||
MembershipRole,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/platform-libraries";
|
||||
import type {
|
||||
App,
|
||||
@@ -27,6 +29,7 @@ import type {
|
||||
TDependencyData,
|
||||
CredentialPayload,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
type EnabledAppType = App & {
|
||||
@@ -40,6 +43,7 @@ export class EventTypesAtomService {
|
||||
constructor(
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly atomsRepository: AtomsRepository,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
@@ -274,4 +278,43 @@ export class EventTypesAtomService {
|
||||
);
|
||||
return apps[0];
|
||||
}
|
||||
|
||||
async getUserPaymentInfo(uid: string) {
|
||||
const rawPayment = await this.atomsRepository.getRawPayment(uid);
|
||||
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
|
||||
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||
const payment = {
|
||||
...restPayment,
|
||||
data: data as Record<string, unknown>,
|
||||
};
|
||||
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
|
||||
const { startTime, endTime, eventType, ...restBooking } = _booking;
|
||||
const booking = {
|
||||
...restBooking,
|
||||
startTime: startTime.toString(),
|
||||
endTime: endTime.toString(),
|
||||
};
|
||||
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
|
||||
if (eventType.users.length === 0 && !!!eventType.team)
|
||||
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
|
||||
const [user] = eventType?.users.length
|
||||
? eventType.users
|
||||
: [{ name: null, theme: null, hideBranding: null, username: null }];
|
||||
const profile = {
|
||||
name: eventType.team?.name || user?.name || null,
|
||||
theme: (!eventType.team?.name && user?.theme) || null,
|
||||
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
|
||||
};
|
||||
return {
|
||||
user,
|
||||
eventType: {
|
||||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
},
|
||||
booking,
|
||||
payment,
|
||||
clientSecret: getClientSecretFromPayment(payment),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Payment } from "@prisma/client";
|
||||
import type { EventType } from "@prisma/client";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import type { StripeElementLocale } from "@stripe/stripe-js";
|
||||
import type { StripeElementLocale, StripeElements } from "@stripe/stripe-js";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { SyntheticEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -10,11 +10,12 @@ import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { PaymentOption } from "@calcom/prisma/enums";
|
||||
import { Button, CheckboxField } from "@calcom/ui";
|
||||
|
||||
import type { PaymentPageProps } from "../pages/payment";
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
payment: Omit<Payment, "id" | "fee" | "success" | "refunded" | "externalId" | "data"> & {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
@@ -31,7 +32,7 @@ type Props = {
|
||||
booking: PaymentPageProps["booking"];
|
||||
};
|
||||
|
||||
type States =
|
||||
export type States =
|
||||
| {
|
||||
status: "idle";
|
||||
}
|
||||
@@ -46,23 +47,94 @@ type States =
|
||||
status: "ok";
|
||||
};
|
||||
|
||||
export const PaymentFormComponent = (
|
||||
props: Props & {
|
||||
onSubmit: (ev: SyntheticEvent) => void;
|
||||
onCancel: () => void;
|
||||
onPaymentElementChange: () => void;
|
||||
elements: StripeElements | null;
|
||||
paymentOption: PaymentOption | null;
|
||||
state: States;
|
||||
}
|
||||
) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const { paymentOption, elements, state, onPaymentElementChange } = props;
|
||||
const [isCanceling, setIsCanceling] = useState<boolean>(false);
|
||||
const [holdAcknowledged, setHoldAcknowledged] = useState<boolean>(paymentOption === "HOLD" ? false : true);
|
||||
const disableButtons = isCanceling || !holdAcknowledged || ["processing", "error"].includes(state.status);
|
||||
|
||||
useEffect(() => {
|
||||
elements?.update({ locale: i18n.language as StripeElementLocale });
|
||||
}, [elements, i18n.language]);
|
||||
|
||||
return (
|
||||
<form id="payment-form" className="bg-subtle mt-4 rounded-md p-6" onSubmit={props.onSubmit}>
|
||||
<div>
|
||||
<PaymentElement onChange={() => onPaymentElementChange()} />
|
||||
</div>
|
||||
{paymentOption === "HOLD" && (
|
||||
<div className="bg-info mb-5 mt-2 rounded-md p-3">
|
||||
<CheckboxField
|
||||
description={t("acknowledge_booking_no_show_fee", {
|
||||
amount: props.payment.amount / 100,
|
||||
formatParams: { amount: { currency: props.payment.currency } },
|
||||
})}
|
||||
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
||||
descriptionClassName="text-info font-semibold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end space-x-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
disabled={disableButtons}
|
||||
id="cancel"
|
||||
type="button"
|
||||
loading={isCanceling}
|
||||
onClick={() => {
|
||||
setIsCanceling(true);
|
||||
props.onCancel();
|
||||
}}>
|
||||
<span id="button-text">{t("cancel")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disableButtons}
|
||||
loading={state.status === "processing"}
|
||||
id="submit"
|
||||
color="secondary">
|
||||
<span id="button-text">
|
||||
{state.status === "processing" ? (
|
||||
<div className="spinner" id="spinner" />
|
||||
) : paymentOption === "HOLD" ? (
|
||||
t("submit_card")
|
||||
) : (
|
||||
t("pay_now")
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<div className="mt-4 text-center text-red-900 dark:text-gray-300" role="alert">
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const PaymentForm = (props: Props) => {
|
||||
const {
|
||||
user: { username },
|
||||
} = props;
|
||||
const { t, i18n } = useLocale();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useCompatSearchParams();
|
||||
const [state, setState] = useState<States>({ status: "idle" });
|
||||
const [isCanceling, setIsCanceling] = useState<boolean>(false);
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const paymentOption = props.payment.paymentOption;
|
||||
const [holdAcknowledged, setHoldAcknowledged] = useState<boolean>(paymentOption === "HOLD" ? false : true);
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
useEffect(() => {
|
||||
elements?.update({ locale: i18n.language as StripeElementLocale });
|
||||
}, [elements, i18n.language]);
|
||||
|
||||
const handleSubmit = async (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -71,6 +143,10 @@ const PaymentForm = (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "processing" });
|
||||
|
||||
let payload;
|
||||
@@ -130,64 +206,23 @@ const PaymentForm = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const disableButtons = isCanceling || !holdAcknowledged || ["processing", "error"].includes(state.status);
|
||||
|
||||
return (
|
||||
<form id="payment-form" className="bg-subtle mt-4 rounded-md p-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<PaymentElement onChange={() => setState({ status: "idle" })} />
|
||||
</div>
|
||||
{paymentOption === "HOLD" && (
|
||||
<div className="bg-info mb-5 mt-2 rounded-md p-3">
|
||||
<CheckboxField
|
||||
description={t("acknowledge_booking_no_show_fee", {
|
||||
amount: props.payment.amount / 100,
|
||||
formatParams: { amount: { currency: props.payment.currency } },
|
||||
})}
|
||||
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
||||
descriptionClassName="text-info font-semibold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-end space-x-2">
|
||||
<Button
|
||||
color="minimal"
|
||||
disabled={disableButtons}
|
||||
id="cancel"
|
||||
type="button"
|
||||
loading={isCanceling}
|
||||
onClick={() => {
|
||||
setIsCanceling(true);
|
||||
if (username) {
|
||||
return router.push(`/${username}`);
|
||||
}
|
||||
return router.back();
|
||||
}}>
|
||||
<span id="button-text">{t("cancel")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disableButtons}
|
||||
loading={state.status === "processing"}
|
||||
id="submit"
|
||||
color="secondary">
|
||||
<span id="button-text">
|
||||
{state.status === "processing" ? (
|
||||
<div className="spinner" id="spinner" />
|
||||
) : paymentOption === "HOLD" ? (
|
||||
t("submit_card")
|
||||
) : (
|
||||
t("pay_now")
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<div className="mt-4 text-center text-red-900 dark:text-gray-300" role="alert">
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<PaymentFormComponent
|
||||
{...props}
|
||||
elements={elements}
|
||||
paymentOption={paymentOption}
|
||||
state={state}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (username) {
|
||||
return router.push(`/${username}`);
|
||||
}
|
||||
return router.back();
|
||||
}}
|
||||
onPaymentElementChange={() => {
|
||||
setState({ status: "idle" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { paymentDataSelect } from "@calcom/prisma/selects/payment";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
|
||||
@@ -23,68 +24,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
success: true,
|
||||
uid: true,
|
||||
refunded: true,
|
||||
bookingId: true,
|
||||
appId: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
paymentOption: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
eventTypeId: true,
|
||||
location: true,
|
||||
status: true,
|
||||
rejectionReason: true,
|
||||
cancellationReason: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
requiresConfirmation: true,
|
||||
userId: true,
|
||||
metadata: true,
|
||||
users: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
price: true,
|
||||
currency: true,
|
||||
successRedirectUrl: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: paymentDataSelect,
|
||||
});
|
||||
|
||||
if (!rawPayment) return { notFound: true } as const;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import { useAtomsContext } from "../../hooks/useAtomsContext";
|
||||
import http from "../../lib/http";
|
||||
|
||||
export const QUERY_KEY = "use-event-app-integration";
|
||||
export const useAtomsEventTypePaymentInfo = ({
|
||||
uid,
|
||||
onEventTypePaymentInfoSuccess,
|
||||
onEventTypePaymentInfoFailure,
|
||||
}: {
|
||||
uid: string;
|
||||
onEventTypePaymentInfoSuccess?: () => void;
|
||||
onEventTypePaymentInfoFailure?: () => void;
|
||||
}) => {
|
||||
const pathname = `/atoms/payment/${uid}`;
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, uid],
|
||||
queryFn: () => {
|
||||
return http?.get<ApiResponse<Omit<PaymentPageProps, "trpcState">>>(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
onEventTypePaymentInfoSuccess?.();
|
||||
return res.data.data;
|
||||
}
|
||||
onEventTypePaymentInfoFailure?.();
|
||||
throw new Error(res.data.error.message);
|
||||
});
|
||||
},
|
||||
enabled: !!uid && isInit && !!accessToken,
|
||||
});
|
||||
};
|
||||
@@ -175,6 +175,7 @@ function getNavigation({ length, multipleDuration, t, tabs, url, onClick, curren
|
||||
tabsNavigation.push({
|
||||
name: "event_payments_tab_title",
|
||||
onClick: () => onClick("payments"),
|
||||
isActive: currentTab === "payments",
|
||||
href: `${url}?tabName=payments`,
|
||||
icon: "credit-card",
|
||||
info: `event_payments_tab_description`,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { Suspense } from "react";
|
||||
|
||||
import { getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { PayIcon } from "@calcom/features/bookings/components/event-meta/PayIcon";
|
||||
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { useAtomsEventTypePaymentInfo } from "../hooks/useAtomEventTypePaymentInfo";
|
||||
|
||||
const StripePaymentForm = React.lazy(() => import("./StripePaymentForm"));
|
||||
|
||||
export const PaymentForm = ({
|
||||
paymentUid,
|
||||
onPaymentSuccess,
|
||||
onPaymentCancellation,
|
||||
onEventTypePaymentInfoSuccess,
|
||||
onEventTypePaymentInfoFailure,
|
||||
}: {
|
||||
paymentUid: string;
|
||||
onPaymentSuccess?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
onPaymentCancellation?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
onEventTypePaymentInfoSuccess?: () => void;
|
||||
onEventTypePaymentInfoFailure?: () => void;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { data: paymentInfo, isLoading } = useAtomsEventTypePaymentInfo({
|
||||
uid: paymentUid,
|
||||
onEventTypePaymentInfoSuccess,
|
||||
onEventTypePaymentInfoFailure,
|
||||
});
|
||||
|
||||
const eventName = paymentInfo?.booking.title;
|
||||
|
||||
const is24h = isBrowserLocale24h();
|
||||
const timezone =
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London";
|
||||
const date = dayjs.utc(paymentInfo?.booking.startTime).tz(timezone);
|
||||
|
||||
if (isLoading) return <h1 className="p-4 pt-4 text-xl">Loading...</h1>;
|
||||
|
||||
if (!paymentInfo) return <h1 className="p-4 text-xl">No payment found with UID - {paymentUid}</h1>;
|
||||
|
||||
const paymentAppData = getPaymentAppData(paymentInfo?.eventType);
|
||||
|
||||
return (
|
||||
<AtomsWrapper>
|
||||
<div>
|
||||
<Suspense fallback={<></>}>
|
||||
<main className="mx-auto">
|
||||
<div>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<div className="inset-0 my-4 transition-opacity sm:my-0" aria-hidden="true">
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"main bg-default border-subtle inline-block transform overflow-hidden rounded-lg border px-8 pb-4 pt-5 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-lg sm:py-6 sm:align-middle"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-headline">
|
||||
<div>
|
||||
<div className="bg-success mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<PayIcon currency={paymentAppData.currency} className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-emphasis text-2xl font-semibold leading-6" id="modal-headline">
|
||||
{paymentAppData.paymentOption === "HOLD"
|
||||
? t("complete_your_booking")
|
||||
: t("payment")}
|
||||
</h3>
|
||||
<div className="text-default mt-4 grid grid-cols-3 border-b border-t py-4 text-left dark:border-gray-900 dark:text-gray-300">
|
||||
<div className="font-medium">{t("what")}</div>
|
||||
<div className="col-span-2 mb-6">{eventName}</div>
|
||||
<div className="font-medium">{t("when")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
{date.format("dddd, DD MMMM YYYY")}
|
||||
<br />
|
||||
{date.format(is24h ? "H:mm" : "h:mma")} - {paymentInfo.eventType.length} mins{" "}
|
||||
<span className="text-subtle">({timezone})</span>
|
||||
</div>
|
||||
{paymentInfo.booking.location && (
|
||||
<>
|
||||
<div className="font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mb-6">
|
||||
{getSuccessPageLocationMessage(paymentInfo.booking.location, t)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="font-medium">
|
||||
{paymentInfo.payment.paymentOption === "HOLD" ? t("no_show_fee") : t("price")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 font-semibold">
|
||||
<Price
|
||||
currency={paymentAppData.currency}
|
||||
price={paymentAppData.price}
|
||||
displayAlternateSymbol={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{paymentInfo.payment.success && !paymentInfo.payment.refunded && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">{t("paid")}</div>
|
||||
)}
|
||||
{paymentInfo.payment.appId === "stripe" && !paymentInfo.payment.success && (
|
||||
<StripePaymentForm
|
||||
clientSecret={paymentInfo.clientSecret}
|
||||
payment={paymentInfo.payment}
|
||||
eventType={paymentInfo.eventType}
|
||||
user={paymentInfo.user}
|
||||
location={paymentInfo.booking.location}
|
||||
booking={paymentInfo.booking}
|
||||
uid={paymentUid}
|
||||
onPaymentSuccess={onPaymentSuccess}
|
||||
onPaymentCancellation={onPaymentCancellation}
|
||||
/>
|
||||
)}
|
||||
{paymentInfo.payment.refunded && (
|
||||
<div className="text-default mt-4 text-center dark:text-gray-300">
|
||||
{t("refunded")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!paymentInfo.profile.hideBranding && (
|
||||
<div className="text-muted dark:text-inverted mt-4 border-t pt-4 text-center text-xs dark:border-gray-900">
|
||||
<a href={`${WEBSITE_URL}/signup`}>
|
||||
{t("create_booking_link_with_calcom", { appName: APP_NAME })}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Suspense>
|
||||
</div>
|
||||
</AtomsWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Elements, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SyntheticEvent } from "react";
|
||||
|
||||
import getStripe from "@calcom/app-store/stripepayment/lib/client";
|
||||
import type { Props, States } from "@calcom/features/ee/payments/components/Payment";
|
||||
import { PaymentFormComponent } from "@calcom/features/ee/payments/components/Payment";
|
||||
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const StripePaymentComponent = (
|
||||
props: Props & {
|
||||
onPaymentSuccess?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
onPaymentCancellation?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
}
|
||||
) => {
|
||||
const { t } = useLocale();
|
||||
const elements = useElements();
|
||||
const paymentOption = props.payment.paymentOption;
|
||||
const attendeeEmail = props.booking.attendees[0].email;
|
||||
const stripe = useStripe();
|
||||
|
||||
const [state, setState] = useState<States>({ status: "idle" });
|
||||
|
||||
return (
|
||||
<PaymentFormComponent
|
||||
{...props}
|
||||
elements={elements}
|
||||
paymentOption={paymentOption}
|
||||
state={state}
|
||||
onSubmit={async (ev: SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "processing" });
|
||||
|
||||
let payload;
|
||||
const params: {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
location?: string;
|
||||
payment_intent?: string;
|
||||
payment_intent_client_secret?: string;
|
||||
redirect_status?: string;
|
||||
} = {
|
||||
uid: props.booking.uid,
|
||||
email: attendeeEmail,
|
||||
};
|
||||
if (paymentOption === "HOLD" && "setupIntent" in props.payment.data) {
|
||||
payload = await stripe.confirmSetup({
|
||||
elements,
|
||||
redirect: "if_required",
|
||||
});
|
||||
if (payload.setupIntent) {
|
||||
params.payment_intent = payload.setupIntent.id;
|
||||
params.payment_intent_client_secret = payload.setupIntent.client_secret || undefined;
|
||||
params.redirect_status = payload.setupIntent.status;
|
||||
}
|
||||
} else if (paymentOption === "ON_BOOKING") {
|
||||
payload = await stripe.confirmPayment({
|
||||
elements,
|
||||
redirect: "if_required",
|
||||
});
|
||||
if (payload.paymentIntent) {
|
||||
params.payment_intent = payload.paymentIntent.id;
|
||||
params.payment_intent_client_secret = payload.paymentIntent.client_secret || undefined;
|
||||
params.redirect_status = payload.paymentIntent.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.error) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: new Error(`Payment failed: ${payload.error.message}`),
|
||||
});
|
||||
} else {
|
||||
setState({ status: "idle" });
|
||||
props.onPaymentSuccess?.(props as unknown as Omit<PaymentPageProps, "trpcState">);
|
||||
if (props.location) {
|
||||
if (props.location.includes("integration")) {
|
||||
params.location = t("web_conferencing_details_to_follow");
|
||||
} else {
|
||||
params.location = props.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
props.onPaymentCancellation?.(props as unknown as Omit<PaymentPageProps, "trpcState">);
|
||||
}}
|
||||
onPaymentElementChange={() => {
|
||||
setState({ status: "idle" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StripePaymentForm = (
|
||||
props: Props & {
|
||||
uid: string;
|
||||
onPaymentSuccess?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
onPaymentCancellation?: (input: Omit<PaymentPageProps, "trpcState">) => void;
|
||||
}
|
||||
) => {
|
||||
const stripePromise = getStripe(props.payment.data.stripe_publishable_key as any);
|
||||
const [theme, setTheme] = useState<"stripe" | "night">("stripe");
|
||||
|
||||
useEffect(() => {
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
setTheme("night");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: props.clientSecret,
|
||||
appearance: {
|
||||
theme,
|
||||
},
|
||||
}}>
|
||||
<StripePaymentComponent {...props} />
|
||||
</Elements>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripePaymentForm;
|
||||
@@ -27,3 +27,4 @@ export type { UpdateScheduleInput_2024_06_11 as UpdateScheduleBody } from "@calc
|
||||
export { EventTypePlatformWrapper as EventTypeSettings } from "./event-types/wrappers/EventTypePlatformWrapper";
|
||||
export { StripeConnect } from "./connect/stripe/StripeConnect";
|
||||
export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper";
|
||||
export { PaymentForm } from "./event-types/payments/PaymentForm";
|
||||
|
||||
@@ -93,7 +93,11 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
eventSlug={eventTypeSlug}
|
||||
onCreateBookingSuccess={(data) => {
|
||||
setBookingTitle(data.data.title ?? "");
|
||||
router.push(`/${data.data.uid}`);
|
||||
if (data.data.paymentRequired) {
|
||||
router.push(`/payment/${data.data.paymentUid}`);
|
||||
} else {
|
||||
router.push(`/${data.data.uid}`);
|
||||
}
|
||||
}}
|
||||
metadata={{ CustomKey: "CustomValue" }}
|
||||
duration={eventTypeDuration}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Inter } from "next/font/google";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { PaymentForm } from "@calcom/atoms";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function Payment(props: { calUsername: string; calEmail: string }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const uid = pathname.split("/").pop();
|
||||
|
||||
return (
|
||||
<main className={`flex min-h-screen flex-col ${inter.className}`}>
|
||||
<Navbar username={props.calUsername} />
|
||||
<PaymentForm
|
||||
paymentUid={uid ?? ""}
|
||||
onPaymentSuccess={(data) => {
|
||||
router.push("/bookings");
|
||||
}}
|
||||
onPaymentCancellation={(data) => {
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booki
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
|
||||
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall";
|
||||
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
|
||||
@@ -19,6 +20,7 @@ import { symmetricEncrypt, symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import { paymentDataSelect } from "@calcom/prisma/selects/payment";
|
||||
import type { TeamQuery } from "@calcom/trpc/server/routers/loggedInViewer/integrations.handler";
|
||||
import { updateHandler as updateScheduleHandler } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.handler";
|
||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
@@ -175,3 +177,6 @@ export type { CredentialPayload };
|
||||
export { getAppFromSlug };
|
||||
export { credentialForCalendarServiceSelect };
|
||||
export { MembershipRole };
|
||||
|
||||
export { paymentDataSelect };
|
||||
export { getClientSecretFromPayment };
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const paymentDataSelect = Prisma.validator<Prisma.PaymentSelect>()({
|
||||
data: true,
|
||||
success: true,
|
||||
uid: true,
|
||||
refunded: true,
|
||||
bookingId: true,
|
||||
appId: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
paymentOption: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
eventTypeId: true,
|
||||
location: true,
|
||||
status: true,
|
||||
rejectionReason: true,
|
||||
cancellationReason: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
requiresConfirmation: true,
|
||||
userId: true,
|
||||
metadata: true,
|
||||
users: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
price: true,
|
||||
currency: true,
|
||||
successRedirectUrl: true,
|
||||
forwardParamsSuccessRedirect: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user