feature/settings-username-update (#2306)
* WIP feature/settings-username-update * WIP username change * WIP downgrade stripe * stripe downgrade and prorate preview * new UI for username premium component * Fix server side props * Remove migration, changed field to metadata user * WIP for update subscriptions * WIP intent username table * WIP saving and updating username via hooks * WIP saving working username sub update * WIP, update html to work with tests * Added stripe test for username update go to stripe * WIP username change test * Working test for username change * Fix timeout for flaky test * Review changes, remove logs * Move input username as a self contained component * Self review changes * Removing unnecesary arrow function * Removed intentUsername table and now using user metadata * Update website * Update turbo.json * Update e2e.yml * Update yarn.lock * Fixes for self host username update * Revert yarn lock from main branch * E2E fixes * Centralizes username check * Improvements * WIP separate logic between premium and save username button * WIP refactor username premium update * Saving WIP * WIP redo of username check * WIP obtain action normal, update or downgrade * Update username change components * Fix test for change-username self host or cal server * Fix user type for premiumTextfield * Using now a global unique const to know if is selfhosted, css fixes * Remove unused import * Using dynamic import for username textfield, prevent submit on enter Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: zomars <zomars@me.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>pull/3244/head
parent
7d6a6bf812
commit
c890e8d06d
|
@ -82,11 +82,13 @@ SEND_FEEDBACK_EMAIL=
|
|||
NEXT_PUBLIC_IS_E2E=
|
||||
|
||||
# Used for internal billing system
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID=
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID=
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID=
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
|
|
|
@ -27,6 +27,9 @@ jobs:
|
|||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
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_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
|
|
|
@ -26,9 +26,15 @@ jobs:
|
|||
# CRON_API_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
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_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
|
|
|
@ -0,0 +1,332 @@
|
|||
import { CheckIcon, ExternalLinkIcon, PencilAltIcon, StarIcon, XIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Input, Label } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { AppRouter } from "@server/routers/_app";
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
DOWNGRADE = "DOWNGRADE",
|
||||
}
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
user: Pick<
|
||||
User,
|
||||
| "username"
|
||||
| "name"
|
||||
| "email"
|
||||
| "bio"
|
||||
| "avatar"
|
||||
| "timeZone"
|
||||
| "weekStart"
|
||||
| "hideBranding"
|
||||
| "theme"
|
||||
| "plan"
|
||||
| "brandColor"
|
||||
| "darkBrandColor"
|
||||
| "metadata"
|
||||
| "timeFormat"
|
||||
| "allowDynamicBooking"
|
||||
>;
|
||||
}
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
user,
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const [usernameChangeCondition, setUsernameChangeCondition] = useState<UsernameChangeStatusEnum | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const userIsPremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||
|
||||
const debouncedApiCall = useCallback(
|
||||
debounce(async (username) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
setMarkAsError(!data.available);
|
||||
setPremiumUsername(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUsername !== inputUsernameValue) {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
} else if (inputUsernameValue === "") {
|
||||
setMarkAsError(false);
|
||||
setPremiumUsername(false);
|
||||
setUsernameIsAvailable(false);
|
||||
} else {
|
||||
setPremiumUsername(userIsPremium);
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}, [inputUsernameValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usernameIsAvailable || premiumUsername) {
|
||||
const condition = obtainNewUsernameChangeCondition({
|
||||
userIsPremium,
|
||||
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 updateUsername = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = (props: { index: string }) => {
|
||||
const { index } = props;
|
||||
return (usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid={`update-username-btn-${index}`}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
const saveUsername = () => {
|
||||
if (usernameChangeCondition === UsernameChangeStatusEnum.NORMAL) {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", justifyItems: "center" }}>
|
||||
<Label htmlFor={"username"}>{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
<div style={{ position: "relative", width: "100%" }}>
|
||||
<Input
|
||||
ref={usernameRef}
|
||||
name={"username"}
|
||||
autoComplete={"none"}
|
||||
autoCapitalize={"none"}
|
||||
autoCorrect={"none"}
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
defaultValue={currentUsername}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div
|
||||
className="top-0"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 2,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}>
|
||||
<span
|
||||
className={classNames(
|
||||
"mx-2 py-1",
|
||||
premiumUsername ? "text-orange-500" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{premiumUsername ? <StarIcon className="mt-[4px] w-6" /> : <></>}
|
||||
{!premiumUsername && usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xs:hidden">
|
||||
<ActionButtons index="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && (
|
||||
<p className={classNames("mt-1 text-xs text-gray-900")}>
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE && (
|
||||
<>{t("standard_to_premium_username_description")}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(usernameIsAvailable || premiumUsername) && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons index="mobile" />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<DialogClose asChild>
|
||||
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
|
||||
<XIcon className="w-4" />
|
||||
</div>
|
||||
</DialogClose>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<PencilAltIcon className="m-auto h-6 w-6"></PencilAltIcon>
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title={"Confirm username change"} />
|
||||
{usernameChangeCondition && usernameChangeCondition !== UsernameChangeStatusEnum.NORMAL && (
|
||||
<p className="-mt-4 mb-4 text-sm text-gray-800">
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE &&
|
||||
t("change_username_standard_to_premium")}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE &&
|
||||
t("change_username_premium_to_standard")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-gray-500">
|
||||
{t("current")} {t("username")}
|
||||
</p>
|
||||
<p className="mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-6 flex-1">
|
||||
<p className="text-gray-500" data-testid="new-username">
|
||||
{t("new")} {t("username")}
|
||||
</p>
|
||||
<p>{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
{/* redirect to checkout */}
|
||||
{(usernameChangeCondition === UsernameChangeStatusEnum.UPGRADE ||
|
||||
usernameChangeCondition === UsernameChangeStatusEnum.DOWNGRADE) && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="go-to-billing"
|
||||
href={`/api/integrations/stripepayment/subscription?intentUsername=${inputUsernameValue}`}>
|
||||
<>
|
||||
{t("go_to_stripe_billing")} <ExternalLinkIcon className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
{/* Normal save */}
|
||||
{usernameChangeCondition === UsernameChangeStatusEnum.NORMAL && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="save-username"
|
||||
onClick={() => {
|
||||
saveUsername();
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { PremiumTextfield };
|
|
@ -0,0 +1,216 @@
|
|||
import { CheckIcon, PencilAltIcon, XIcon } from "@heroicons/react/solid";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Input, Label } from "@calcom/ui/form/fields";
|
||||
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { AppRouter } from "@server/routers/_app";
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
}
|
||||
|
||||
const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
currentUsername,
|
||||
setCurrentUsername,
|
||||
inputUsernameValue,
|
||||
setInputUsernameValue,
|
||||
usernameRef,
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
|
||||
const debouncedApiCall = useCallback(
|
||||
debounce(async (username) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
setMarkAsError(!data.available);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUsername !== inputUsernameValue) {
|
||||
debouncedApiCall(inputUsernameValue);
|
||||
} else if (inputUsernameValue === "") {
|
||||
setMarkAsError(false);
|
||||
setUsernameIsAvailable(false);
|
||||
} else {
|
||||
setUsernameIsAvailable(false);
|
||||
}
|
||||
}, [inputUsernameValue]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const updateUsername = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
onSuccessMutation && (await onSuccessMutation());
|
||||
setCurrentUsername(inputUsernameValue);
|
||||
setOpenDialogSaveUsername(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onErrorMutation && onErrorMutation(error);
|
||||
},
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = (props: { index: string }) => {
|
||||
const { index } = props;
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid={`update-username-btn-${index}`}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="mx-2"
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor={"username"}>{t("username")}</Label>
|
||||
</div>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center rounded-l-sm border border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={usernameRef}
|
||||
name={"username"}
|
||||
autoComplete={"none"}
|
||||
autoCapitalize={"none"}
|
||||
autoCorrect={"none"}
|
||||
className={classNames(
|
||||
"mt-0 rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
)}
|
||||
defaultValue={currentUsername}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
/>
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||
<span className={classNames("mx-2 py-1")}>
|
||||
{usernameIsAvailable ? <CheckIcon className="mt-[4px] w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="xs:hidden">
|
||||
<ActionButtons index="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons index="mobile" />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
<DialogContent>
|
||||
<DialogClose asChild>
|
||||
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
|
||||
<XIcon className="w-4" />
|
||||
</div>
|
||||
</DialogClose>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<div className="xs:hidden flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
|
||||
<PencilAltIcon className="m-auto h-6 w-6"></PencilAltIcon>
|
||||
</div>
|
||||
<div className="mb-4 w-full px-4 pt-1">
|
||||
<DialogHeader title={t("confirm_username_change_dialog_title")} />
|
||||
|
||||
<div className="flex w-full flex-wrap rounded-sm bg-gray-100 py-3 text-sm">
|
||||
<div className="flex-1 px-2">
|
||||
<p className="text-gray-500">
|
||||
{t("current")} {t("username").toLocaleLowerCase()}
|
||||
</p>
|
||||
<p className="mt-1" data-testid="current-username">
|
||||
{currentUsername}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-500" data-testid="new-username">
|
||||
{t("new")} {t("username").toLocaleLowerCase()}
|
||||
</p>
|
||||
<p>{inputUsernameValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse gap-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
loading={updateUsername.isLoading}
|
||||
data-testid="save-username"
|
||||
onClick={() => {
|
||||
updateUsername.mutate({
|
||||
username: inputUsernameValue,
|
||||
});
|
||||
}}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary" onClick={() => setOpenDialogSaveUsername(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { UsernameTextfield };
|
|
@ -0,0 +1,6 @@
|
|||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
|
||||
import { PremiumTextfield } from "./PremiumTextfield";
|
||||
import { UsernameTextfield } from "./UsernameTextfield";
|
||||
|
||||
export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : PremiumTextfield;
|
|
@ -3,6 +3,7 @@ import slugify from "@lib/slugify";
|
|||
|
||||
export async function checkRegularUsername(_username: string) {
|
||||
const username = slugify(_username);
|
||||
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
|
@ -14,10 +15,12 @@ export async function checkRegularUsername(_username: string) {
|
|||
if (user) {
|
||||
return {
|
||||
available: false as const,
|
||||
premium,
|
||||
message: "A user exists with that username",
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true as const,
|
||||
premium,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
|
||||
import { checkRegularUsername } from "@lib/core/server/checkRegularUsername";
|
||||
|
||||
export const checkUsername = IS_SELF_HOSTED ? checkRegularUsername : checkPremiumUsername;
|
|
@ -0,0 +1,50 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { defaultHandler } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { userMetadata as zodUserMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||
|
||||
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { intentUsername } = req.body;
|
||||
// Check that user is authenticated
|
||||
try {
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
},
|
||||
where: { id: userId },
|
||||
rejectOnNotFound: true,
|
||||
});
|
||||
const checkPremiumUsernameResult = await checkUsername(intentUsername);
|
||||
|
||||
if (userId && user) {
|
||||
const userMetadata = zodUserMetadata.parse(user.metadata);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
...userMetadata,
|
||||
intentUsername,
|
||||
isIntentPremium: checkPremiumUsernameResult.premium,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(501).send({ message: "intent-username.save.error" });
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: getHandler }),
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||
|
||||
type Response = {
|
||||
available: boolean;
|
||||
|
@ -8,6 +8,6 @@ type Response = {
|
|||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const result = await checkPremiumUsername(req.body.username);
|
||||
const result = await checkUsername(req.body.username);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
|
|
@ -3,14 +3,14 @@ import { signIn } from "next-auth/react";
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
import { getPremiumPlanPrice } from "@calcom/stripe/utils";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { getSession } from "@lib/auth";
|
||||
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||
import prisma from "@lib/prisma";
|
||||
import { isSAMLLoginEnabled, hostedCal, samlTenantID, samlProductID, samlTenantProduct } from "@lib/saml";
|
||||
import { hostedCal, isSAMLLoginEnabled, samlProductID, samlTenantID, samlTenantProduct } from "@lib/saml";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
@ -62,7 +62,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
if (usernameParam && session.user.email) {
|
||||
const availability = await checkPremiumUsername(usernameParam);
|
||||
const availability = await checkUsername(usernameParam);
|
||||
if (availability.available && availability.premium) {
|
||||
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
||||
userEmail: session.user.email,
|
||||
|
|
|
@ -15,8 +15,8 @@ import * as z from "zod";
|
|||
import getApps from "@calcom/app-store/utils";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ResponseUsernameApi } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import { DOCS_URL } from "@calcom/lib/constants";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
|
@ -230,20 +230,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
|||
token: string;
|
||||
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
|
||||
|
||||
const fetchUsername = async (username: string) => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_WEBSITE_URL}/api/username`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username: username.trim() }),
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
});
|
||||
const data = (await response.json()) as ResponseUsernameApi;
|
||||
return { response, data };
|
||||
};
|
||||
|
||||
// Should update username on user when being redirected from sign up and doing google/saml
|
||||
useEffect(() => {
|
||||
async function validateAndSave(username: string) {
|
||||
|
|
|
@ -4,13 +4,13 @@ import { GetServerSidePropsContext } from "next";
|
|||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { TextField } from "@calcom/ui/form/fields";
|
||||
|
||||
import { withQuery } from "@lib/QueryCell";
|
||||
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
|
||||
|
@ -27,15 +27,19 @@ import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogCont
|
|||
import Avatar from "@components/ui/Avatar";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import InfoBadge from "@components/ui/InfoBadge";
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
import ColorPicker from "@components/ui/colorpicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
import TimezoneSelect, { ITimezone } from "@components/ui/form/TimezoneSelect";
|
||||
|
||||
import { AppRouter } from "@server/routers/_app";
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
|
||||
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
|
@ -46,12 +50,12 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
|||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={props.hideBrandingRef}
|
||||
defaultChecked={isBrandingHidden(props.user)}
|
||||
defaultChecked={isBrandingHidden(user)}
|
||||
className={
|
||||
"h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
|
||||
if (!e.currentTarget.checked || user.plan !== "FREE") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -69,20 +73,24 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
|||
}
|
||||
|
||||
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
|
||||
const utils = trpc.useContext();
|
||||
const { user } = props;
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: async () => {
|
||||
const utils = trpc.useContext();
|
||||
const onSuccessMutation = async () => {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
},
|
||||
onError: (err) => {
|
||||
};
|
||||
|
||||
const onErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
setErrorMessage(error.message);
|
||||
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
};
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: onSuccessMutation,
|
||||
onError: onErrorMutation,
|
||||
async onSettled() {
|
||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||
},
|
||||
|
@ -95,7 +103,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
}).catch((e) => {
|
||||
console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e);
|
||||
console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e);
|
||||
});
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
||||
|
@ -129,28 +137,28 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
|
||||
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
||||
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
||||
value: props.user.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === props.user.timeFormat)?.label || 12,
|
||||
value: user.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
|
||||
});
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(props.user.timeZone);
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(user.timeZone);
|
||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
|
||||
value: props.user.weekStart,
|
||||
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(props.localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
});
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState({
|
||||
value: props.localeProp || "",
|
||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
|
||||
});
|
||||
const [imageSrc, setImageSrc] = useState<string>(props.user.avatar || "");
|
||||
const [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [brandColor, setBrandColor] = useState(props.user.brandColor);
|
||||
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
|
||||
const [brandColor, setBrandColor] = useState(user.brandColor);
|
||||
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.user.theme) return;
|
||||
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
|
||||
if (!user.theme) return;
|
||||
const userTheme = themeOptions.find((theme) => theme.value === user.theme);
|
||||
if (!userTheme) return;
|
||||
setSelectedTheme(userTheme);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
@ -192,27 +200,34 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
timeFormat: enteredTimeFormat,
|
||||
});
|
||||
}
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
|
||||
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
|
||||
|
||||
return (
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<>
|
||||
<div className="pt-6 pb-4 lg:pb-8">
|
||||
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
||||
<div className="mb-6 w-full sm:w-1/2">
|
||||
<TextField
|
||||
name="username"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
}
|
||||
ref={usernameRef}
|
||||
defaultValue={props.user.username || undefined}
|
||||
<div className="w-full">
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={inputUsernameValue}
|
||||
usernameRef={usernameRef}
|
||||
setInputUsernameValue={setInputUsernameValue}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2">
|
||||
</div>
|
||||
</div>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<div className="pb-6 lg:pb-8">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("full_name")}
|
||||
</label>
|
||||
|
@ -224,8 +239,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
autoComplete="given-name"
|
||||
placeholder={t("your_name")}
|
||||
required
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
||||
defaultValue={props.user.name || undefined}
|
||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm focus:border-neutral-800 focus:outline-none focus:ring-neutral-800 sm:text-sm"
|
||||
defaultValue={user.name || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -240,8 +255,8 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
name="email"
|
||||
id="email"
|
||||
placeholder={t("your_email")}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"
|
||||
defaultValue={props.user.email}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
defaultValue={user.email}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||
{t("change_email_tip")}
|
||||
|
@ -260,16 +275,16 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
name="about"
|
||||
placeholder={t("little_something_about")}
|
||||
rows={3}
|
||||
defaultValue={props.user.bio || undefined}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm sm:text-sm"></textarea>
|
||||
defaultValue={user.bio || undefined}
|
||||
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-1 flex">
|
||||
<Avatar
|
||||
alt={props.user.name || ""}
|
||||
alt={user.name || ""}
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
gravatarFallbackMd5={props.user.emailMd5}
|
||||
gravatarFallbackMd5={user.emailMd5}
|
||||
imageSrc={imageSrc}
|
||||
/>
|
||||
<input
|
||||
|
@ -338,7 +353,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<div className="mt-1">
|
||||
<Select
|
||||
id="timeFormatSelect"
|
||||
value={selectedTimeFormat || props.user.timeFormat}
|
||||
value={selectedTimeFormat || user.timeFormat}
|
||||
onChange={(v) => v && setSelectedTimeFormat(v)}
|
||||
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
||||
options={timeFormatOptions}
|
||||
|
@ -424,24 +439,24 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
|
||||
{t("light_brand_color")}
|
||||
</label>
|
||||
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
|
||||
<ColorPicker defaultValue={user.brandColor} onChange={setBrandColor} />
|
||||
</div>
|
||||
<div className="mb-2 sm:w-1/2">
|
||||
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
|
||||
{t("dark_brand_color")}
|
||||
</label>
|
||||
<ColorPicker defaultValue={props.user.darkBrandColor} onChange={setDarkBrandColor} />
|
||||
<ColorPicker defaultValue={user.darkBrandColor} onChange={setDarkBrandColor} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<HideBrandingInput user={props.user} hideBrandingRef={hideBrandingRef} />
|
||||
<HideBrandingInput user={user} hideBrandingRef={hideBrandingRef} />
|
||||
</div>
|
||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}{" "}
|
||||
{props.user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
|
||||
{user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
|
@ -483,6 +498,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
import { expect } from "@playwright/test";
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
import { getFreePlanPrice, getProPlanPrice } from "@calcom/stripe/utils";
|
||||
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||
process.env.STRIPE_PRIVATE_KEY
|
||||
);
|
||||
|
||||
const IS_SELF_HOSTED = !(
|
||||
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
|
||||
);
|
||||
|
||||
test.describe("Change username on settings", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("User can change username", async ({ page, users }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL });
|
||||
|
||||
await user.login();
|
||||
// Try to go homepage
|
||||
await page.goto("/settings/profile");
|
||||
// Change username from normal to normal
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
|
||||
await usernameInput.fill("demousernamex");
|
||||
|
||||
// Click on save button
|
||||
await page.click("[data-testid=update-username-btn-desktop]");
|
||||
|
||||
await page.click("[data-testid=save-username]");
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(400);
|
||||
const newUpdatedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
expect(newUpdatedUser?.username).toBe("demousernamex");
|
||||
});
|
||||
|
||||
test("User trial can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
|
||||
|
||||
const user = await users.create({ plan: UserPlan.TRIAL });
|
||||
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
|
||||
await stripe.subscriptionSchedules.create({
|
||||
customer: customer.id,
|
||||
start_date: "now",
|
||||
end_behavior: "release",
|
||||
phases: [
|
||||
{
|
||||
items: [{ price: getProPlanPrice() }],
|
||||
trial_end: dayjs().add(14, "day").unix(),
|
||||
end_date: dayjs().add(14, "day").unix(),
|
||||
},
|
||||
{
|
||||
items: [{ price: getFreePlanPrice() }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await page.goto("/settings/profile");
|
||||
|
||||
// Change username from normal to premium
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
|
||||
await usernameInput.fill(`xx${testInfo.workerIndex}`);
|
||||
|
||||
// Click on save button
|
||||
await page.click("[data-testid=update-username-btn-desktop]");
|
||||
|
||||
// Validate modal text fields
|
||||
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
|
||||
const newUsernameText = page.locator("[data-testid=new-username]").innerText();
|
||||
|
||||
expect(currentUsernameText).not.toBe(newUsernameText);
|
||||
|
||||
// Click on Go to billing
|
||||
await page.click("[data-testid=go-to-billing]", { timeout: 300 });
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await expect(page).toHaveURL(/.*checkout.stripe.com/);
|
||||
});
|
||||
|
||||
test("User PRO can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
test.skip(IS_SELF_HOSTED, "It shouldn't run on self hosted");
|
||||
const user = await users.create({ plan: UserPlan.PRO });
|
||||
const customer = await stripe.customers.create({ email: `${user?.username}@example.com` });
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
number: "4242424242424242",
|
||||
cvc: "123",
|
||||
exp_month: 12,
|
||||
exp_year: 2040,
|
||||
},
|
||||
});
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id });
|
||||
await stripe.subscriptions.create({
|
||||
customer: customer.id,
|
||||
items: [{ price: getProPlanPrice() }],
|
||||
});
|
||||
|
||||
await user.login();
|
||||
await page.goto("/settings/profile");
|
||||
|
||||
// Change username from normal to premium
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
|
||||
await usernameInput.fill(`xx${testInfo.workerIndex}`);
|
||||
|
||||
// Click on save button
|
||||
await page.click("[data-testid=update-username-btn-desktop]");
|
||||
|
||||
// Validate modal text fields
|
||||
const currentUsernameText = page.locator("[data-testid=current-username]").innerText();
|
||||
const newUsernameText = page.locator("[data-testid=new-username]").innerText();
|
||||
|
||||
expect(currentUsernameText).not.toBe(newUsernameText);
|
||||
|
||||
// Click on Go to billing
|
||||
await page.click("[data-testid=go-to-billing]", { timeout: 300 });
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await expect(page).toHaveURL(/.*billing.stripe.com/);
|
||||
});
|
||||
});
|
|
@ -29,7 +29,7 @@ test.describe("Stripe integration", () => {
|
|||
).toContainText("Disconnect");
|
||||
|
||||
// Cleanup
|
||||
await user.delete();
|
||||
await users.deleteAll();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -67,7 +67,7 @@ test.describe("Stripe integration", () => {
|
|||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await user.delete();
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
todo("Pending payment booking should not be confirmed by default");
|
||||
|
|
|
@ -902,6 +902,15 @@
|
|||
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
|
||||
"url_start_with_https": "URL needs to start with http:// or https://",
|
||||
"number_provided": "Phone number will be provided",
|
||||
"standard_to_premium_username_description": "This is a standard username and updating will take you to billing to downgrade.",
|
||||
"current": "Current",
|
||||
"premium": "premium",
|
||||
"standard": "standard",
|
||||
"new": "New",
|
||||
"confirm_username_change_dialog_title": "Confirm username change",
|
||||
"change_username_standard_to_premium": "As you are changing from a standard to a premium username, you will be taken to the checkout to upgrade.",
|
||||
"change_username_premium_to_standard": "As you are changing from a premium to a standard username, you will be taken to the checkout to downgrade.",
|
||||
"go_to_stripe_billing": "Go to billing",
|
||||
"trial_expired": "Your trial has expired",
|
||||
"remove_app": "Remove App",
|
||||
"yes_remove_app": "Yes, remove app",
|
||||
|
|
|
@ -6,14 +6,13 @@ import { z } from "zod";
|
|||
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import { sendFeedbackEmail } from "@calcom/emails";
|
||||
import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { parseRecurringEvent, isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { closePayments } from "@ee/lib/stripe/server";
|
||||
|
||||
import { checkRegularUsername } from "@lib/core/checkRegularUsername";
|
||||
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||
import jackson from "@lib/jackson";
|
||||
import prisma from "@lib/prisma";
|
||||
import { isTeamOwner } from "@lib/queries/teams";
|
||||
|
@ -41,9 +40,6 @@ import { resizeBase64Image } from "../lib/resizeBase64Image";
|
|||
import { viewerTeamsRouter } from "./viewer/teams";
|
||||
import { webhookRouter } from "./viewer/webhook";
|
||||
|
||||
const checkUsername =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL === "https://cal.com" ? checkPremiumUsername : checkRegularUsername;
|
||||
|
||||
// things that unauthenticated users can query about themselves
|
||||
const publicViewerRouter = createRouter()
|
||||
.query("session", {
|
||||
|
@ -698,7 +694,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
|||
if (username !== user.username) {
|
||||
data.username = username;
|
||||
const response = await checkUsername(username);
|
||||
if (!response.available || ("premium" in response && response.premium)) {
|
||||
if (!response.available) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export { default as add } from "./add";
|
||||
export { default as callback } from "./callback";
|
||||
export { default as portal } from "./portal";
|
||||
export { default as subscription } from "./subscription";
|
||||
// TODO: Figure out how to handle webhook endpoints from App Store
|
||||
// export { default as webhook } from "./webhook";
|
||||
|
|
|
@ -0,0 +1,158 @@
|
|||
import { UserPlan } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import {
|
||||
PREMIUM_PLAN_PRICE,
|
||||
PREMIUM_PLAN_PRODUCT_ID,
|
||||
PRO_PLAN_PRICE,
|
||||
PRO_PLAN_PRODUCT_ID,
|
||||
} from "@calcom/stripe/constants";
|
||||
import { getStripeCustomerIdFromUserId } from "@calcom/stripe/customer";
|
||||
import stripe from "@calcom/stripe/server";
|
||||
|
||||
enum UsernameChangeStatusEnum {
|
||||
NORMAL = "NORMAL",
|
||||
UPGRADE = "UPGRADE",
|
||||
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) {
|
||||
if (req.method === "GET") {
|
||||
const userId = req.session?.user.id;
|
||||
let { intentUsername = null } = req.query;
|
||||
|
||||
if (!userId || !intentUsername) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
if (intentUsername && typeof intentUsername === "object") {
|
||||
intentUsername = intentUsername[0];
|
||||
}
|
||||
const customerId = await getStripeCustomerIdFromUserId(userId);
|
||||
|
||||
if (!customerId) {
|
||||
res.status(404).json({ message: "Missing customer id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = await prisma.user.findFirst({
|
||||
where: { id: userId },
|
||||
select: { id: true, plan: true, metadata: true },
|
||||
});
|
||||
if (!userData) {
|
||||
res.status(404).json({ message: "Missing user data" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentlyPremium = hasKeyInMetadata(userData, "isPremium") && !!userData.metadata.isPremium;
|
||||
|
||||
// Save the intentUsername in the metadata
|
||||
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 = {
|
||||
customer: customerId,
|
||||
return_url,
|
||||
};
|
||||
|
||||
const checkPremiumResult = await checkPremiumUsername(intentUsername);
|
||||
if (!checkPremiumResult.available) {
|
||||
return res.status(404).json({ message: "Intent username not available" });
|
||||
}
|
||||
|
||||
if (userData && (userData.plan === UserPlan.FREE || userData.plan === UserPlan.TRIAL)) {
|
||||
const subscriptionPrice = checkPremiumResult.premium ? PREMIUM_PLAN_PRICE : PRO_PLAN_PRICE;
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: subscriptionPrice,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: return_url,
|
||||
cancel_url: return_url,
|
||||
allow_promotion_codes: true,
|
||||
});
|
||||
if (checkoutSession && checkoutSession.url) {
|
||||
return res.redirect(checkoutSession.url).end();
|
||||
}
|
||||
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) {
|
||||
let actionText = "";
|
||||
const customProductsSession = [];
|
||||
if (action === UsernameChangeStatusEnum.UPGRADE) {
|
||||
actionText = "Upgrade your plan account";
|
||||
if (checkPremiumResult.premium) {
|
||||
customProductsSession.push({ prices: [PREMIUM_PLAN_PRICE], product: PREMIUM_PLAN_PRODUCT_ID });
|
||||
} else {
|
||||
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
|
||||
}
|
||||
} else if (action === UsernameChangeStatusEnum.DOWNGRADE) {
|
||||
actionText = "Downgrade your plan account";
|
||||
if (isCurrentlyPremium) {
|
||||
customProductsSession.push({ prices: [PRO_PLAN_PRICE], product: PRO_PLAN_PRODUCT_ID });
|
||||
}
|
||||
}
|
||||
|
||||
const configuration = await stripe.billingPortal.configurations.create({
|
||||
business_profile: {
|
||||
headline: actionText,
|
||||
},
|
||||
features: {
|
||||
payment_method_update: {
|
||||
enabled: true,
|
||||
},
|
||||
subscription_update: {
|
||||
enabled: true,
|
||||
proration_behavior: "always_invoice",
|
||||
default_allowed_updates: ["price"],
|
||||
products: customProductsSession,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (configuration) {
|
||||
createSessionParams.configuration = configuration.id;
|
||||
}
|
||||
}
|
||||
const stripeSession = await stripe.billingPortal.sessions.create(createSessionParams);
|
||||
|
||||
res.redirect(stripeSession.url).end();
|
||||
}
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { JSONObject } from "superjson/dist/types";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET" && req.session && req.session.user.id) {
|
||||
const userId = req.session.user.id;
|
||||
try {
|
||||
const user = await prisma?.user.findFirst({
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import slugify from "@calcom/lib/slugify";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
|
||||
export type ResponseUsernameApi = {
|
||||
interface ResponseUsernameApi {
|
||||
available: boolean;
|
||||
premium: boolean;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
|
||||
const username = slugify(_username);
|
||||
|
|
|
@ -12,6 +12,9 @@ export const CONSOLE_URL =
|
|||
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || process.env.NODE_ENV !== "production"
|
||||
? `https://console.cal.dev`
|
||||
: `https://console.cal.com`;
|
||||
export const IS_SELF_HOSTED = !(
|
||||
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || !!new URL(WEBAPP_URL).hostname.endsWith(".cal.com")
|
||||
);
|
||||
export const EMBED_LIB_URL = process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
|
||||
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||
export const TRIAL_LIMIT_DAYS = 14;
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
type ResponseUsernameApi = {
|
||||
available: boolean;
|
||||
premium: boolean;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
|
||||
export async function fetchUsername(username: string) {
|
||||
const response = await fetch("/api/username", {
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const data = (await response.json()) as ResponseUsernameApi;
|
||||
return { response, data };
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import isPrismaObj from "./isPrismaObj";
|
||||
|
||||
const hasKeyInMetadata = <T extends string>(
|
||||
x: { metadata: unknown } | null,
|
||||
key: T
|
||||
): x is { metadata: { [key in T]: string | boolean | number } } =>
|
||||
isPrismaObj(x?.metadata) && !!x?.metadata && key in x.metadata;
|
||||
|
||||
export default hasKeyInMetadata;
|
|
@ -106,5 +106,7 @@ export const userMetadata = z
|
|||
proPaidForByTeamId: z.number().optional(),
|
||||
stripeCustomerId: z.string().optional(),
|
||||
vitalSettings: vitalSettingsUpdateSchema.optional(),
|
||||
isPremium: z.boolean().optional(),
|
||||
intentUsername: z.string().optional(),
|
||||
})
|
||||
.nullable();
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
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 PRO_PLAN_PRICE = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || "";
|
||||
export const PRO_PLAN_PRODUCT = process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT || "";
|
||||
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 PREMIUM_PLAN_PRODUCT_ID = process.env.STRIPE_PREMIUM_PLAN_PRODUCT_ID || "";
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { UserPlan } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import stripe from "@calcom/stripe/server";
|
||||
|
||||
import {
|
||||
getFreePlanPrice,
|
||||
getPremiumPlanPrice,
|
||||
getProPlanPrice,
|
||||
getFreePlanProductId,
|
||||
getPremiumPlanProductId,
|
||||
getProPlanProductId,
|
||||
} from "./utils";
|
||||
|
||||
interface IRetrieveSubscriptionIdResponse {
|
||||
message?: string;
|
||||
subscriptionId?: string;
|
||||
}
|
||||
|
||||
export async function retrieveSubscriptionIdFromStripeCustomerId(
|
||||
stripeCustomerId: string
|
||||
): Promise<IRetrieveSubscriptionIdResponse> {
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId, {
|
||||
expand: ["subscriptions.data.plan"],
|
||||
});
|
||||
if (!customer || customer.deleted) {
|
||||
return {
|
||||
message: "Not found",
|
||||
};
|
||||
}
|
||||
|
||||
const subscription = customer.subscriptions?.data[0];
|
||||
if (!subscription) {
|
||||
return {
|
||||
message: "Not found",
|
||||
};
|
||||
}
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
};
|
||||
}
|
||||
|
||||
// @NOTE: Remove when user subscription plan id is saved on db and not on stripe only
|
||||
export function obtainUserPlanDetails(subscription: Stripe.Subscription) {
|
||||
const proPlanProductId = getProPlanProductId();
|
||||
const premiumPlanProductId = getPremiumPlanProductId();
|
||||
const freePlanProductId = getFreePlanProductId();
|
||||
let priceId = "";
|
||||
const hasProPlan = !!subscription.items.data.find((item) => item.plan.product === proPlanProductId);
|
||||
const hasPremiumPlan = !!subscription.items.data.find((item) => item.plan.product === premiumPlanProductId);
|
||||
const hasFreePlan = !!subscription.items.data.find((item) => item.plan.product === freePlanProductId);
|
||||
let userPlan: UserPlan;
|
||||
if (hasPremiumPlan) {
|
||||
priceId = getPremiumPlanPrice();
|
||||
userPlan = UserPlan.PRO;
|
||||
} else if (hasProPlan) {
|
||||
priceId = getProPlanPrice();
|
||||
userPlan = UserPlan.PRO;
|
||||
} else if (hasFreePlan) {
|
||||
priceId = getFreePlanPrice();
|
||||
userPlan = UserPlan.FREE;
|
||||
} else {
|
||||
userPlan = UserPlan.TRIAL;
|
||||
}
|
||||
|
||||
return {
|
||||
userPlan,
|
||||
priceId,
|
||||
isProPlan: hasProPlan,
|
||||
isPremiumPlan: hasPremiumPlan,
|
||||
isFreePlan: hasFreePlan,
|
||||
};
|
||||
}
|
|
@ -1,4 +1,11 @@
|
|||
import { FREE_PLAN_PRICE, PREMIUM_PLAN_PRICE, PRO_PLAN_PRICE, PRO_PLAN_PRODUCT } from "./constants";
|
||||
import {
|
||||
FREE_PLAN_PRICE,
|
||||
FREE_PLAN_PRODUCT_ID,
|
||||
PREMIUM_PLAN_PRICE,
|
||||
PREMIUM_PLAN_PRODUCT_ID,
|
||||
PRO_PLAN_PRICE,
|
||||
PRO_PLAN_PRODUCT_ID,
|
||||
} from "./constants";
|
||||
|
||||
export function getPerSeatProPlanPrice(): string {
|
||||
return PRO_PLAN_PRICE;
|
||||
|
@ -12,10 +19,18 @@ export function getProPlanPrice(): string {
|
|||
return PRO_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getProPlanProduct(): string {
|
||||
return PRO_PLAN_PRODUCT;
|
||||
}
|
||||
|
||||
export function getFreePlanPrice(): string {
|
||||
return FREE_PLAN_PRICE;
|
||||
}
|
||||
|
||||
export function getProPlanProductId(): string {
|
||||
return PRO_PLAN_PRODUCT_ID;
|
||||
}
|
||||
|
||||
export function getPremiumPlanProductId(): string {
|
||||
return PREMIUM_PLAN_PRODUCT_ID;
|
||||
}
|
||||
|
||||
export function getFreePlanProductId(): string {
|
||||
return FREE_PLAN_PRODUCT_ID;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,9 @@
|
|||
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT",
|
||||
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||
|
@ -56,7 +58,9 @@
|
|||
"$NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE",
|
||||
"$NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT",
|
||||
"$STRIPE_PRO_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_PREMIUM_PLAN_PRODUCT_ID",
|
||||
"$STRIPE_FREE_PLAN_PRODUCT_ID",
|
||||
"$NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
|
||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||
|
|
Loading…
Reference in New Issue