Signup Flow improvements (#4012)

* Get login working

* Update website

* Fixes

* Save

* svae

* Save

* Change translation key

* Various fixes after testing

* Update website

* Add TS Tests

* Upate website

* Fix tests

* Fix linting and other issues

* Fix linting and other issues

* Fix bugs found during recording of demos

* Revert default coookie change

* Self review fixe

* Link fixes

* Removed inline styles, cleanup

* Various fixes

* Added new envs to e2e

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Hariom Balhara 2022-09-08 06:08:37 +05:30 committed by GitHub
parent d36d925788
commit 4856ed9977
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 547 additions and 245 deletions

View File

@ -94,6 +94,8 @@ NEXT_PUBLIC_IS_E2E=
# Used for internal billing system # Used for internal billing system
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE= NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE= NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE= NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PLAN_PRODUCT_ID= STRIPE_PRO_PLAN_PRODUCT_ID=

View File

@ -25,6 +25,8 @@ jobs:
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}

View File

@ -26,6 +26,8 @@ jobs:
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }} NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}

View File

@ -41,7 +41,12 @@ const UserSettings = (props: IUserSettingsProps) => {
const mutation = trpc.useMutation("viewer.updateProfile", { const mutation = trpc.useMutation("viewer.updateProfile", {
onSuccess: onSuccess, onSuccess: onSuccess,
}); });
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
const paymentRequired = stripeCustomer?.isPremium ? !stripeCustomer?.paidForPremium : false;
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (paymentRequired) {
return;
}
mutation.mutate({ mutation.mutate({
name: data.name, name: data.name,
timeZone: selectedTimeZone, timeZone: selectedTimeZone,
@ -56,6 +61,7 @@ const UserSettings = (props: IUserSettingsProps) => {
<div className="space-y-6"> <div className="space-y-6">
{/* Username textfield */} {/* Username textfield */}
<UsernameAvailability <UsernameAvailability
readonly={true}
currentUsername={currentUsername} currentUsername={currentUsername}
setCurrentUsername={setCurrentUsername} setCurrentUsername={setCurrentUsername}
inputUsernameValue={inputUsernameValue} inputUsernameValue={inputUsernameValue}

View File

@ -1,17 +1,18 @@
import classNames from "classnames"; import classNames from "classnames";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { useRouter } from "next/router";
import { MutableRefObject, useCallback, useEffect, useState } from "react"; import { MutableRefObject, useCallback, useEffect, useState } from "react";
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { fetchUsername } from "@calcom/lib/fetchUsername"; import { fetchUsername } from "@calcom/lib/fetchUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { User } from "@calcom/prisma/client"; import { User } from "@calcom/prisma/client";
import { TRPCClientErrorLike } from "@calcom/trpc/client"; import { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react"; import { inferQueryOutput, trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app"; import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog"; import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon"; import { Icon, StarIconSolid } from "@calcom/ui/Icon";
import { Input, Label } from "@calcom/ui/form/fields"; import { Input, Label } from "@calcom/ui/form/fields";
export enum UsernameChangeStatusEnum { export enum UsernameChangeStatusEnum {
@ -46,8 +47,39 @@ interface ICustomUsernameProps {
| "timeFormat" | "timeFormat"
| "allowDynamicBooking" | "allowDynamicBooking"
>; >;
readonly?: boolean;
} }
const obtainNewUsernameChangeCondition = ({
userIsPremium,
isNewUsernamePremium,
stripeCustomer,
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
stripeCustomer: inferQueryOutput<"viewer.stripeCustomer"> | undefined;
}) => {
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
return UsernameChangeStatusEnum.UPGRADE;
}
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
return UsernameChangeStatusEnum.DOWNGRADE;
}
return UsernameChangeStatusEnum.NORMAL;
};
const useIsUsernamePremium = (username: string) => {
const [isCurrentUsernamePremium, setIsCurrentUsernamePremium] = useState(false);
useEffect(() => {
(async () => {
if (!username) return;
const { data } = await fetchUsername(username);
setIsCurrentUsernamePremium(data.premium);
})();
}, [username]);
return isCurrentUsernamePremium;
};
const PremiumTextfield = (props: ICustomUsernameProps) => { const PremiumTextfield = (props: ICustomUsernameProps) => {
const { t } = useLocale(); const { t } = useLocale();
const { const {
@ -58,73 +90,38 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
usernameRef, usernameRef,
onSuccessMutation, onSuccessMutation,
onErrorMutation, onErrorMutation,
user, readonly: disabled,
} = props; } = props;
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false); const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
const [markAsError, setMarkAsError] = useState(false); const [markAsError, setMarkAsError] = useState(false);
const router = useRouter();
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false); const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const [usernameChangeCondition, setUsernameChangeCondition] = useState<UsernameChangeStatusEnum | null>( const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
null const isCurrentUsernamePremium = useIsUsernamePremium(currentUsername || "");
); const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
const userIsPremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [premiumUsername, setPremiumUsername] = useState(false);
const debouncedApiCall = useCallback( const debouncedApiCall = useCallback(
debounce(async (username) => { debounce(async (username) => {
const { data } = await fetchUsername(username); const { data } = await fetchUsername(username);
setMarkAsError(!data.available); setMarkAsError(!data.available && username !== currentUsername);
setPremiumUsername(data.premium); setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available); setUsernameIsAvailable(data.available);
}, 150), }, 150),
[] []
); );
useEffect(() => { useEffect(() => {
if (currentUsername !== inputUsernameValue) { // Use the current username or if it's not set, use the one available from stripe
debouncedApiCall(inputUsernameValue); setInputUsernameValue(currentUsername || stripeCustomer?.username || "");
} else if (inputUsernameValue === "") { }, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
setMarkAsError(false);
setPremiumUsername(false);
setUsernameIsAvailable(false);
} else {
setPremiumUsername(userIsPremium);
setUsernameIsAvailable(false);
}
}, [inputUsernameValue]);
useEffect(() => { useEffect(() => {
if (usernameIsAvailable || premiumUsername) { if (!inputUsernameValue) return;
const condition = obtainNewUsernameChangeCondition({ debouncedApiCall(inputUsernameValue);
userIsPremium, }, [debouncedApiCall, inputUsernameValue]);
isNewUsernamePremium: premiumUsername,
});
setUsernameChangeCondition(condition);
}
}, [usernameIsAvailable, premiumUsername]);
const obtainNewUsernameChangeCondition = ({
userIsPremium,
isNewUsernamePremium,
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
}) => {
let resultCondition: UsernameChangeStatusEnum;
if (!userIsPremium && isNewUsernamePremium) {
resultCondition = UsernameChangeStatusEnum.UPGRADE;
} else if (userIsPremium && !isNewUsernamePremium) {
resultCondition = UsernameChangeStatusEnum.DOWNGRADE;
} else {
resultCondition = UsernameChangeStatusEnum.NORMAL;
}
return resultCondition;
};
const utils = trpc.useContext(); const utils = trpc.useContext();
const updateUsername = trpc.useMutation("viewer.updateProfile", { const updateUsername = trpc.useMutation("viewer.updateProfile", {
onSuccess: async () => { onSuccess: async () => {
onSuccessMutation && (await onSuccessMutation()); onSuccessMutation && (await onSuccessMutation());
@ -139,34 +136,62 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
}, },
}); });
const ActionButtons = (props: { index: string }) => { // when current username isn't set - Go to stripe to check what username he wanted to buy and was it a premium and was it paid for
const { index } = props; const paymentRequired = !currentUsername && stripeCustomer?.isPremium && !stripeCustomer?.paidForPremium;
return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? (
<div className="flex flex-row"> const usernameChangeCondition = obtainNewUsernameChangeCondition({
<Button userIsPremium: isCurrentUsernamePremium,
type="button" isNewUsernamePremium: isInputUsernamePremium,
color="primary" stripeCustomer,
className="mx-2" });
onClick={() => setOpenDialogSaveUsername(true)}
data-testid={`update-username-btn-${index}`}> const usernameFromStripe = stripeCustomer?.username;
{t("update")}
</Button> const paymentLink = `/api/integrations/stripepayment/subscription?intentUsername=${
<Button inputUsernameValue || usernameFromStripe
type="button" }&action=${usernameChangeCondition}&callbackUrl=${router.asPath}`;
color="secondary"
className="mx-2" const ActionButtons = () => {
onClick={() => { if (paymentRequired) {
if (currentUsername) { return (
setInputUsernameValue(currentUsername); <div className="flex flex-row">
usernameRef.current.value = currentUsername; <Button
} type="button"
}}> color="primary"
{t("cancel")} className="mx-2"
</Button> href={paymentLink}
</div> data-testid="reserve-username-btn">
) : ( {t("Reserve")}
<></> </Button>
); </div>
);
}
if ((usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue) {
return (
<div className="flex flex-row">
<Button
type="button"
color="primary"
className="mx-2"
onClick={() => setOpenDialogSaveUsername(true)}
data-testid="update-username-btn">
{t("update")}
</Button>
<Button
type="button"
color="secondary"
onClick={() => {
if (currentUsername) {
setInputUsernameValue(currentUsername);
usernameRef.current.value = currentUsername;
}
}}>
{t("cancel")}
</Button>
</div>
);
}
return <></>;
}; };
const saveUsername = () => { const saveUsername = () => {
@ -179,79 +204,89 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
return ( return (
<div> <div>
<div style={{ display: "flex", justifyItems: "center" }}> <div className="flex justify-items-center">
<Label htmlFor="username">{t("username")}</Label> <Label htmlFor="username">{t("username")}</Label>
</div> </div>
<div className="mt-2 flex rounded-md"> <div className="mt-2 flex rounded-md">
<span <span
className={classNames( className={classNames(
"inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500" isInputUsernamePremium ? "border-2 border-orange-400 " : "",
"hidden items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
)}> )}>
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/ {process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
</span> </span>
<div style={{ position: "relative", width: "100%" }}>
<div className="relative w-full">
<Input <Input
ref={usernameRef} ref={usernameRef}
name="username" name="username"
autoComplete="none" autoComplete="none"
autoCapitalize="none" autoCapitalize="none"
autoCorrect="none" autoCorrect="none"
disabled={disabled}
className={classNames( className={classNames(
"mt-0 rounded-md rounded-l-none", "mt-0 rounded-md rounded-l-none border-l-2 focus:!ring-0",
isInputUsernamePremium
? "border-2 border-orange-400 focus:border-2 focus:border-orange-400"
: "border-2 focus:border-2",
markAsError markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0" ? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none"
: "" : "border-l-gray-300 "
)} )}
defaultValue={currentUsername} value={inputUsernameValue}
onChange={(event) => { onChange={(event) => {
event.preventDefault(); event.preventDefault();
setInputUsernameValue(event.target.value); setInputUsernameValue(event.target.value);
}} }}
data-testid="username-input" data-testid="username-input"
/> />
{currentUsername !== inputUsernameValue && ( <div className="absolute top-0 right-2 flex flex-row">
<div <span
className="top-0" className={classNames(
style={{ "mx-2 py-1",
position: "absolute", isInputUsernamePremium ? "text-orange-400" : "",
right: 2, usernameIsAvailable ? "" : ""
display: "flex", )}>
flexDirection: "row", {isInputUsernamePremium ? <StarIconSolid className="mt-[4px] w-6" /> : <></>}
}}> {!isInputUsernamePremium && usernameIsAvailable ? (
<span <Icon.FiCheck className="mt-[7px] w-6" />
className={classNames( ) : (
"mx-2 py-1", <></>
premiumUsername ? "text-orange-500" : "", )}
usernameIsAvailable ? "" : "" </span>
)}> </div>
{premiumUsername ? <Icon.FiStar className="mt-[4px] w-6" /> : <></>}
{!premiumUsername && usernameIsAvailable ? <Icon.FiCheck className="mt-[4px] w-6" /> : <></>}
</span>
</div>
)}
</div>
<div className="xs:hidden">
<ActionButtons index="desktop" />
</div> </div>
{(usernameIsAvailable || isInputUsernamePremium) && currentUsername !== inputUsernameValue && (
<div className="flex justify-end">
<ActionButtons />
</div>
)}
</div> </div>
{paymentRequired ? (
recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid" ? (
<span className="text-sm text-red-500">
Your payment could not be completed. Your username is still not reserved
</span>
) : (
<span className="text-xs text-orange-400">
You need to reserve your premium username for {getPremiumPlanPriceValue()}
</span>
)
) : null}
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>} {markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
{usernameIsAvailable && ( {usernameIsAvailable && (
<p className={classNames("mt-1 text-xs text-gray-900")}> <p className={classNames("mt-1 text-xs text-gray-900")}>
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && ( {usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
<>{t("standard_to_premium_username_description")}</> <>{t("premium_to_standard_username_description")}</>
)} )}
</p> </p>
)} )}
{(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && (
<div className="mt-2 flex justify-end sm:hidden">
<ActionButtons index="mobile" />
</div>
)}
<Dialog open={openDialogSaveUsername}> <Dialog open={openDialogSaveUsername}>
<DialogContent> <DialogContent>
<div style={{ display: "flex", flexDirection: "row" }}> <div className="flex flex-row">
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]"> <div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<Icon.FiEdit2 className="m-auto h-6 w-6" /> <Icon.FiEdit2 className="m-auto h-6 w-6" />
</div> </div>
@ -291,7 +326,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
type="button" type="button"
loading={updateUsername.isLoading} loading={updateUsername.isLoading}
data-testid="go-to-billing" data-testid="go-to-billing"
href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}> href={paymentLink}>
<> <>
{t("go_to_stripe_billing")} <Icon.FiExternalLink className="ml-1 h-4 w-4" /> {t("go_to_stripe_billing")} <Icon.FiExternalLink className="ml-1 h-4 w-4" />
</> </>

View File

@ -1,19 +1,30 @@
import { GetStaticPropsContext } from "next"; import { GetStaticPropsContext } from "next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button"; import Button from "@calcom/ui/Button";
import { Icon } from "@calcom/ui/Icon"; import { Icon } from "@calcom/ui/Icon";
import { SkeletonText } from "@calcom/ui/v2";
import AuthContainer from "@components/ui/AuthContainer"; import AuthContainer from "@components/ui/AuthContainer";
import { ssgInit } from "@server/lib/ssg"; import { ssgInit } from "@server/lib/ssg";
const querySchema = z.object({
error: z.string().optional(),
});
export default function Error() { export default function Error() {
const { t } = useLocale(); const { t } = useLocale();
const router = useRouter(); const router = useRouter();
const { error } = router.query; const { error } = querySchema.parse(router.query);
const isTokenVerificationError = error?.toLowerCase() === "verification";
let errorMsg = <SkeletonText />;
if (router.isReady) {
errorMsg = isTokenVerificationError ? t("token_invalid_expired") : t("error_during_login");
}
return ( return (
<AuthContainer title="" description=""> <AuthContainer title="" description="">
@ -26,7 +37,7 @@ export default function Error() {
{error} {error}
</h3> </h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500">{t("error_during_login")}</p> <p className="text-sm text-gray-500">{errorMsg}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,12 +1,17 @@
import { CheckIcon, ExclamationIcon, MailOpenIcon } from "@heroicons/react/outline"; import { CheckIcon, MailOpenIcon, ExclamationIcon } from "@heroicons/react/outline";
import { getSession, signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useRef, useState } from "react"; import * as React from "react";
import { useEffect, useState, useRef } from "react";
import z from "zod";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import showToast from "@calcom/lib/notification"; import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button"; import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/v2/";
import Loader from "@components/Loader";
async function sendVerificationLogin(email: string, username: string) { async function sendVerificationLogin(email: string, username: string) {
await signIn("email", { await signIn("email", {
@ -23,25 +28,43 @@ async function sendVerificationLogin(email: string, username: string) {
}); });
} }
function useSendFirstVerificationLogin() { function useSendFirstVerificationLogin({
const router = useRouter(); email,
const { email, username } = router.query; username,
}: {
email: string | undefined;
username: string | undefined;
}) {
const sent = useRef(false); const sent = useRef(false);
useEffect(() => { useEffect(() => {
if (router.isReady && !sent.current) { if (!email || !username || sent.current) {
(async () => { return;
await sendVerificationLogin(`${email}`, `${username}`);
sent.current = true;
})();
} }
}, [email, router.isReady, username]); (async () => {
await sendVerificationLogin(email, username);
sent.current = true;
})();
}, [email, username]);
} }
const querySchema = z.object({
stripeCustomerId: z.string().optional(),
sessionId: z.string().optional(),
t: z.string().optional(),
});
export default function Verify() { export default function Verify() {
const router = useRouter(); const router = useRouter();
const { email, username, t, session_id, cancel } = router.query; const { t, sessionId, stripeCustomerId } = querySchema.parse(router.query);
const [secondsLeft, setSecondsLeft] = useState(30); const [secondsLeft, setSecondsLeft] = useState(30);
const { data } = trpc.useQuery([
"viewer.public.stripeCheckoutSession",
{
stripeCustomerId,
checkoutSessionId: sessionId,
},
]);
useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username });
// @note: check for t=timestamp and apply disabled state and secondsLeft accordingly // @note: check for t=timestamp and apply disabled state and secondsLeft accordingly
// to avoid refresh to skip waiting 30 seconds to re-send email // to avoid refresh to skip waiting 30 seconds to re-send email
useEffect(() => { useEffect(() => {
@ -68,35 +91,28 @@ export default function Verify() {
} }
}, [secondsLeft]); }, [secondsLeft]);
// @note: check for session, redirect to webapp if session found if (!router.isReady || !data) {
useEffect(() => { // Loading state
let intervalId: NodeJS.Timer, redirecting: boolean; return <Loader />;
// eslint-disable-next-line prefer-const }
intervalId = setInterval(async () => { const { valid, hasPaymentFailed, customer } = data;
const session = await getSession(); if (!valid) {
if (session && !redirecting) { throw new Error("Invalid session or customer id");
// User connected using the magic link -> redirect him/her }
redirecting = true;
// @note: redirect to webapp /getting-started, user will end up with two tabs open with the onboarding 'getting-started' wizard.
router.push(WEBAPP_URL + "/getting-started");
}
}, 1000);
return () => {
intervalId && clearInterval(intervalId);
};
}, [router]);
useSendFirstVerificationLogin(); if (!stripeCustomerId && !sessionId) {
return <div>Invalid Link</div>;
}
return ( return (
<div className=" bg-black bg-opacity-90 text-white backdrop-blur-md backdrop-grayscale backdrop-filter"> <div className="bg-black bg-opacity-90 text-white backdrop-blur-md backdrop-grayscale backdrop-filter">
<Head> <Head>
<title> <title>
{/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth {/* @note: Ternary can look ugly ant his might be extracted later but I think at 3 it's not yet worth
it or too hard to read. */} it or too hard to read. */}
{cancel {hasPaymentFailed
? "Your payment failed" ? "Your payment failed"
: session_id : sessionId
? "Payment successful!" ? "Payment successful!"
: "Verify your email" + " | Cal.com"} : "Verify your email" + " | Cal.com"}
</title> </title>
@ -104,34 +120,41 @@ export default function Verify() {
<div className="flex min-h-screen flex-col items-center justify-center px-6"> <div className="flex min-h-screen flex-col items-center justify-center px-6">
<div className="m-10 flex max-w-2xl flex-col items-start border border-white p-12 text-left"> <div className="m-10 flex max-w-2xl flex-col items-start border border-white p-12 text-left">
<div className="rounded-full border border-white p-3"> <div className="rounded-full border border-white p-3">
{cancel ? ( {hasPaymentFailed ? (
<ExclamationIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" /> <ExclamationIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
) : session_id ? ( ) : sessionId ? (
<CheckIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" /> <CheckIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
) : ( ) : (
<MailOpenIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" /> <MailOpenIcon className="h-12 w-12 flex-shrink-0 p-0.5 font-extralight text-white" />
)} )}
</div> </div>
<h3 className="font-cal my-6 text-3xl font-normal"> <h3 className="font-cal my-6 text-3xl font-normal">
{cancel ? "Your payment failed" : session_id ? "Payment successful!" : "Check your Inbox"} {hasPaymentFailed
? "Your payment failed"
: sessionId
? "Payment successful!"
: "Check your Inbox"}
</h3> </h3>
{cancel && ( {hasPaymentFailed && (
<p className="my-6">Your account has been created, but your premium has not been reserved.</p> <p className="my-6">Your account has been created, but your premium has not been reserved.</p>
)} )}
<p> <p>
We have sent an email to <b>{email} </b>with a link to activate your account.{" "} We have sent an email to <b>{customer?.email} </b>with a link to activate your account.{" "}
{cancel && {hasPaymentFailed &&
"Once you activate your account you will be able to try purchase your premium username again or select a different one."} "Once you activate your account you will be able to try purchase your premium username again or select a different one."}
</p> </p>
<p className="mt-6 text-gray-400"> <p className="mt-6 text-gray-400">
Don&apos;t see an email? Click the button below to send another email. Don&apos;t see an email? Click the button below to send another email.
</p> </p>
<div className="mt-6 space-x-5 text-center"> <div className="mt-6 flex space-x-5 text-center">
<Button <Button
color="secondary" color="secondary"
disabled={secondsLeft > 0} disabled={secondsLeft > 0}
onClick={async (e) => { onClick={async (e) => {
if (!customer) {
return;
}
e.preventDefault(); e.preventDefault();
setSecondsLeft(30); setSecondsLeft(30);
// Update query params with t:timestamp, shallow: true doesn't re-render the page // Update query params with t:timestamp, shallow: true doesn't re-render the page
@ -139,14 +162,13 @@ export default function Verify() {
router.asPath, router.asPath,
{ {
query: { query: {
email: router.query.email, ...router.query,
username: router.query.username,
t: Date.now(), t: Date.now(),
}, },
}, },
{ shallow: true } { shallow: true }
); );
return await sendVerificationLogin(`${email}`, `${username}`); return await sendVerificationLogin(customer.email, customer.username);
}}> }}>
{secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Send another mail"} {secondsLeft > 0 ? `Resend in ${secondsLeft} seconds` : "Send another mail"}
</Button> </Button>

View File

@ -245,7 +245,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
hasGiphyIntegration, hasGiphyIntegration,
hasRainbowIntegration, hasRainbowIntegration,
} = props; } = props;
const router = useRouter(); const router = useRouter();
const updateMutation = trpc.useMutation("viewer.eventTypes.update", { const updateMutation = trpc.useMutation("viewer.eventTypes.update", {

View File

@ -10,7 +10,6 @@ test.describe("Change Password Test", () => {
await pro.login(); await pro.login();
// Go to http://localhost:3000/settings/security // Go to http://localhost:3000/settings/security
await page.goto("/settings/security"); await page.goto("/settings/security");
if (!pro.username) throw Error("Test user doesn't have a username"); if (!pro.username) throw Error("Test user doesn't have a username");
// Fill form // Fill form

View File

@ -37,7 +37,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill("demousernamex"); await usernameInput.fill("demousernamex");
// Click on save button // Click on save button
await page.click("[data-testid=update-username-btn-desktop]"); await page.click("[data-testid=update-username-btn]");
await Promise.all([ await Promise.all([
page.waitForResponse("**/viewer.updateProfile*"), page.waitForResponse("**/viewer.updateProfile*"),
@ -84,7 +84,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill(`xx${testInfo.workerIndex}`); await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button // Click on save button
await page.click("[data-testid=update-username-btn-desktop]"); await page.click("[data-testid=update-username-btn]");
// Validate modal text fields // Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText(); const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
@ -130,7 +130,7 @@ test.describe("Change username on settings", () => {
await usernameInput.fill(`xx${testInfo.workerIndex}`); await usernameInput.fill(`xx${testInfo.workerIndex}`);
// Click on save button // Click on save button
await page.click("[data-testid=update-username-btn-desktop]"); await page.click("[data-testid=update-username-btn]");
// Validate modal text fields // Validate modal text fields
const currentUsernameText = page.locator("[data-testid=current-username]").innerText(); const currentUsernameText = page.locator("[data-testid=current-username]").innerText();

View File

@ -955,7 +955,7 @@
"new_workflow_description": "يُمكّنك سير العمل من أتمتة إرسال التذكير والإشعارات.", "new_workflow_description": "يُمكّنك سير العمل من أتمتة إرسال التذكير والإشعارات.",
"active_on": "إجراء في", "active_on": "إجراء في",
"workflow_updated_successfully": "تم تحديث سير العمل {{workflowName}} بنجاح", "workflow_updated_successfully": "تم تحديث سير العمل {{workflowName}} بنجاح",
"standard_to_premium_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.", "premium_to_standard_username_description": "هذا اسم مستخدم قياسي، سوف ينقلك هذا التحديث إلى صفحة الفوترة لخفض المستوى.",
"current": "الحالي", "current": "الحالي",
"premium": "مميز", "premium": "مميز",
"standard": "قياسي", "standard": "قياسي",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Pracovní postupy umožňují automatizovat zasílání upomínek a oznámení.", "new_workflow_description": "Pracovní postupy umožňují automatizovat zasílání upomínek a oznámení.",
"active_on": "Aktivní pro:", "active_on": "Aktivní pro:",
"workflow_updated_successfully": "Pracovní postup {{workflowName}} byl aktualizován", "workflow_updated_successfully": "Pracovní postup {{workflowName}} byl aktualizován",
"standard_to_premium_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.", "premium_to_standard_username_description": "Toto je standardní uživatelské jméno a při aktualizaci přejdete k fakturaci, kde provedete snížení úrovně.",
"current": "Aktuální", "current": "Aktuální",
"premium": "prémiové", "premium": "prémiové",
"standard": "standardní", "standard": "standardní",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Workflows ermöglichen Ihnen das automatisierte Versenden von Erinnerungen und Benachrichtigungen.", "new_workflow_description": "Workflows ermöglichen Ihnen das automatisierte Versenden von Erinnerungen und Benachrichtigungen.",
"active_on": "Aktiv am", "active_on": "Aktiv am",
"workflow_updated_successfully": "{{workflowName}} Workflow erfolgreich aktualisiert", "workflow_updated_successfully": "{{workflowName}} Workflow erfolgreich aktualisiert",
"standard_to_premium_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.", "premium_to_standard_username_description": "Dies ist ein Standard-Benutzername und die Aktualisierung führt Sie zur Rechnungsstellung, um ein Downgrade durchzuführen.",
"current": "Aktuell", "current": "Aktuell",
"premium": "Premium", "premium": "Premium",
"standard": "Standard", "standard": "Standard",

View File

@ -977,7 +977,7 @@
"new_workflow_description": "Workflows enable you to automate sending reminders and notifications.", "new_workflow_description": "Workflows enable you to automate sending reminders and notifications.",
"active_on": "Active on", "active_on": "Active on",
"workflow_updated_successfully": "{{workflowName}} workflow updated successfully", "workflow_updated_successfully": "{{workflowName}} workflow updated successfully",
"standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.", "premium_to_standard_username_description": "This is a standard username and updating will take you to billing to downgrade.",
"current": "Current", "current": "Current",
"premium": "premium", "premium": "premium",
"standard": "standard", "standard": "standard",
@ -1180,5 +1180,6 @@
"edit_form_later_subtitle": "Youll be able to edit this later.", "edit_form_later_subtitle": "Youll be able to edit this later.",
"connect_calendar_later": "I'll connect my calendar later", "connect_calendar_later": "I'll connect my calendar later",
"set_my_availability_later": "I'll set my availability later", "set_my_availability_later": "I'll set my availability later",
"problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support." "problem_saving_user_profile": "There was a problem saving your data. Please try again or reach out to customer support.",
"token_invalid_expired": "Token is either invalid or expired."
} }

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Los flujos de trabajo le permiten automatizar el envío de recordatorios y notificaciones.", "new_workflow_description": "Los flujos de trabajo le permiten automatizar el envío de recordatorios y notificaciones.",
"active_on": "Activo en", "active_on": "Activo en",
"workflow_updated_successfully": "Flujo de trabajo {{workflowName}} actualizado correctamente", "workflow_updated_successfully": "Flujo de trabajo {{workflowName}} actualizado correctamente",
"standard_to_premium_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.", "premium_to_standard_username_description": "Este es un nombre de usuario estándar y la actualización te llevará a la facturación para bajar de categoría.",
"current": "Actual", "current": "Actual",
"premium": "premium", "premium": "premium",
"standard": "estándar", "standard": "estándar",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Les workflows vous permettent d'automatiser l'envoi de rappels et de notifications.", "new_workflow_description": "Les workflows vous permettent d'automatiser l'envoi de rappels et de notifications.",
"active_on": "Actif le", "active_on": "Actif le",
"workflow_updated_successfully": "Workflow {{workflowName}} mis à jour avec succès", "workflow_updated_successfully": "Workflow {{workflowName}} mis à jour avec succès",
"standard_to_premium_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.", "premium_to_standard_username_description": "Ceci est un nom d'utilisateur standard et la mise à jour vous mènera à la facturation à downgrader.",
"current": "Actuel", "current": "Actuel",
"premium": "premium", "premium": "premium",
"standard": "standard", "standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "תהליכי עבודה מאפשרים להפוך את פעולת השליחה של תזכורות ועדכונים לאוטומטית.", "new_workflow_description": "תהליכי עבודה מאפשרים להפוך את פעולת השליחה של תזכורות ועדכונים לאוטומטית.",
"active_on": "פעיל ב", "active_on": "פעיל ב",
"workflow_updated_successfully": "עדכון תהליך העבודה {{workflowName}} בוצע בהצלחה", "workflow_updated_successfully": "עדכון תהליך העבודה {{workflowName}} בוצע בהצלחה",
"standard_to_premium_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.", "premium_to_standard_username_description": "זהו שם משתמש סטנדרטי ועדכון שלו יגרום להעברה שלך אל דף החיוב לצורך שנמוך.",
"current": "נוכחי", "current": "נוכחי",
"premium": "פרימיום", "premium": "פרימיום",
"standard": "רגיל", "standard": "רגיל",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "I flussi di lavoro consentono di automatizzare l'invio di promemoria e notifiche.", "new_workflow_description": "I flussi di lavoro consentono di automatizzare l'invio di promemoria e notifiche.",
"active_on": "Data attivazione", "active_on": "Data attivazione",
"workflow_updated_successfully": "Flusso di lavoro {{workflowName}} aggiornato correttamente", "workflow_updated_successfully": "Flusso di lavoro {{workflowName}} aggiornato correttamente",
"standard_to_premium_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.", "premium_to_standard_username_description": "Questo è un nome utente standard e l'aggiornamento ti porterà alla fatturazione per il downgrade.",
"current": "Corrente", "current": "Corrente",
"premium": "premium", "premium": "premium",
"standard": "standard", "standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "ワークフローにより、リマインダーと通知の送信を自動化できます。", "new_workflow_description": "ワークフローにより、リマインダーと通知の送信を自動化できます。",
"active_on": "有効日時", "active_on": "有効日時",
"workflow_updated_successfully": "{{workflowName}} が正常に更新されました", "workflow_updated_successfully": "{{workflowName}} が正常に更新されました",
"standard_to_premium_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。", "premium_to_standard_username_description": "これはスタンダードユーザー名で、更新するとダウングレードするための請求画面に移動します。",
"current": "現在", "current": "現在",
"premium": "プレミアム", "premium": "プレミアム",
"standard": "スタンダード", "standard": "スタンダード",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "워크플로를 사용하면 미리 알림 및 알림 보내기를 자동화할 수 있습니다.", "new_workflow_description": "워크플로를 사용하면 미리 알림 및 알림 보내기를 자동화할 수 있습니다.",
"active_on": "유효일", "active_on": "유효일",
"workflow_updated_successfully": "{{workflowName}} 워크플로 업데이트 완료", "workflow_updated_successfully": "{{workflowName}} 워크플로 업데이트 완료",
"standard_to_premium_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.", "premium_to_standard_username_description": "이것은 표준 사용자 이름이며 업데이트하면 다운그레이드를 위한 청구로 이동합니다.",
"current": "기존", "current": "기존",
"premium": "프리미엄", "premium": "프리미엄",
"standard": "표준", "standard": "표준",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Met werkstromen kunt u het verzenden van herinneringen en meldingen automatiseren.", "new_workflow_description": "Met werkstromen kunt u het verzenden van herinneringen en meldingen automatiseren.",
"active_on": "Actief op", "active_on": "Actief op",
"workflow_updated_successfully": "Werkstroom {{workflowName}} bijgewerkt", "workflow_updated_successfully": "Werkstroom {{workflowName}} bijgewerkt",
"standard_to_premium_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.", "premium_to_standard_username_description": "Dit is een standaard gebruikersnaam, door bij te werken ga je naar facturatie om te downgraden.",
"current": "Huidig", "current": "Huidig",
"premium": "premium", "premium": "premium",
"standard": "standaard", "standard": "standaard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Przepływy pracy umożliwiają automatyzację wysyłania przypomnień i powiadomień.", "new_workflow_description": "Przepływy pracy umożliwiają automatyzację wysyłania przypomnień i powiadomień.",
"active_on": "Aktywny dnia", "active_on": "Aktywny dnia",
"workflow_updated_successfully": "Pomyślnie zaktualizowano przepływ pracy {{workflowName}}", "workflow_updated_successfully": "Pomyślnie zaktualizowano przepływ pracy {{workflowName}}",
"standard_to_premium_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.", "premium_to_standard_username_description": "To standardowa nazwa użytkownika i jej aktualizacja spowoduje przejście do płatności za zmianę wersji.",
"current": "Bieżąca", "current": "Bieżąca",
"premium": "Premium", "premium": "Premium",
"standard": "Standardowa", "standard": "Standardowa",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Fluxos de trabalho permitem que você automatize o envio de lembretes e notificações.", "new_workflow_description": "Fluxos de trabalho permitem que você automatize o envio de lembretes e notificações.",
"active_on": "Ativar em", "active_on": "Ativar em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} atualizado com sucesso", "workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} atualizado com sucesso",
"standard_to_premium_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.", "premium_to_standard_username_description": "Este é um nome de usuário padrão. Para atualizá-lo, levaremos você para fazer downgrade na cobrança.",
"current": "Atual", "current": "Atual",
"premium": "premium", "premium": "premium",
"standard": "padrão", "standard": "padrão",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Os fluxos de trabalho permitem automatizar o envio de lembretes e notificações.", "new_workflow_description": "Os fluxos de trabalho permitem automatizar o envio de lembretes e notificações.",
"active_on": "Activo em", "active_on": "Activo em",
"workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} actualizado com sucesso", "workflow_updated_successfully": "Fluxo de trabalho {{workflowName}} actualizado com sucesso",
"standard_to_premium_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.", "premium_to_standard_username_description": "Este é um nome de utilizador padrão, e ao atualizar irá para a faturação para fazer o downgrade.",
"current": "Atual", "current": "Atual",
"premium": "premium", "premium": "premium",
"standard": "padrão", "standard": "padrão",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Fluxurile de lucru vă permit să automatizați trimiterea mementourilor și a notificărilor.", "new_workflow_description": "Fluxurile de lucru vă permit să automatizați trimiterea mementourilor și a notificărilor.",
"active_on": "Activ pe", "active_on": "Activ pe",
"workflow_updated_successfully": "Fluxul de lucru {{workflowName}} a fost actualizat cu succes", "workflow_updated_successfully": "Fluxul de lucru {{workflowName}} a fost actualizat cu succes",
"standard_to_premium_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.", "premium_to_standard_username_description": "Acesta este un nume de utilizator standard, iar actualizarea vă va duce la facturare pentru retrogradare.",
"current": "Actual", "current": "Actual",
"premium": "premium", "premium": "premium",
"standard": "standard", "standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Рабочие процессы позволяют автоматизировать отправку напоминаний и уведомлений.", "new_workflow_description": "Рабочие процессы позволяют автоматизировать отправку напоминаний и уведомлений.",
"active_on": "Активен с", "active_on": "Активен с",
"workflow_updated_successfully": "Рабочий процесс {{workflowName}} обновлен", "workflow_updated_successfully": "Рабочий процесс {{workflowName}} обновлен",
"standard_to_premium_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.", "premium_to_standard_username_description": "Это стандартное имя пользователя. В случае изменения вы будете перенаправлены на страницу оплаты для смены тарифа.",
"current": "Текущее", "current": "Текущее",
"premium": "премиум", "premium": "премиум",
"standard": "стандартное", "standard": "стандартное",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Radni tokovi vam omogućavaju da automatizujete slanje podsetnika i notifikacija.", "new_workflow_description": "Radni tokovi vam omogućavaju da automatizujete slanje podsetnika i notifikacija.",
"active_on": "Aktivan uključen", "active_on": "Aktivan uključen",
"workflow_updated_successfully": "Radni tok {{workflowName}} je uspešno ažuriran", "workflow_updated_successfully": "Radni tok {{workflowName}} je uspešno ažuriran",
"standard_to_premium_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.", "premium_to_standard_username_description": "Ovo je standardno korisničko ime i ažuriranje će vas poslati na naplatu da biste prešli na nižu verziju.",
"current": "Trenutno", "current": "Trenutno",
"premium": "premijum", "premium": "premijum",
"standard": "standardno", "standard": "standardno",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Arbetsflöden gör att du kan automatisera att skicka påminnelser och aviseringar.", "new_workflow_description": "Arbetsflöden gör att du kan automatisera att skicka påminnelser och aviseringar.",
"active_on": "Aktiv på", "active_on": "Aktiv på",
"workflow_updated_successfully": "{{workflowName}} uppdaterades framgångsrikt", "workflow_updated_successfully": "{{workflowName}} uppdaterades framgångsrikt",
"standard_to_premium_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.", "premium_to_standard_username_description": "Detta är ett standardanvändarnamn och uppdatering tar dig till fakturering för att nedgradera.",
"current": "Nuvarande", "current": "Nuvarande",
"premium": "premium", "premium": "premium",
"standard": "standard", "standard": "standard",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "İş akışları, hatırlatıcı ve bildirim göndermeyi otomatikleştirmenizi sağlar.", "new_workflow_description": "İş akışları, hatırlatıcı ve bildirim göndermeyi otomatikleştirmenizi sağlar.",
"active_on": "Aktif", "active_on": "Aktif",
"workflow_updated_successfully": "{{workflowName}} iş akışı başarıyla güncellendi", "workflow_updated_successfully": "{{workflowName}} iş akışı başarıyla güncellendi",
"standard_to_premium_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.", "premium_to_standard_username_description": "Bu, standart bir kullanıcı adıdır ve güncelleme işlemi eski sürüme geçmeniz için sizi faturalandırma sayfasına yönlendirecektir.",
"current": "Mevcut", "current": "Mevcut",
"premium": "premium", "premium": "premium",
"standard": "standart", "standard": "standart",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Робочі процеси дають змогу автоматизувати надсилання нагадувань і сповіщень.", "new_workflow_description": "Робочі процеси дають змогу автоматизувати надсилання нагадувань і сповіщень.",
"active_on": "Активується", "active_on": "Активується",
"workflow_updated_successfully": "Робочий процес «{{workflowName}}» оновлено", "workflow_updated_successfully": "Робочий процес «{{workflowName}}» оновлено",
"standard_to_premium_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.", "premium_to_standard_username_description": "Це стандандартне ім’я користувача. Якщо оновити його, ви перейдете до виставлення рахунків для переходу на дешевшу підписку.",
"current": "Поточна", "current": "Поточна",
"premium": "преміум", "premium": "преміум",
"standard": "стандарт", "standard": "стандарт",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "Dòng công việc giúp bạn tự động hóa việc gửi lời nhắc và thông báo.", "new_workflow_description": "Dòng công việc giúp bạn tự động hóa việc gửi lời nhắc và thông báo.",
"active_on": "Hoạt động vào", "active_on": "Hoạt động vào",
"workflow_updated_successfully": "Đã cập nhật thành công tiến độ công việc {{workflowName}}", "workflow_updated_successfully": "Đã cập nhật thành công tiến độ công việc {{workflowName}}",
"standard_to_premium_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.", "premium_to_standard_username_description": "Đây là tên người dùng tiêu chuẩn và việc cập nhật sẽ đưa bạn đến khâu thanh toán để hạ cấp độ.",
"current": "Hiện tại", "current": "Hiện tại",
"premium": "cao cấp", "premium": "cao cấp",
"standard": "tiêu chuẩn", "standard": "tiêu chuẩn",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "工作流程可让您自动发送提醒和通知。", "new_workflow_description": "工作流程可让您自动发送提醒和通知。",
"active_on": "活跃于", "active_on": "活跃于",
"workflow_updated_successfully": "{{workflowName}} 工作流程已成功更新", "workflow_updated_successfully": "{{workflowName}} 工作流程已成功更新",
"standard_to_premium_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。", "premium_to_standard_username_description": "这是一个标准用户名,更新流程会将您引导至账单页面进行降级。",
"current": "当前", "current": "当前",
"premium": "高级", "premium": "高级",
"standard": "标准", "standard": "标准",

View File

@ -955,7 +955,7 @@
"new_workflow_description": "工作流程可讓您自動傳送提醒和通知。", "new_workflow_description": "工作流程可讓您自動傳送提醒和通知。",
"active_on": "啟用類型:", "active_on": "啟用類型:",
"workflow_updated_successfully": "已成功更新 {{workflowName}}", "workflow_updated_successfully": "已成功更新 {{workflowName}}",
"standard_to_premium_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。", "premium_to_standard_username_description": "此為標準使用者名稱,更新系統會帶您前往付費頁面進行降級。",
"current": "目前", "current": "目前",
"premium": "高級", "premium": "高級",
"standard": "標準", "standard": "標準",

@ -1 +1 @@
Subproject commit f3fba833b8c5a0b61fe3b0a8a08acc67b7fdb099 Subproject commit 81ff552ad98ff4d2b5819c73cde30e53269bda37

View File

@ -2,5 +2,7 @@ export { default as add } from "./add";
export { default as callback } from "./callback"; export { default as callback } from "./callback";
export { default as portal } from "./portal"; export { default as portal } from "./portal";
export { default as subscription } from "./subscription"; export { default as subscription } from "./subscription";
export { default as paymentCallback } from "./paymentCallback";
// TODO: Figure out how to handle webhook endpoints from App Store // TODO: Figure out how to handle webhook endpoints from App Store
// export { default as webhook } from "./webhook"; // export { default as webhook } from "./webhook";

View File

@ -0,0 +1,51 @@
import { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { prisma } from "@calcom/prisma";
const querySchema = z.object({
callbackUrl: z.string().transform((url) => {
if (url.search(/^https?:\/\//) === -1) {
url = `${WEBAPP_URL}${url}`;
}
return new URL(url);
}),
checkoutSessionId: z.string(),
});
// It handles premium user payment success/failure. Can be modified to handle other PRO upgrade payment as well.
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query);
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
if (!stripeCustomer) return { message: "Stripe customer not found or deleted" };
if (checkoutSession.payment_status === "paid") {
console.log("Found payment ");
try {
await prisma.user.update({
data: {
username: stripeCustomer.metadata.username,
},
where: {
email: stripeCustomer.metadata.email,
},
});
} catch (error) {
console.error(error);
return {
message:
"We have received your payment. Your premium username could still not be reserved. Please contact support@cal.com and mention your premium username",
};
}
}
callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status);
return res.redirect(callbackUrl.toString()).end();
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@ -2,45 +2,32 @@ import { UserPlan } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe"; import Stripe from "stripe";
import {
getPremiumPlanMode,
getPremiumPlanPrice,
getPremiumPlanProductId,
} from "@calcom/app-store/stripepayment/lib/utils";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername"; import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client"; import { Prisma } from "@calcom/prisma/client";
import { import { PRO_PLAN_PRICE, PRO_PLAN_PRODUCT_ID } from "../lib/constants";
PREMIUM_PLAN_PRICE,
PREMIUM_PLAN_PRODUCT_ID,
PRO_PLAN_PRICE,
PRO_PLAN_PRODUCT_ID,
} from "../lib/constants";
import { getStripeCustomerIdFromUserId } from "../lib/customer"; import { getStripeCustomerIdFromUserId } from "../lib/customer";
import stripe from "../lib/server"; import stripe from "../lib/server";
enum UsernameChangeStatusEnum { export enum UsernameChangeStatusEnum {
NORMAL = "NORMAL", NORMAL = "NORMAL",
UPGRADE = "UPGRADE", UPGRADE = "UPGRADE",
DOWNGRADE = "DOWNGRADE", DOWNGRADE = "DOWNGRADE",
} }
const obtainNewConditionAction = ({
userCurrentPlan,
isNewUsernamePremium,
}: {
userCurrentPlan: UserPlan;
isNewUsernamePremium: boolean;
}) => {
if (userCurrentPlan === UserPlan.PRO) {
if (isNewUsernamePremium) return UsernameChangeStatusEnum.UPGRADE;
return UsernameChangeStatusEnum.DOWNGRADE;
}
return UsernameChangeStatusEnum.NORMAL;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") { if (req.method === "GET") {
const userId = req.session?.user.id; const userId = req.session?.user.id;
let { intentUsername = null } = req.query;
let { intentUsername = null } = req.query;
const { action, callbackUrl } = req.query;
if (!userId || !intentUsername) { if (!userId || !intentUsername) {
res.status(404).end(); res.status(404).end();
return; return;
@ -66,18 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium; const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium;
// Save the intentUsername in the metadata const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`;
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
intentUsername,
},
},
});
const return_url = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/profile`;
const createSessionParams: Stripe.BillingPortal.SessionCreateParams = { const createSessionParams: Stripe.BillingPortal.SessionCreateParams = {
customer: customerId, customer: customerId,
return_url, return_url,
@ -87,11 +63,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!checkPremiumResult.available) { if (!checkPremiumResult.available) {
return res.status(404).json({ message: "Intent username not available" }); return res.status(404).json({ message: "Intent username not available" });
} }
const stripeCustomer = await stripe.customers.retrieve(customerId);
if (!stripeCustomer || stripeCustomer.deleted) {
return res.status(400).json({ message: "Stripe customer not found or deleted" });
}
await stripe.customers.update(customerId, {
metadata: {
...stripeCustomer.metadata,
username: intentUsername,
},
});
if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) { if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) {
const subscriptionPrice = checkPremiumResult.premium ? PREMIUM_PLAN_PRICE : PRO_PLAN_PRICE; const subscriptionPrice = checkPremiumResult.premium ? getPremiumPlanPrice() : PRO_PLAN_PRICE;
const checkoutSession = await stripe.checkout.sessions.create({ const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription", mode: getPremiumPlanMode(),
payment_method_types: ["card"], payment_method_types: ["card"],
customer: customerId, customer: customerId,
line_items: [ line_items: [
@ -104,24 +90,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
cancel_url: return_url, cancel_url: return_url,
allow_promotion_codes: true, allow_promotion_codes: true,
}); });
// Save the intentUsername in the metadata
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
checkoutSessionId: checkoutSession.id,
intentUsername,
},
},
});
if (checkoutSession && checkoutSession.url) { if (checkoutSession && checkoutSession.url) {
return res.redirect(checkoutSession.url).end(); return res.redirect(checkoutSession.url).end();
} }
return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" }); return res.status(404).json({ message: "Couldn't redirect to stripe checkout session" });
} }
const action = obtainNewConditionAction({
userCurrentPlan: userData?.plan ?? UserPlan.FREE,
isNewUsernamePremium: checkPremiumResult.premium,
});
if (action && userData) { if (action && userData) {
let actionText = ""; let actionText = "";
const customProductsSession = []; const customProductsSession = [];
if (action === UsernameChangeStatusEnum.UPGRADE) { if (action === UsernameChangeStatusEnum.UPGRADE) {
actionText = "Upgrade your plan account"; actionText = "Upgrade your plan account";
if (checkPremiumResult.premium) { if (checkPremiumResult.premium) {
customProductsSession.push({ prices: [PREMIUM_PLAN_PRICE], product: PREMIUM_PLAN_PRODUCT_ID }); customProductsSession.push({ prices: [getPremiumPlanPrice()], product: getPremiumPlanProductId() });
} else { } else {
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID }); customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
} }
@ -148,6 +140,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}, },
}, },
}); });
await prisma.user.update({
where: { id: userId },
data: {
metadata: {
...(userData.metadata as Prisma.JsonObject),
intentUsername,
},
},
});
if (configuration) { if (configuration) {
createSessionParams.configuration = configuration.id; createSessionParams.configuration = configuration.id;
} }

View File

@ -1,5 +1,7 @@
export const FREE_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE || ""; export const FREE_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE || "";
export const PREMIUM_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE || ""; export const PREMIUM_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE || "";
export const IS_PREMIUM_NEW_PLAN = process.env.NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN === "1" ? true : false;
export const PREMIUM_NEW_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE || "";
export const PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || ""; export const PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || "";
export const FREE_PLAN_PRODUCT_ID = process.env.STRIPE_FREE_PLAN_PRODUCT_ID || ""; export const FREE_PLAN_PRODUCT_ID = process.env.STRIPE_FREE_PLAN_PRODUCT_ID || "";
export const PRO_PLAN_PRODUCT_ID = process.env.STRIPE_PRO_PLAN_PRODUCT_ID || ""; export const PRO_PLAN_PRODUCT_ID = process.env.STRIPE_PRO_PLAN_PRODUCT_ID || "";

View File

@ -0,0 +1,24 @@
import stripe from "@calcom/app-store/stripepayment/lib/server";
export async function getCustomerAndCheckoutSession(checkoutSessionId: string) {
const checkoutSession = await stripe.checkout.sessions.retrieve(checkoutSessionId);
const customerOrCustomerId = checkoutSession.customer;
let customerId = null;
if (!customerOrCustomerId) {
return { checkoutSession, customer: null };
}
if (typeof customerOrCustomerId === "string") {
customerId = customerOrCustomerId;
} else if (customerOrCustomerId.deleted) {
return { checkoutSession, customer: null };
} else {
customerId = customerOrCustomerId.id;
}
const stripeCustomer = await stripe.customers.retrieve(customerId);
if (stripeCustomer.deleted) {
return { checkoutSession, customer: null };
}
return { stripeCustomer, checkoutSession };
}

View File

@ -33,7 +33,6 @@ export const stripeDataSchema = stripeOAuthTokenSchema.extend({
export type StripeData = z.infer<typeof stripeDataSchema>; export type StripeData = z.infer<typeof stripeDataSchema>;
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!; const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!;
const stripe = new Stripe(stripePrivateKey, { const stripe = new Stripe(stripePrivateKey, {
apiVersion: "2020-08-27", apiVersion: "2020-08-27",
}); });

View File

@ -4,15 +4,25 @@ import {
PREMIUM_PLAN_PRICE, PREMIUM_PLAN_PRICE,
PREMIUM_PLAN_PRODUCT_ID, PREMIUM_PLAN_PRODUCT_ID,
PRO_PLAN_PRICE, PRO_PLAN_PRICE,
PREMIUM_NEW_PLAN_PRICE,
IS_PREMIUM_NEW_PLAN,
PRO_PLAN_PRODUCT_ID, PRO_PLAN_PRODUCT_ID,
} from "./constants"; } from "./constants";
export function getPerSeatProPlanPrice(): string { export function getPremiumPlanMode() {
return PRO_PLAN_PRICE; return IS_PREMIUM_NEW_PLAN ? "payment" : "subscription";
}
export function getPremiumPlanPriceValue() {
return IS_PREMIUM_NEW_PLAN ? "$499" : "$29/mo";
} }
export function getPremiumPlanPrice(): string { export function getPremiumPlanPrice(): string {
return PREMIUM_PLAN_PRICE; return IS_PREMIUM_NEW_PLAN ? PREMIUM_NEW_PLAN_PRICE : PREMIUM_PLAN_PRICE;
}
export function getPerSeatProPlanPrice(): string {
return PRO_PLAN_PRICE;
} }
export function getProPlanPrice(): string { export function getProPlanPrice(): string {

View File

@ -39,7 +39,6 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer
// Get Custom Contact fields ids // Get Custom Contact fields ids
const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service); const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service);
this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds }); this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds });
debugger;
// Get shared fields ids // Get shared fields ids
const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service); const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service);
this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds }); this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds });

View File

@ -128,6 +128,7 @@ export const userMetadata = z
vitalSettings: vitalSettingsUpdateSchema.optional(), vitalSettings: vitalSettingsUpdateSchema.optional(),
isPremium: z.boolean().optional(), isPremium: z.boolean().optional(),
intentUsername: z.string().optional(), intentUsername: z.string().optional(),
checkoutSessionId: z.string().nullable().optional(),
}) })
.nullable(); .nullable();

View File

@ -7,6 +7,7 @@ import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router"; import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router"; import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server"; import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils"; import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler"; import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
@ -37,6 +38,7 @@ import {
updateWebUser as syncServicesUpdateWebUser, updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager"; } from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma"; import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image"; import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@ -79,6 +81,71 @@ const publicViewerRouter = createRouter()
return await samlTenantProduct(prisma, email); return await samlTenantProduct(prisma, email);
}, },
}) })
.query("stripeCheckoutSession", {
input: z.object({
stripeCustomerId: z.string().optional(),
checkoutSessionId: z.string().optional(),
}),
async resolve({ input }) {
const { checkoutSessionId, stripeCustomerId } = input;
// TODO: Move the following data checks to superRefine
if (!checkoutSessionId && !stripeCustomerId) {
throw new Error("Missing checkoutSessionId or stripeCustomerId");
}
if (checkoutSessionId && stripeCustomerId) {
throw new Error("Both checkoutSessionId and stripeCustomerId provided");
}
let customerId: string;
let isPremiumUsername = false;
let hasPaymentFailed = false;
if (checkoutSessionId) {
try {
const session = await stripe.checkout.sessions.retrieve(checkoutSessionId);
if (typeof session.customer !== "string") {
return {
valid: false,
};
}
customerId = session.customer;
isPremiumUsername = true;
hasPaymentFailed = session.payment_status !== "paid";
} catch (e) {
return {
valid: false,
};
}
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
customerId = stripeCustomerId!;
}
try {
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) {
return {
valid: false,
};
}
return {
valid: true,
hasPaymentFailed,
isPremiumUsername,
customer: {
username: customer.metadata.username,
email: customer.metadata.email,
stripeCustomerId: customerId,
},
};
} catch (e) {
return {
valid: false,
};
}
},
})
.merge("slots.", slotsRouter); .merge("slots.", slotsRouter);
// routes only available to authenticated users // routes only available to authenticated users
@ -687,6 +754,43 @@ const loggedInViewerRouter = createProtectedRouter()
return app; return app;
}, },
}) })
.query("stripeCustomer", {
async resolve({ ctx }) {
const {
user: { id: userId },
prisma,
} = ctx;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
metadata: true,
},
});
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" });
}
const metadata = userMetadata.parse(user.metadata);
const checkoutSessionId = metadata?.checkoutSessionId;
//TODO: Rename checkoutSessionId to premiumUsernameCheckoutSessionId
if (!checkoutSessionId) return { isPremium: false };
const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId);
if (!stripeCustomer) {
throw new TRPCError({ code: "NOT_FOUND", message: "Stripe User not found" });
}
return {
isPremium: true,
paidForPremium: checkoutSession.payment_status === "paid",
username: stripeCustomer.metadata.username,
};
},
})
.mutation("updateProfile", { .mutation("updateProfile", {
input: z.object({ input: z.object({
username: z.string().optional(), username: z.string().optional(),
@ -711,12 +815,14 @@ const loggedInViewerRouter = createProtectedRouter()
const data: Prisma.UserUpdateInput = { const data: Prisma.UserUpdateInput = {
...input, ...input,
}; };
let isPremiumUsername = false;
if (input.username) { if (input.username) {
const username = slugify(input.username); const username = slugify(input.username);
// Only validate if we're changing usernames // Only validate if we're changing usernames
if (username !== user.username) { if (username !== user.username) {
data.username = username; data.username = username;
const response = await checkUsername(username); const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) { if (!response.available) {
throw new TRPCError({ code: "BAD_REQUEST", message: response.message }); throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
} }
@ -725,6 +831,30 @@ const loggedInViewerRouter = createProtectedRouter()
if (input.avatar) { if (input.avatar) {
data.avatar = await resizeBase64Image(input.avatar); data.avatar = await resizeBase64Image(input.avatar);
} }
const userToUpdate = await prisma.user.findUnique({
where: {
id: user.id,
},
});
if (!userToUpdate) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const metadata = userMetadata.parse(userToUpdate.metadata);
// Checking the status of payment directly from stripe allows to avoid the situation where the user has got the refund or maybe something else happened asyncly at stripe but our DB thinks it's still paid for
// TODO: Test the case where one time payment is refunded.
const premiumUsernameCheckoutSessionId = metadata?.checkoutSessionId;
if (premiumUsernameCheckoutSessionId) {
const checkoutSession = await stripe.checkout.sessions.retrieve(premiumUsernameCheckoutSessionId);
const canUserHavePremiumUsername = checkoutSession.payment_status == "paid";
if (isPremiumUsername && !canUserHavePremiumUsername) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {

View File

@ -7,7 +7,7 @@ export { CollectionIcon } from "@heroicons/react/outline";
export { ShieldCheckIcon } from "@heroicons/react/outline"; export { ShieldCheckIcon } from "@heroicons/react/outline";
export { BadgeCheckIcon } from "@heroicons/react/outline"; export { BadgeCheckIcon } from "@heroicons/react/outline";
export { ClipboardCopyIcon } from "@heroicons/react/outline"; export { ClipboardCopyIcon } from "@heroicons/react/outline";
export { StarIcon as StarIconSolid } from "@heroicons/react/solid";
// TODO: // TODO:
// right now: Icon.Sun comes from react-feather // right now: Icon.Sun comes from react-feather
// CollectionIcon comes from "@heroicons/react/outline"; // CollectionIcon comes from "@heroicons/react/outline";

View File

@ -33,7 +33,7 @@ const SkeletonText: React.FC<SkeletonBaseProps> = ({ width = "", height = "", cl
className = width ? `${className} w-${width}` : className; className = width ? `${className} w-${width}` : className;
className = height ? `${className} h-${height}` : className; className = height ? `${className} h-${height}` : className;
return ( return (
<div <span
className={classNames( className={classNames(
`dark:white-300 animate-pulse rounded-md bg-gray-300 empty:before:inline-block empty:before:content-[''] w-${width} h-${height}`, `dark:white-300 animate-pulse rounded-md bg-gray-300 empty:before:inline-block empty:before:content-[''] w-${width} h-${height}`,
className className

View File

@ -33,7 +33,9 @@
"$NEXT_PUBLIC_LICENSE_CONSENT", "$NEXT_PUBLIC_LICENSE_CONSENT",
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN",
"$STRIPE_PRO_PLAN_PRODUCT_ID", "$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID", "$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID", "$STRIPE_FREE_PLAN_PRODUCT_ID",
@ -58,7 +60,9 @@
"^build", "^build",
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE",
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE", "$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
"$NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN",
"$STRIPE_PRO_PLAN_PRODUCT_ID", "$STRIPE_PRO_PLAN_PRODUCT_ID",
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID", "$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"$STRIPE_FREE_PLAN_PRODUCT_ID", "$STRIPE_FREE_PLAN_PRODUCT_ID",