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>
This commit is contained in:
parent
7d6a6bf812
commit
c890e8d06d
|
@ -82,11 +82,13 @@ SEND_FEEDBACK_EMAIL=
|
||||||
NEXT_PUBLIC_IS_E2E=
|
NEXT_PUBLIC_IS_E2E=
|
||||||
|
|
||||||
# Used for internal billing system
|
# Used for internal billing system
|
||||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRODUCT=
|
|
||||||
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_STRIPE_FREE_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
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
|
# Use for internal Public API Keys and optional
|
||||||
API_KEY_PREFIX=cal_
|
API_KEY_PREFIX=cal_
|
||||||
|
|
|
@ -27,6 +27,9 @@ jobs:
|
||||||
# CRON_API_KEY: xxx
|
# CRON_API_KEY: xxx
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_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_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 }}
|
||||||
|
|
|
@ -26,9 +26,15 @@ jobs:
|
||||||
# CRON_API_KEY: xxx
|
# CRON_API_KEY: xxx
|
||||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_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_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 }}
|
||||||
|
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_PERCENTAGE: 0.005
|
||||||
PAYMENT_FEE_FIXED: 10
|
PAYMENT_FEE_FIXED: 10
|
||||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
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) {
|
export async function checkRegularUsername(_username: string) {
|
||||||
const username = slugify(_username);
|
const username = slugify(_username);
|
||||||
|
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
|
@ -14,10 +15,12 @@ export async function checkRegularUsername(_username: string) {
|
||||||
if (user) {
|
if (user) {
|
||||||
return {
|
return {
|
||||||
available: false as const,
|
available: false as const,
|
||||||
|
premium,
|
||||||
message: "A user exists with that username",
|
message: "A user exists with that username",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
available: true as const,
|
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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||||
|
|
||||||
type Response = {
|
type Response = {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
@ -8,6 +8,6 @@ type Response = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
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);
|
return res.status(200).json(result);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,14 @@ import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
|
||||||
import stripe from "@calcom/stripe/server";
|
import stripe from "@calcom/stripe/server";
|
||||||
import { getPremiumPlanPrice } from "@calcom/stripe/utils";
|
import { getPremiumPlanPrice } from "@calcom/stripe/utils";
|
||||||
|
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
import { checkUsername } from "@lib/core/server/checkUsername";
|
||||||
import prisma from "@lib/prisma";
|
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 { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import { ssrInit } from "@server/lib/ssr";
|
import { ssrInit } from "@server/lib/ssr";
|
||||||
|
@ -62,7 +62,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
if (session) {
|
if (session) {
|
||||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||||
if (usernameParam && session.user.email) {
|
if (usernameParam && session.user.email) {
|
||||||
const availability = await checkPremiumUsername(usernameParam);
|
const availability = await checkUsername(usernameParam);
|
||||||
if (availability.available && availability.premium) {
|
if (availability.available && availability.premium) {
|
||||||
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
const stripePremiumUrl = await getStripePremiumUsernameUrl({
|
||||||
userEmail: session.user.email,
|
userEmail: session.user.email,
|
||||||
|
|
|
@ -15,8 +15,8 @@ import * as z from "zod";
|
||||||
import getApps from "@calcom/app-store/utils";
|
import getApps from "@calcom/app-store/utils";
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { ResponseUsernameApi } from "@calcom/ee/lib/core/checkPremiumUsername";
|
|
||||||
import { DOCS_URL } from "@calcom/lib/constants";
|
import { DOCS_URL } from "@calcom/lib/constants";
|
||||||
|
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Form } from "@calcom/ui/form/fields";
|
import { Form } from "@calcom/ui/form/fields";
|
||||||
|
@ -230,20 +230,6 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
|
||||||
token: string;
|
token: string;
|
||||||
}>({ resolver: zodResolver(schema), mode: "onSubmit" });
|
}>({ 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
|
// Should update username on user when being redirected from sign up and doing google/saml
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function validateAndSave(username: string) {
|
async function validateAndSave(username: string) {
|
||||||
|
|
|
@ -4,13 +4,13 @@ import { GetServerSidePropsContext } from "next";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import showToast from "@calcom/lib/notification";
|
import showToast from "@calcom/lib/notification";
|
||||||
import { Alert } from "@calcom/ui/Alert";
|
import { Alert } from "@calcom/ui/Alert";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import { TextField } from "@calcom/ui/form/fields";
|
|
||||||
|
|
||||||
import { withQuery } from "@lib/QueryCell";
|
import { withQuery } from "@lib/QueryCell";
|
||||||
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
|
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
|
||||||
|
@ -27,15 +27,19 @@ import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogCont
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import Badge from "@components/ui/Badge";
|
import Badge from "@components/ui/Badge";
|
||||||
import InfoBadge from "@components/ui/InfoBadge";
|
import InfoBadge from "@components/ui/InfoBadge";
|
||||||
|
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||||
import ColorPicker from "@components/ui/colorpicker";
|
import ColorPicker from "@components/ui/colorpicker";
|
||||||
import Select from "@components/ui/form/Select";
|
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";
|
import { UpgradeToProDialog } from "../../components/UpgradeToProDialog";
|
||||||
|
|
||||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>; user: Props["user"] }) {
|
||||||
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
@ -46,12 +50,12 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
||||||
name="hide-branding"
|
name="hide-branding"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
ref={props.hideBrandingRef}
|
ref={props.hideBrandingRef}
|
||||||
defaultChecked={isBrandingHidden(props.user)}
|
defaultChecked={isBrandingHidden(user)}
|
||||||
className={
|
className={
|
||||||
"h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
|
"h-4 w-4 rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-800 disabled:opacity-50"
|
||||||
}
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
|
if (!e.currentTarget.checked || user.plan !== "FREE") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,20 +73,24 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
|
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
|
||||||
const utils = trpc.useContext();
|
const { user } = props;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
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"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
|
||||||
|
setHasErrors(true);
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||||
onSuccess: async () => {
|
onSuccess: onSuccessMutation,
|
||||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
onError: onErrorMutation,
|
||||||
setHasErrors(false); // dismiss any open errors
|
|
||||||
await utils.invalidateQueries(["viewer.me"]);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setHasErrors(true);
|
|
||||||
setErrorMessage(err.message);
|
|
||||||
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
},
|
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
await utils.invalidateQueries(["viewer.public.i18n"]);
|
await utils.invalidateQueries(["viewer.public.i18n"]);
|
||||||
},
|
},
|
||||||
|
@ -95,7 +103,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
}).catch((e) => {
|
}).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") {
|
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||||
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
||||||
|
@ -129,28 +137,28 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
|
const allowDynamicGroupBookingRef = useRef<HTMLInputElement>(null!);
|
||||||
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
const [selectedTheme, setSelectedTheme] = useState<typeof themeOptions[number] | undefined>();
|
||||||
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
const [selectedTimeFormat, setSelectedTimeFormat] = useState({
|
||||||
value: props.user.timeFormat || 12,
|
value: user.timeFormat || 12,
|
||||||
label: timeFormatOptions.find((option) => option.value === props.user.timeFormat)?.label || 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({
|
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({
|
||||||
value: props.user.weekStart,
|
value: user.weekStart,
|
||||||
label: nameOfDay(props.localeProp, props.user.weekStart === "Sunday" ? 0 : 1),
|
label: nameOfDay(props.localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState({
|
const [selectedLanguage, setSelectedLanguage] = useState({
|
||||||
value: props.localeProp || "",
|
value: props.localeProp || "",
|
||||||
label: localeOptions.find((option) => option.value === props.localeProp)?.label || "",
|
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 [hasErrors, setHasErrors] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [brandColor, setBrandColor] = useState(props.user.brandColor);
|
const [brandColor, setBrandColor] = useState(user.brandColor);
|
||||||
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
|
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.user.theme) return;
|
if (!user.theme) return;
|
||||||
const userTheme = themeOptions.find((theme) => theme.value === props.user.theme);
|
const userTheme = themeOptions.find((theme) => theme.value === user.theme);
|
||||||
if (!userTheme) return;
|
if (!userTheme) return;
|
||||||
setSelectedTheme(userTheme);
|
setSelectedTheme(userTheme);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
@ -192,297 +200,305 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
timeFormat: enteredTimeFormat,
|
timeFormat: enteredTimeFormat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const [currentUsername, setCurrentUsername] = useState(user.username || undefined);
|
||||||
|
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
<>
|
||||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
<div className="pt-6 pb-4 lg:pb-8">
|
||||||
<div className="py-6 lg:pb-8">
|
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
||||||
<div className="flex flex-col lg:flex-row">
|
<div className="w-full">
|
||||||
<div className="flex-grow space-y-6">
|
<UsernameAvailability
|
||||||
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
currentUsername={currentUsername}
|
||||||
<div className="mb-6 w-full sm:w-1/2">
|
setCurrentUsername={setCurrentUsername}
|
||||||
<TextField
|
inputUsernameValue={inputUsernameValue}
|
||||||
name="username"
|
usernameRef={usernameRef}
|
||||||
addOnLeading={
|
setInputUsernameValue={setInputUsernameValue}
|
||||||
<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">
|
onSuccessMutation={onSuccessMutation}
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
onErrorMutation={onErrorMutation}
|
||||||
</span>
|
user={user}
|
||||||
}
|
/>
|
||||||
ref={usernameRef}
|
</div>
|
||||||
defaultValue={props.user.username || undefined}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
|
||||||
<div className="w-full sm:w-1/2">
|
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<div className="pb-6 lg:pb-8">
|
||||||
{t("full_name")}
|
<div className="flex flex-col lg:flex-row">
|
||||||
</label>
|
<div className="flex-grow space-y-6">
|
||||||
<input
|
<div className="block sm:flex">
|
||||||
ref={nameRef}
|
<div className="w-full">
|
||||||
type="text"
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
name="name"
|
{t("full_name")}
|
||||||
id="name"
|
</label>
|
||||||
autoComplete="given-name"
|
<input
|
||||||
placeholder={t("your_name")}
|
ref={nameRef}
|
||||||
required
|
type="text"
|
||||||
className="mt-1 block w-full rounded-sm border border-gray-300 px-3 py-2 shadow-sm sm:text-sm"
|
name="name"
|
||||||
defaultValue={props.user.name || undefined}
|
id="name"
|
||||||
/>
|
autoComplete="given-name"
|
||||||
</div>
|
placeholder={t("your_name")}
|
||||||
</div>
|
required
|
||||||
<div className="block sm:flex">
|
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"
|
||||||
<div className="mb-6 w-full sm:w-1/2">
|
defaultValue={user.name || undefined}
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("email")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={emailRef}
|
|
||||||
type="email"
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
|
||||||
{t("change_email_tip")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("about")}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<textarea
|
|
||||||
ref={descriptionRef}
|
|
||||||
id="about"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mt-1 flex">
|
|
||||||
<Avatar
|
|
||||||
alt={props.user.name || ""}
|
|
||||||
className="relative h-10 w-10 rounded-full"
|
|
||||||
gravatarFallbackMd5={props.user.emailMd5}
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={avatarRef}
|
|
||||||
type="hidden"
|
|
||||||
name="avatar"
|
|
||||||
id="avatar"
|
|
||||||
placeholder="URL"
|
|
||||||
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={imageSrc}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center px-5">
|
|
||||||
<ImageUploader
|
|
||||||
target="avatar"
|
|
||||||
id="avatar-upload"
|
|
||||||
buttonMsg={t("change_avatar")}
|
|
||||||
handleAvatarChange={(newAvatar) => {
|
|
||||||
avatarRef.current.value = newAvatar;
|
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
||||||
window.HTMLInputElement.prototype,
|
|
||||||
"value"
|
|
||||||
)?.set;
|
|
||||||
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
|
||||||
const ev2 = new Event("input", { bubbles: true });
|
|
||||||
avatarRef.current.dispatchEvent(ev2);
|
|
||||||
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
|
||||||
setImageSrc(newAvatar);
|
|
||||||
}}
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-6" />
|
<div className="block sm:flex">
|
||||||
</div>
|
<div className="mb-6 w-full sm:w-1/2">
|
||||||
<div>
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
<label htmlFor="language" className="block text-sm font-medium text-gray-700">
|
{t("email")}
|
||||||
{t("language")}
|
</label>
|
||||||
</label>
|
<input
|
||||||
<div className="mt-1">
|
ref={emailRef}
|
||||||
<Select
|
type="email"
|
||||||
id="languageSelect"
|
name="email"
|
||||||
value={selectedLanguage || props.localeProp}
|
id="email"
|
||||||
onChange={(v) => v && setSelectedLanguage(v)}
|
placeholder={t("your_email")}
|
||||||
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
className="mt-1 block w-full rounded-sm border-gray-300 shadow-sm focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||||
options={localeOptions}
|
defaultValue={user.email}
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-2 text-sm text-gray-500" id="email-description">
|
||||||
|
{t("change_email_tip")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||||
{t("timezone")}
|
{t("about")}
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<TimezoneSelect
|
|
||||||
id="timeZone"
|
|
||||||
value={selectedTimeZone}
|
|
||||||
onChange={(v) => v && setSelectedTimeZone(v)}
|
|
||||||
className="mt-1 block w-full rounded-sm shadow-sm sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="timeFormat" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("time_format")}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Select
|
|
||||||
id="timeFormatSelect"
|
|
||||||
value={selectedTimeFormat || props.user.timeFormat}
|
|
||||||
onChange={(v) => v && setSelectedTimeFormat(v)}
|
|
||||||
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
|
||||||
options={timeFormatOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("first_day_of_week")}
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Select
|
|
||||||
id="weekStart"
|
|
||||||
value={selectedWeekStartDay}
|
|
||||||
onChange={(v) => v && setSelectedWeekStartDay(v)}
|
|
||||||
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
|
||||||
options={[
|
|
||||||
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
|
||||||
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
|
||||||
{ value: "Tuesday", label: nameOfDay(props.localeProp, 2) },
|
|
||||||
{ value: "Wednesday", label: nameOfDay(props.localeProp, 3) },
|
|
||||||
{ value: "Thursday", label: nameOfDay(props.localeProp, 4) },
|
|
||||||
{ value: "Friday", label: nameOfDay(props.localeProp, 5) },
|
|
||||||
{ value: "Saturday", label: nameOfDay(props.localeProp, 6) },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-8 flex items-start">
|
|
||||||
<div className="flex h-5 items-center">
|
|
||||||
<input
|
|
||||||
id="dynamic-group-booking"
|
|
||||||
name="dynamic-group-booking"
|
|
||||||
type="checkbox"
|
|
||||||
ref={allowDynamicGroupBookingRef}
|
|
||||||
defaultChecked={props.user.allowDynamicBooking || false}
|
|
||||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
|
||||||
<label
|
|
||||||
htmlFor="dynamic-group-booking"
|
|
||||||
className="flex items-center font-medium text-gray-700">
|
|
||||||
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
|
|
||||||
</label>
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<textarea
|
||||||
|
ref={descriptionRef}
|
||||||
|
id="about"
|
||||||
|
name="about"
|
||||||
|
placeholder={t("little_something_about")}
|
||||||
|
rows={3}
|
||||||
|
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>
|
<div>
|
||||||
<div>
|
<div className="mt-1 flex">
|
||||||
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
<Avatar
|
||||||
{t("single_theme")}
|
alt={user.name || ""}
|
||||||
</label>
|
className="relative h-10 w-10 rounded-full"
|
||||||
<div className="my-1">
|
gravatarFallbackMd5={user.emailMd5}
|
||||||
<Select
|
imageSrc={imageSrc}
|
||||||
id="theme"
|
/>
|
||||||
isDisabled={!selectedTheme}
|
<input
|
||||||
defaultValue={selectedTheme || themeOptions[0]}
|
ref={avatarRef}
|
||||||
value={selectedTheme || themeOptions[0]}
|
type="hidden"
|
||||||
onChange={(v) => v && setSelectedTheme(v)}
|
name="avatar"
|
||||||
className="mt-1 block w-full rounded-sm shadow-sm sm:text-sm"
|
id="avatar"
|
||||||
options={themeOptions}
|
placeholder="URL"
|
||||||
/>
|
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={imageSrc}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center px-5">
|
||||||
|
<ImageUploader
|
||||||
|
target="avatar"
|
||||||
|
id="avatar-upload"
|
||||||
|
buttonMsg={t("change_avatar")}
|
||||||
|
handleAvatarChange={(newAvatar) => {
|
||||||
|
avatarRef.current.value = newAvatar;
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value"
|
||||||
|
)?.set;
|
||||||
|
nativeInputValueSetter?.call(avatarRef.current, newAvatar);
|
||||||
|
const ev2 = new Event("input", { bubbles: true });
|
||||||
|
avatarRef.current.dispatchEvent(ev2);
|
||||||
|
updateProfileHandler(ev2 as unknown as FormEvent<HTMLFormElement>);
|
||||||
|
setImageSrc(newAvatar);
|
||||||
|
}}
|
||||||
|
imageSrc={imageSrc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr className="mt-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="language" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("language")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Select
|
||||||
|
id="languageSelect"
|
||||||
|
value={selectedLanguage || props.localeProp}
|
||||||
|
onChange={(v) => v && setSelectedLanguage(v)}
|
||||||
|
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
||||||
|
options={localeOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("timezone")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<TimezoneSelect
|
||||||
|
id="timeZone"
|
||||||
|
value={selectedTimeZone}
|
||||||
|
onChange={(v) => v && setSelectedTimeZone(v)}
|
||||||
|
className="mt-1 block w-full rounded-sm shadow-sm sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="timeFormat" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("time_format")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Select
|
||||||
|
id="timeFormatSelect"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="weekStart" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("first_day_of_week")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Select
|
||||||
|
id="weekStart"
|
||||||
|
value={selectedWeekStartDay}
|
||||||
|
onChange={(v) => v && setSelectedWeekStartDay(v)}
|
||||||
|
className="mt-1 block w-full rounded-sm capitalize shadow-sm sm:text-sm"
|
||||||
|
options={[
|
||||||
|
{ value: "Sunday", label: nameOfDay(props.localeProp, 0) },
|
||||||
|
{ value: "Monday", label: nameOfDay(props.localeProp, 1) },
|
||||||
|
{ value: "Tuesday", label: nameOfDay(props.localeProp, 2) },
|
||||||
|
{ value: "Wednesday", label: nameOfDay(props.localeProp, 3) },
|
||||||
|
{ value: "Thursday", label: nameOfDay(props.localeProp, 4) },
|
||||||
|
{ value: "Friday", label: nameOfDay(props.localeProp, 5) },
|
||||||
|
{ value: "Saturday", label: nameOfDay(props.localeProp, 6) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-8 flex items-start">
|
<div className="relative mt-8 flex items-start">
|
||||||
<div className="flex h-5 items-center">
|
<div className="flex h-5 items-center">
|
||||||
<input
|
<input
|
||||||
id="theme-adjust-os"
|
id="dynamic-group-booking"
|
||||||
name="theme-adjust-os"
|
name="dynamic-group-booking"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
ref={allowDynamicGroupBookingRef}
|
||||||
checked={!selectedTheme}
|
defaultChecked={props.user.allowDynamicBooking || false}
|
||||||
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||||
<label htmlFor="theme-adjust-os" className="font-medium text-gray-700">
|
<label
|
||||||
{t("automatically_adjust_theme")}
|
htmlFor="dynamic-group-booking"
|
||||||
|
className="flex items-center font-medium text-gray-700">
|
||||||
|
{t("allow_dynamic_booking")} <InfoBadge content={t("allow_dynamic_booking_tooltip")} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
<label htmlFor="theme" className="block text-sm font-medium text-gray-700">
|
||||||
<div className="mb-2 sm:w-1/2">
|
{t("single_theme")}
|
||||||
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("light_brand_color")}
|
|
||||||
</label>
|
</label>
|
||||||
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
|
<div className="my-1">
|
||||||
</div>
|
<Select
|
||||||
<div className="mb-2 sm:w-1/2">
|
id="theme"
|
||||||
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
|
isDisabled={!selectedTheme}
|
||||||
{t("dark_brand_color")}
|
defaultValue={selectedTheme || themeOptions[0]}
|
||||||
</label>
|
value={selectedTheme || themeOptions[0]}
|
||||||
<ColorPicker defaultValue={props.user.darkBrandColor} onChange={setDarkBrandColor} />
|
onChange={(v) => v && setSelectedTheme(v)}
|
||||||
</div>
|
className="mt-1 block w-full rounded-sm shadow-sm sm:text-sm"
|
||||||
</div>
|
options={themeOptions}
|
||||||
<div>
|
/>
|
||||||
<div className="relative flex items-start">
|
|
||||||
<div className="flex h-5 items-center">
|
|
||||||
<HideBrandingInput user={props.user} hideBrandingRef={hideBrandingRef} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
<div className="relative mt-8 flex items-start">
|
||||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
<div className="flex h-5 items-center">
|
||||||
{t("disable_cal_branding")}{" "}
|
<input
|
||||||
{props.user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
|
id="theme-adjust-os"
|
||||||
|
name="theme-adjust-os"
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(e) => setSelectedTheme(e.target.checked ? undefined : themeOptions[0])}
|
||||||
|
checked={!selectedTheme}
|
||||||
|
className="h-4 w-4 rounded-sm border-gray-300 text-neutral-900 "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||||
|
<label htmlFor="theme-adjust-os" className="font-medium text-gray-700">
|
||||||
|
{t("automatically_adjust_theme")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
|
||||||
|
<div className="mb-2 sm:w-1/2">
|
||||||
|
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("light_brand_color")}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
<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={user.darkBrandColor} onChange={setDarkBrandColor} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<h3 className="text-md mt-7 font-bold leading-6 text-red-700">{t("danger_zone")}</h3>
|
<div className="relative flex items-start">
|
||||||
<div>
|
<div className="flex h-5 items-center">
|
||||||
<div className="relative flex items-start">
|
<HideBrandingInput user={user} hideBrandingRef={hideBrandingRef} />
|
||||||
<Dialog>
|
</div>
|
||||||
<DialogTrigger asChild>
|
<div className="text-sm ltr:ml-3 rtl:mr-3">
|
||||||
<Button
|
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||||
type="button"
|
{t("disable_cal_branding")}{" "}
|
||||||
color="warn"
|
{user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
|
||||||
StartIcon={TrashIcon}
|
</label>
|
||||||
className="border-2 border-red-700 text-red-700"
|
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||||
data-testid="delete-account">
|
</div>
|
||||||
{t("delete_account")}
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
</DialogTrigger>
|
<h3 className="text-md mt-7 font-bold leading-6 text-red-700">{t("danger_zone")}</h3>
|
||||||
<ConfirmationDialogContent
|
<div>
|
||||||
variety="danger"
|
<div className="relative flex items-start">
|
||||||
title={t("delete_account")}
|
<Dialog>
|
||||||
confirmBtn={
|
<DialogTrigger asChild>
|
||||||
<Button color="warn" data-testid="delete-account-confirm">
|
<Button
|
||||||
{t("confirm_delete_account")}
|
type="button"
|
||||||
|
color="warn"
|
||||||
|
StartIcon={TrashIcon}
|
||||||
|
className="border-2 border-red-700 text-red-700"
|
||||||
|
data-testid="delete-account">
|
||||||
|
{t("delete_account")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</DialogTrigger>
|
||||||
onConfirm={() => deleteAccount()}>
|
<ConfirmationDialogContent
|
||||||
{t("delete_account_confirmation_message")}
|
variety="danger"
|
||||||
</ConfirmationDialogContent>
|
title={t("delete_account")}
|
||||||
</Dialog>
|
confirmBtn={
|
||||||
|
<Button color="warn" data-testid="delete-account-confirm">
|
||||||
|
{t("confirm_delete_account")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onConfirm={() => deleteAccount()}>
|
||||||
|
{t("delete_account_confirmation_message")}
|
||||||
|
</ConfirmationDialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr className="mt-8" />
|
||||||
|
<div className="flex justify-end py-4">
|
||||||
|
<Button type="submit">{t("save")}</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-8" />
|
</form>
|
||||||
<div className="flex justify-end py-4">
|
</>
|
||||||
<Button type="submit">{t("save")}</Button>
|
|
||||||
</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");
|
).toContainText("Disconnect");
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await user.delete();
|
await users.deleteAll();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -67,7 +67,7 @@ test.describe("Stripe integration", () => {
|
||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await user.delete();
|
await users.deleteAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
todo("Pending payment booking should not be confirmed by default");
|
todo("Pending payment booking should not be confirmed by default");
|
||||||
|
|
|
@ -184,9 +184,9 @@
|
||||||
"getting_started": "Getting Started",
|
"getting_started": "Getting Started",
|
||||||
"15min_meeting": "15 Min Meeting",
|
"15min_meeting": "15 Min Meeting",
|
||||||
"30min_meeting": "30 Min Meeting",
|
"30min_meeting": "30 Min Meeting",
|
||||||
"secret":"Secret",
|
"secret": "Secret",
|
||||||
"leave_blank_to_remove_secret":"Leave blank to remove secret",
|
"leave_blank_to_remove_secret": "Leave blank to remove secret",
|
||||||
"webhook_secret_key_description":"Ensure your server is only receiving the expected Cal.com requests for security reasons",
|
"webhook_secret_key_description": "Ensure your server is only receiving the expected Cal.com requests for security reasons",
|
||||||
"secret_meeting": "Secret Meeting",
|
"secret_meeting": "Secret Meeting",
|
||||||
"login_instead": "Login instead",
|
"login_instead": "Login instead",
|
||||||
"already_have_an_account": "Already have an account?",
|
"already_have_an_account": "Already have an account?",
|
||||||
|
@ -398,7 +398,7 @@
|
||||||
"change_password": "Change Password",
|
"change_password": "Change Password",
|
||||||
"change_secret": "Change Secret",
|
"change_secret": "Change Secret",
|
||||||
"new_password_matches_old_password": "New password matches your old password. Please choose a different password.",
|
"new_password_matches_old_password": "New password matches your old password. Please choose a different password.",
|
||||||
"forgotten_secret_description":"If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated",
|
"forgotten_secret_description": "If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated",
|
||||||
"current_incorrect_password": "Current password is incorrect",
|
"current_incorrect_password": "Current password is incorrect",
|
||||||
"incorrect_password": "Password is incorrect.",
|
"incorrect_password": "Password is incorrect.",
|
||||||
"1_on_1": "1-on-1",
|
"1_on_1": "1-on-1",
|
||||||
|
@ -896,23 +896,32 @@
|
||||||
"event_location": "Event's location",
|
"event_location": "Event's location",
|
||||||
"reschedule_optional": "Reason for rescheduling (optional)",
|
"reschedule_optional": "Reason for rescheduling (optional)",
|
||||||
"reschedule_placeholder": "Let others know why you need to reschedule",
|
"reschedule_placeholder": "Let others know why you need to reschedule",
|
||||||
"event_cancelled":"This event is cancelled",
|
"event_cancelled": "This event is cancelled",
|
||||||
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.",
|
"emailed_information_about_cancelled_event": "We emailed you and the other attendees to let them know.",
|
||||||
"this_input_will_shown_booking_this_event": "This input will be shown when booking this event",
|
"this_input_will_shown_booking_this_event": "This input will be shown when booking this event",
|
||||||
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
|
"meeting_url_in_conformation_email": "Meeting url is in the confirmation email",
|
||||||
"url_start_with_https": "URL needs to start with http:// or https://",
|
"url_start_with_https": "URL needs to start with http:// or https://",
|
||||||
"number_provided": "Phone number will be provided",
|
"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",
|
"trial_expired": "Your trial has expired",
|
||||||
"remove_app": "Remove App",
|
"remove_app": "Remove App",
|
||||||
"yes_remove_app": "Yes, remove app",
|
"yes_remove_app": "Yes, remove app",
|
||||||
"are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?",
|
"are_you_sure_you_want_to_remove_this_app": "Are you sure you want to remove this app?",
|
||||||
"web_conference": "Web conference",
|
"web_conference": "Web conference",
|
||||||
"requires_confirmation": "Requires confirmation",
|
"requires_confirmation": "Requires confirmation",
|
||||||
"set_whereby_link":"Set Whereby link",
|
"set_whereby_link": "Set Whereby link",
|
||||||
"invalid_whereby_link": "Please enter a valid Whereby Link",
|
"invalid_whereby_link": "Please enter a valid Whereby Link",
|
||||||
"set_around_link":"Set Around.Co link",
|
"set_around_link": "Set Around.Co link",
|
||||||
"invalid_around_link": "Please enter a valid Around Link",
|
"invalid_around_link": "Please enter a valid Around Link",
|
||||||
"set_riverside_link":"Set Riverside link",
|
"set_riverside_link": "Set Riverside link",
|
||||||
"invalid_riverside_link": "Please enter a valid Riverside Link",
|
"invalid_riverside_link": "Please enter a valid Riverside Link",
|
||||||
"add_exchange2013": "Connect Exchange 2013 Server",
|
"add_exchange2013": "Connect Exchange 2013 Server",
|
||||||
"add_exchange2016": "Connect Exchange 2016 Server",
|
"add_exchange2016": "Connect Exchange 2016 Server",
|
||||||
|
|
|
@ -6,14 +6,13 @@ import { z } from "zod";
|
||||||
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { checkPremiumUsername } from "@calcom/ee/lib/core/checkPremiumUsername";
|
|
||||||
import { sendFeedbackEmail } from "@calcom/emails";
|
import { sendFeedbackEmail } from "@calcom/emails";
|
||||||
import { sendCancelledEmails } from "@calcom/emails";
|
import { sendCancelledEmails } from "@calcom/emails";
|
||||||
import { parseRecurringEvent, isPrismaObjOrUndefined } from "@calcom/lib";
|
import { parseRecurringEvent, isPrismaObjOrUndefined } from "@calcom/lib";
|
||||||
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
import { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
||||||
import { closePayments } from "@ee/lib/stripe/server";
|
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 jackson from "@lib/jackson";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { isTeamOwner } from "@lib/queries/teams";
|
import { isTeamOwner } from "@lib/queries/teams";
|
||||||
|
@ -41,9 +40,6 @@ import { resizeBase64Image } from "../lib/resizeBase64Image";
|
||||||
import { viewerTeamsRouter } from "./viewer/teams";
|
import { viewerTeamsRouter } from "./viewer/teams";
|
||||||
import { webhookRouter } from "./viewer/webhook";
|
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
|
// things that unauthenticated users can query about themselves
|
||||||
const publicViewerRouter = createRouter()
|
const publicViewerRouter = createRouter()
|
||||||
.query("session", {
|
.query("session", {
|
||||||
|
@ -698,7 +694,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
if (username !== user.username) {
|
if (username !== user.username) {
|
||||||
data.username = username;
|
data.username = username;
|
||||||
const response = await checkUsername(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 });
|
throw new TRPCError({ code: "BAD_REQUEST", message: response.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export { default as add } from "./add";
|
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";
|
||||||
// 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";
|
||||||
|
|
|
@ -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 type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
|
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "GET" && req.session && req.session.user.id) {
|
if (req.method === "GET" && req.session && req.session.user.id) {
|
||||||
const userId = req.session.user.id;
|
const userId = req.session.user.id;
|
||||||
try {
|
try {
|
||||||
const user = await prisma?.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
select: {
|
select: {
|
||||||
metadata: true,
|
metadata: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
export type ResponseUsernameApi = {
|
interface ResponseUsernameApi {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
|
export async function checkPremiumUsername(_username: string): Promise<ResponseUsernameApi> {
|
||||||
const username = slugify(_username);
|
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"
|
new URL(WEBAPP_URL).hostname.endsWith(".cal.dev") || process.env.NODE_ENV !== "production"
|
||||||
? `https://console.cal.dev`
|
? `https://console.cal.dev`
|
||||||
: `https://console.cal.com`;
|
: `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 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 IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||||
export const TRIAL_LIMIT_DAYS = 14;
|
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(),
|
proPaidForByTeamId: z.number().optional(),
|
||||||
stripeCustomerId: z.string().optional(),
|
stripeCustomerId: z.string().optional(),
|
||||||
vitalSettings: vitalSettingsUpdateSchema.optional(),
|
vitalSettings: vitalSettingsUpdateSchema.optional(),
|
||||||
|
isPremium: z.boolean().optional(),
|
||||||
|
intentUsername: z.string().optional(),
|
||||||
})
|
})
|
||||||
.nullable();
|
.nullable();
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
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 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 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 {
|
export function getPerSeatProPlanPrice(): string {
|
||||||
return PRO_PLAN_PRICE;
|
return PRO_PLAN_PRICE;
|
||||||
|
@ -12,10 +19,18 @@ export function getProPlanPrice(): string {
|
||||||
return PRO_PLAN_PRICE;
|
return PRO_PLAN_PRICE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProPlanProduct(): string {
|
|
||||||
return PRO_PLAN_PRODUCT;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFreePlanPrice(): string {
|
export function getFreePlanPrice(): string {
|
||||||
return FREE_PLAN_PRICE;
|
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_FREE_PLAN_PRICE",
|
||||||
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
|
"$NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE",
|
||||||
"$NEXT_PUBLIC_STRIPE_PRO_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_STRIPE_PUBLIC_KEY",
|
||||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||||
|
@ -56,7 +58,9 @@
|
||||||
"$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_PRO_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_STRIPE_PUBLIC_KEY",
|
||||||
"$NEXT_PUBLIC_WEBAPP_URL",
|
"$NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"$NEXT_PUBLIC_WEBSITE_URL"
|
"$NEXT_PUBLIC_WEBSITE_URL"
|
||||||
|
|
|
@ -17843,4 +17843,4 @@ zwitch@^1.0.0:
|
||||||
zwitch@^2.0.0:
|
zwitch@^2.0.0:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1"
|
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.2.tgz#91f8d0e901ffa3d66599756dde7f57b17c95dce1"
|
||||||
integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==
|
integrity sha512-JZxotl7SxAJH0j7dN4pxsTV6ZLXoLdGME+PsjkL/DaBrVryK9kTGq06GfKrwcSOqypP+fdXGoCHE36b99fWVoA==
|
Loading…
Reference in New Issue
Block a user