Skip to content

Commit cfeb18d

Browse files
sean-brydonCarinaWollidevin-ai-integration[bot]
authored
feat: Billing page redesign plus credits (calcom#23908)
* manage billing section * wip billing credits * WIP * WIP * Download expense log * credit worth * skeleton fixes * add org tip * add teams tip * restore service * type check * type check * fix types * additional credits * fix progress bar * add dashed prop * match new designs * hide area with no monthly credits * fix i18n * show current balance label * Update apps/web/modules/settings/billing/billing-view.tsx Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * spacing + monthly credits not showing additional * Remove additional credits from monthly calculations * feat: replace add members redirect with invite modal in billing settings - Replace Button href with onClick handler to open MemberInvitationModal - Add MemberInvitationModalWithoutMembers import and state management - Maintain existing team/org context support - Follow established modal usage patterns from other components - Fix lint error by using undefined instead of empty arrow function Co-Authored-By: sean@cal.com <Sean@brydon.io> * Remove redudant vars from method * fix type check --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent b407be9 commit cfeb18d

7 files changed

Lines changed: 348 additions & 183 deletions

File tree

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const Page = async () => {
2626
<SettingsHeader
2727
title={t("billing")}
2828
description={t("manage_billing_description")}
29-
borderInShellHeader={true}>
29+
borderInShellHeader={false}>
3030
<BillingView />
3131
</SettingsHeader>
3232
);

apps/web/modules/settings/billing/billing-view.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,21 +77,26 @@ const BillingView = () => {
7777

7878
return (
7979
<>
80-
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
81-
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
82-
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
80+
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
81+
<div className="bg-default border-muted flex rounded-[10px] border px-5 py-4">
82+
<div className="flex w-full flex-col gap-1">
83+
<h3 className="text-emphasis text-sm font-semibold leading-none">{t("manage_billing")}</h3>
84+
<p className="text-subtle text-sm font-medium leading-tight">
85+
{t("view_and_manage_billing_details")}
86+
</p>
87+
</div>
88+
<Button color="primary" href={billingHref} target="_blank" size="sm" EndIcon="external-link">
8389
{t("billing_portal")}
8490
</Button>
85-
</CtaRow>
86-
</div>
87-
<BillingCredits />
88-
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
89-
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
90-
<Button color="secondary" onClick={onContactSupportClick}>
91+
</div>
92+
<div className="flex items-center justify-between px-4 py-5">
93+
<p className="text-subtle text-sm font-medium leading-tight">{t("need_help")}</p>
94+
<Button color="secondary" size="sm" onClick={onContactSupportClick}>
9195
{t("contact_support")}
9296
</Button>
93-
</CtaRow>
97+
</div>
9498
</div>
99+
<BillingCredits />
95100
</>
96101
);
97102
};

apps/web/modules/settings/billing/components/BillingCredits.tsx

Lines changed: 174 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ import { useForm } from "react-hook-form";
88

99
import dayjs from "@calcom/dayjs";
1010
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
11+
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
1112
import ServerTrans from "@calcom/lib/components/ServerTrans";
1213
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
1314
import { downloadAsCsv } from "@calcom/lib/csvUtils";
1415
import { useLocale } from "@calcom/lib/hooks/useLocale";
1516
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
1617
import { trpc } from "@calcom/trpc/react";
18+
import classNames from "@calcom/ui/classNames";
1719
import { Button } from "@calcom/ui/components/button";
1820
import { Select } from "@calcom/ui/components/form";
1921
import { TextField, Label, InputError } from "@calcom/ui/components/form";
22+
import { Icon } from "@calcom/ui/components/icon";
2023
import { ProgressBar } from "@calcom/ui/components/progress-bar";
2124
import { showToast } from "@calcom/ui/components/toast";
25+
import { Tooltip } from "@calcom/ui/components/tooltip";
2226

2327
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
2428

@@ -29,6 +33,39 @@ type MonthOption = {
2933
endDate: string;
3034
};
3135

36+
type CreditRowProps = {
37+
label: string;
38+
value: number;
39+
isBold?: boolean;
40+
underline?: "dashed" | "solid";
41+
className?: string;
42+
};
43+
44+
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
45+
const numberFormatter = new Intl.NumberFormat();
46+
return (
47+
<div
48+
className={classNames(
49+
`my-1 flex justify-between`,
50+
underline === "dashed"
51+
? "border-subtle border-b border-dashed"
52+
: underline === "solid"
53+
? "border-subtle border-b border-solid"
54+
: "mt-1",
55+
className
56+
)}>
57+
<span
58+
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
59+
{label}
60+
</span>
61+
<span
62+
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
63+
{numberFormatter.format(value)}
64+
</span>
65+
</div>
66+
);
67+
};
68+
3269
const getMonthOptions = (): MonthOption[] => {
3370
const options: MonthOption[] = [];
3471
const minDate = dayjs.utc("2025-05-01");
@@ -60,6 +97,7 @@ export default function BillingCredits() {
6097
const monthOptions = useMemo(() => getMonthOptions(), []);
6198
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
6299
const [isDownloading, setIsDownloading] = useState(false);
100+
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
63101
const utils = trpc.useUtils();
64102

65103
const {
@@ -71,6 +109,7 @@ export default function BillingCredits() {
71109

72110
const params = useParamsWithFallback();
73111
const orgId = session.data?.user?.org?.id;
112+
const orgSlug = session.data?.user?.org?.slug;
74113

75114
const parsedTeamId = Number(params.id);
76115
const teamId: number | undefined = Number.isFinite(parsedTeamId)
@@ -132,117 +171,153 @@ export default function BillingCredits() {
132171
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
133172
};
134173

135-
const teamCreditsPercentageUsed =
136-
creditsData.credits.totalMonthlyCredits > 0
137-
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
138-
: 0;
174+
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
175+
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
176+
177+
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
178+
const numberFormatter = new Intl.NumberFormat();
139179

140180
return (
141-
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
142-
<div>
143-
<h2 className="text-base font-semibold">{t("credits")}</h2>
144-
<ServerTrans
145-
t={t}
146-
i18nKey="view_and_manage_credits_description"
147-
components={[
148-
<Link
149-
key="Credit System"
150-
className="underline underline-offset-2"
151-
target="_blank"
152-
href="https://cal.com/help/billing-and-usage/messaging-credits">
153-
Learn more
154-
</Link>,
155-
]}
156-
/>
157-
<div className="-mx-6 mt-6">
158-
<hr className="border-subtle" />
181+
<>
182+
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
183+
<div className="flex flex-col gap-1 px-4 py-5">
184+
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
185+
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
159186
</div>
160-
<div className="mt-6">
161-
{creditsData.credits.totalMonthlyCredits > 0 ? (
162-
<div className="mb-4">
163-
<Label>{t("monthly_credits")}</Label>
164-
<ProgressBar
165-
color="green"
166-
percentageValue={teamCreditsPercentageUsed}
167-
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
168-
/>
169-
<div className="text-subtle">
170-
<div>
171-
{t("total_credits", {
172-
totalCredits: creditsData.credits.totalMonthlyCredits,
173-
})}
187+
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
188+
<div className="w-full">
189+
{totalCredits > 0 ? (
190+
<>
191+
<div className="mb-4">
192+
<CreditRow
193+
label={t("monthly_credits")}
194+
value={totalCredits}
195+
isBold={true}
196+
underline="dashed"
197+
/>
198+
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
199+
<CreditRow
200+
label={t("total_credits_remaining")}
201+
value={creditsData.credits.totalRemainingMonthlyCredits}
202+
/>
203+
<div className="mt-4">
204+
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
205+
</div>
206+
{/*750 credits per tip*/}
207+
<div className="mt-4 flex flex-1 items-center justify-between">
208+
<p className="text-subtle text-sm font-medium leading-tight">
209+
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
210+
</p>
211+
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
212+
{t("add_members_no_ellipsis")}
213+
</Button>
214+
</div>
174215
</div>
175-
<div>
176-
{t("remaining_credits", {
177-
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
178-
})}
216+
<div className="-mx-5 mt-5">
217+
<hr className="border-subtle" />
218+
</div>
219+
</>
220+
) : (
221+
<></>
222+
)}
223+
224+
{/*Auto Top-Up goes here when we have it*/}
225+
{/*<div className="-mx-5 mt-5">
226+
<hr className="border-subtle" />
227+
</div>*/}
228+
{/*Additional Credits*/}
229+
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
230+
<div className="-mb-1 mr-auto w-full">
231+
<div className="flex justify-between">
232+
<Label>{t("additional_credits")}</Label>
233+
<div className="mb-2 flex items-center gap-1">
234+
<p className="text-sm font-semibold leading-none">
235+
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
236+
{numberFormatter.format(creditsData.credits.additionalCredits)}
237+
</p>
238+
<Tooltip content={t("view_additional_credits_expense_tip")}>
239+
<Icon name="info" className="text-emphasis h-3 w-3" />
240+
</Tooltip>
241+
</div>
242+
</div>
243+
<div className="flex w-full items-center gap-2">
244+
<TextField
245+
required
246+
type="number"
247+
{...register("quantity", {
248+
required: t("error_required_field"),
249+
min: { value: 50, message: t("minimum_of_credits_required") },
250+
valueAsNumber: true,
251+
})}
252+
label=""
253+
containerClassName="w-full -mt-1"
254+
size="sm"
255+
onChange={(e) => setValue("quantity", Number(e.target.value))}
256+
min={50}
257+
addOnSuffix={<>{t("credits")}</>}
258+
/>
259+
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
260+
{t("buy")}
261+
</Button>
179262
</div>
180-
</div>
181-
</div>
182-
) : (
183-
<></>
184-
)}
185-
<Label>
186-
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
187-
</Label>
188-
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
189-
<div className="-mx-6 mb-6 mt-6">
190-
<hr className="border-subtle mb-3 mt-3" />
191-
</div>
192-
<form onSubmit={handleSubmit(onSubmit)} className="flex">
193-
<div className="-mb-1 mr-auto">
194-
<Label>{t("buy_additional_credits")}</Label>
195-
<div className="flex flex-col">
196-
<TextField
197-
required
198-
type="number"
199-
{...register("quantity", {
200-
required: t("error_required_field"),
201-
min: { value: 50, message: t("minimum_of_credits_required") },
202-
valueAsNumber: true,
203-
})}
204-
label=""
205-
containerClassName="w-60"
206-
onChange={(e) => setValue("quantity", Number(e.target.value))}
207-
min={50}
208-
addOnSuffix={<>{t("credits")}</>}
209-
/>
210263
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
211264
</div>
265+
</form>
266+
<div className="-mx-5 mt-5">
267+
<hr className="border-subtle" />
212268
</div>
213-
<div className="mt-auto">
214-
<Button
215-
color="primary"
216-
target="_blank"
217-
EndIcon="external-link"
218-
type="submit"
219-
data-testid="buy-credits">
220-
{t("buy_credits")}
221-
</Button>
222-
</div>
223-
</form>
224-
<div className="-mx-6 mb-6 mt-6">
225-
<hr className="border-subtle mb-3 mt-3" />
226-
</div>
227-
<div className="flex">
228-
<div className="mr-auto">
229-
<Label className="mb-4">{t("download_expense_log")}</Label>
230-
<div className="mt-2 flex flex-col">
231-
<Select
232-
options={monthOptions}
233-
value={selectedMonth}
234-
onChange={(option) => option && setSelectedMonth(option)}
235-
/>
269+
{/*Download Expense Log*/}
270+
<div className="mt-4 flex">
271+
<div className="mr-auto w-full">
272+
<Label className="mb-4">{t("download_expense_log")}</Label>
273+
<div className="mr-2 mt-1">
274+
<Select
275+
size="sm"
276+
className="w-full"
277+
innerClassNames={{
278+
control: "font-medium text-emphasis",
279+
}}
280+
options={monthOptions}
281+
value={selectedMonth}
282+
onChange={(option) => option && setSelectedMonth(option)}
283+
/>
284+
</div>
285+
</div>
286+
<div className="mt-auto">
287+
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
288+
{t("download")}
289+
</Button>
236290
</div>
237-
</div>
238-
<div className="mt-auto">
239-
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
240-
{t("download")}
241-
</Button>
242291
</div>
243292
</div>
244293
</div>
294+
{/*Credit Worth Section*/}
295+
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
296+
<ServerTrans
297+
t={t}
298+
i18nKey="credit_worth_description"
299+
components={[
300+
<Link
301+
key="Credit System"
302+
className="underline underline-offset-2"
303+
target="_blank"
304+
href="https://cal.com/help/billing-and-usage/messaging-credits">
305+
Learn more
306+
</Link>,
307+
]}
308+
/>
309+
</div>
245310
</div>
246-
</div>
311+
{teamId && (
312+
<MemberInvitationModalWithoutMembers
313+
teamId={teamId}
314+
showMemberInvitationModal={showMemberInvitationModal}
315+
hideInvitationModal={() => setShowMemberInvitationModal(false)}
316+
onSettingsOpen={() => {
317+
return;
318+
}}
319+
/>
320+
)}
321+
</>
247322
);
248323
}

0 commit comments

Comments
 (0)