fix: show personal credits even when user is on teams plan (#27511)
* show personal credits even if on team plan * show personal credits even when on teams plan * fix learn more link --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
1 parent
336295c0ab
commit
fc3815a150
2 files changed
+147
-78
No files matched your search
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -24,6 +22,7 @@ import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { MemberInvitationModalWithoutMembers } from "~/ee/teams/components/MemberInvitationModal";
|
||||
import { LearnMoreLink } from "~/event-types/components/LearnMoreLink";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -42,7 +41,13 @@ type CreditRowProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
|
||||
const CreditRow = ({
|
||||
label,
|
||||
value,
|
||||
isBold = false,
|
||||
underline,
|
||||
className = "",
|
||||
}: CreditRowProps) => {
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
return (
|
||||
<div
|
||||
@@ -54,13 +59,22 @@ const CreditRow = ({ label, value, isBold = false, underline, className = "" }:
|
||||
? "border-subtle border-b border-solid"
|
||||
: "mt-1",
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
className={classNames(
|
||||
"text-sm",
|
||||
isBold ? "font-semibold" : "font-medium leading-tight text-subtle"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
className={classNames(
|
||||
`text-sm`,
|
||||
isBold ? "font-semibold" : "font-medium leading-tight text-subtle"
|
||||
)}
|
||||
>
|
||||
{numberFormatter.format(value)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -73,7 +87,10 @@ const getMonthOptions = (): MonthOption[] => {
|
||||
|
||||
let date = dayjs.utc();
|
||||
let count = 0;
|
||||
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
|
||||
while (
|
||||
(date.isAfter(minDate) || date.isSame(minDate, "month")) &&
|
||||
count < 12
|
||||
) {
|
||||
const startDate = date.startOf("month");
|
||||
const endDate = date.endOf("month");
|
||||
options.push({
|
||||
@@ -96,9 +113,12 @@ export default function BillingCredits() {
|
||||
const session = useSession();
|
||||
const orgBranding = useOrgBranding();
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(
|
||||
monthOptions[0]
|
||||
);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] =
|
||||
useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
@@ -122,14 +142,26 @@ export default function BillingCredits() {
|
||||
const tokens = (pathname ?? "").split("/").filter(Boolean);
|
||||
const settingsIndex = tokens.indexOf("settings");
|
||||
const isOrgScopedPath =
|
||||
settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]);
|
||||
settingsIndex >= 0 &&
|
||||
["organizations", "teams"].includes(tokens[settingsIndex + 1]);
|
||||
|
||||
const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug);
|
||||
const shouldRender =
|
||||
IS_SMS_CREDITS_ENABLED &&
|
||||
!(orgId && !isOrgScopedPath && !orgBranding?.slug);
|
||||
|
||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: shouldRender }
|
||||
const { data: creditsData, isLoading } =
|
||||
trpc.viewer.credits.getAllCredits.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: shouldRender }
|
||||
);
|
||||
|
||||
const { data: teamPlanData } = trpc.viewer.teams.hasTeamPlan.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: shouldRender && !teamId,
|
||||
}
|
||||
);
|
||||
const isUserInTeam = teamPlanData?.hasTeamPlan ?? false;
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
@@ -153,7 +185,9 @@ export default function BillingCredits() {
|
||||
endDate: selectedMonth.endDate,
|
||||
});
|
||||
if (result?.csvData) {
|
||||
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
|
||||
const filename = `credit-expense-log-${selectedMonth.value
|
||||
.toLowerCase()
|
||||
.replace(" ", "-")}.csv`;
|
||||
downloadAsCsv(result.csvData, filename);
|
||||
} else {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
@@ -168,6 +202,11 @@ export default function BillingCredits() {
|
||||
if (isLoading && teamId) return <BillingCreditsSkeleton />;
|
||||
if (!creditsData) return null;
|
||||
|
||||
// For personal billing page: hide credits section if user is part of a team and has no personal credits
|
||||
if (!teamId && isUserInTeam && creditsData.credits.additionalCredits <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = (data: { quantity: number }) => {
|
||||
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
||||
};
|
||||
@@ -175,15 +214,20 @@ export default function BillingCredits() {
|
||||
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
|
||||
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
|
||||
|
||||
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
|
||||
const teamCreditsPercentageUsed =
|
||||
totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-cal-muted border-muted mt-5 rounded-xl border p-1">
|
||||
<div className="p-1 mt-5 rounded-xl border bg-cal-muted border-muted">
|
||||
<div className="flex flex-col gap-1 px-4 py-5">
|
||||
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
|
||||
<h2 className="text-base font-semibold leading-none text-default">
|
||||
{t("credits")}
|
||||
</h2>
|
||||
<p className="text-sm font-medium leading-tight text-subtle">
|
||||
{t("view_and_manage_credits")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
|
||||
<div className="w-full">
|
||||
@@ -196,20 +240,33 @@ export default function BillingCredits() {
|
||||
isBold={true}
|
||||
underline="dashed"
|
||||
/>
|
||||
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
|
||||
<CreditRow
|
||||
label={t("credits_used")}
|
||||
value={totalUsed}
|
||||
underline="solid"
|
||||
/>
|
||||
<CreditRow
|
||||
label={t("total_credits_remaining")}
|
||||
value={creditsData.credits.totalRemainingMonthlyCredits}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
|
||||
<ProgressBar
|
||||
color="green"
|
||||
percentageValue={100 - teamCreditsPercentageUsed}
|
||||
/>
|
||||
</div>
|
||||
{/*750 credits per tip*/}
|
||||
<div className="mt-4 flex flex-1 items-center justify-between">
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
|
||||
<div className="flex flex-1 justify-between items-center mt-4">
|
||||
<p className="text-sm font-medium leading-tight text-subtle">
|
||||
{orgSlug
|
||||
? t("credits_per_tip_org")
|
||||
: t("credits_per_tip_teams")}
|
||||
</p>
|
||||
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
|
||||
<Button
|
||||
onClick={() => setShowMemberInvitationModal(true)}
|
||||
size="sm"
|
||||
color="secondary"
|
||||
>
|
||||
{t("add_members_no_ellipsis")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,51 +284,76 @@ export default function BillingCredits() {
|
||||
<hr className="border-subtle" />
|
||||
</div>*/}
|
||||
{/*Additional Credits*/}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
|
||||
<div className="-mb-1 mr-auto w-full">
|
||||
<div className="flex mt-4">
|
||||
<div className="mr-auto -mb-1 w-full">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("additional_credits")}</Label>
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<div className="flex gap-1 items-center mb-2">
|
||||
<p className="text-sm font-semibold leading-none">
|
||||
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
|
||||
{numberFormatter.format(creditsData.credits.additionalCredits)}
|
||||
<span className="font-medium text-subtle">
|
||||
{t("current_balance")}
|
||||
</span>{" "}
|
||||
{numberFormatter.format(
|
||||
creditsData.credits.additionalCredits
|
||||
)}
|
||||
</p>
|
||||
<Tooltip content={t("view_additional_credits_expense_tip")}>
|
||||
<Icon name="info" className="text-emphasis h-3 w-3" />
|
||||
<Icon name="info" className="w-3 h-3 text-emphasis" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...register("quantity", {
|
||||
required: t("error_required_field"),
|
||||
min: { value: 50, message: t("minimum_of_credits_required") },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
label=""
|
||||
containerClassName="w-full -mt-1"
|
||||
size="sm"
|
||||
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
||||
min={50}
|
||||
addOnSuffix={<>{t("credits")}</>}
|
||||
{/* Users who are part of a team cannot buy personal credits */}
|
||||
{teamId || !isUserInTeam ? (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex gap-2 items-center w-full"
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...register("quantity", {
|
||||
required: t("error_required_field"),
|
||||
min: {
|
||||
value: 50,
|
||||
message: t("minimum_of_credits_required"),
|
||||
},
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
label=""
|
||||
containerClassName="w-full -mt-1"
|
||||
size="sm"
|
||||
onChange={(e) =>
|
||||
setValue("quantity", Number(e.target.value))
|
||||
}
|
||||
min={50}
|
||||
addOnSuffix={<>{t("credits")}</>}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
type="submit"
|
||||
data-testid="buy-credits"
|
||||
>
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
{errors.quantity && (
|
||||
<InputError
|
||||
message={errors.quantity.message ?? t("invalid_input")}
|
||||
/>
|
||||
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/*Download Expense Log*/}
|
||||
<div className="mt-4 flex">
|
||||
<div className="flex mt-4">
|
||||
<div className="mr-auto w-full">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mr-2 mt-1">
|
||||
<div className="mt-1 mr-2">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
@@ -285,7 +367,12 @@ export default function BillingCredits() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
loading={isDownloading}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -293,19 +380,11 @@ export default function BillingCredits() {
|
||||
</div>
|
||||
</div>
|
||||
{/*Credit Worth Section*/}
|
||||
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
|
||||
<ServerTrans
|
||||
<div className="px-5 py-4 text-sm font-medium leading-tight text-subtle">
|
||||
<LearnMoreLink
|
||||
t={t}
|
||||
i18nKey="credit_worth_description"
|
||||
components={[
|
||||
<Link
|
||||
key="Credit System"
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
||||
Learn more
|
||||
</Link>,
|
||||
]}
|
||||
href="https://cal.com/help/billing-and-usage/messaging-credits"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TGetAllCreditsSchema } from "./getAllCredits.schema";
|
||||
|
||||
type GetAllCreditsOptions = {
|
||||
@@ -34,18 +31,11 @@ export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions)
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
//if user is part of team, don't return any credits if teamId is not given
|
||||
const memberships = await MembershipRepository.findAllAcceptedPublishedTeamMemberships(ctx.user.id);
|
||||
|
||||
if (memberships && memberships.length > 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
||||
|
||||
const creditService = new CreditService();
|
||||
|
||||
const credits = await creditService.getAllCredits({ userId: ctx.user.id, teamId });
|
||||
|
||||
return { credits };
|
||||
};
|
||||
Reference in new issue
Block a user