Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions apps/web/modules/settings/billing/components/BillingCredits.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable prettier/prettier */
"use client";

import { useSession } from "next-auth/react";
Expand All @@ -17,15 +18,26 @@ import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@calcom/ui/components/dialog";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { Checkbox } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
// Fix Switch import
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";

import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";

/* eslint-disable prettier/prettier */

/* eslint-disable prettier/prettier */

/* eslint-disable prettier/prettier */

/* eslint-disable prettier/prettier */

type MonthOption = {
value: string;
label: string;
Expand Down Expand Up @@ -100,6 +112,8 @@ export default function BillingCredits() {
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const utils = trpc.useUtils();

const [showAutoRechargeModal, setShowAutoRechargeModal] = useState(false);

const {
register,
handleSubmit,
Expand Down Expand Up @@ -177,6 +191,8 @@ export default function BillingCredits() {
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
const numberFormatter = new Intl.NumberFormat();

const autoRechargeSettings = creditsData?.settings;

return (
<>
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
Expand Down Expand Up @@ -260,6 +276,68 @@ export default function BillingCredits() {
{t("buy")}
</Button>
</div>
</div>
</div>
) : (
<></>
)}
<Label>
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
</Label>
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>

{/* Auto-recharge section */}
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="mb-4 flex items-center justify-between">
<div>
<Label>{t("auto_recharge")}</Label>
<p className="text-subtle mt-1 text-sm">
{autoRechargeSettings?.enabled
? t("auto_recharge_enabled_description", {
threshold: autoRechargeSettings?.threshold,
amount: autoRechargeSettings?.amount,
})
: t("auto_recharge_disabled_description")}
</p>
{autoRechargeSettings?.lastAutoRechargeAt && (
<p className="text-subtle mt-1 text-sm">
{t("last_auto_recharged_at", {
date: dayjs(autoRechargeSettings.lastAutoRechargeAt).format("MMM D, YYYY HH:mm"),
})}
</p>
)}
</div>
<Button
color="secondary"
onClick={() => setShowAutoRechargeModal(true)}
data-testid="configure-auto-recharge">
{autoRechargeSettings?.enabled ? t("edit") : t("setup")}
</Button>
</div>

<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex">
<div className="-mb-1 mr-auto">
<Label>{t("buy_additional_credits")}</Label>
<div className="flex flex-col">
<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-60"
onChange={(e) => setValue("quantity", Number(e.target.value))}
min={50}
addOnSuffix={<>{t("credits")}</>}
/>
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
</div>
</form>
Expand Down Expand Up @@ -308,6 +386,17 @@ export default function BillingCredits() {
/>
</div>
</div>

{/* Auto-recharge modal */}
{showAutoRechargeModal && (
<AutoRechargeModal
defaultValues={autoRechargeSettings}
onSubmit={handleAutoRechargeSubmit}
onCancel={() => setShowAutoRechargeModal(false)}
isLoading={updateAutoRechargeMutation.isLoading}
/>
)}
</div>
{teamId && (
<MemberInvitationModalWithoutMembers
teamId={teamId}
Expand All @@ -321,3 +410,109 @@ export default function BillingCredits() {
</>
);
}

function AutoRechargeModal({
defaultValues,
onSubmit,
onCancel,
isLoading,
}: {
defaultValues?: {
enabled: boolean;
threshold: number;
amount: number;
};
onSubmit: (data: { enabled: boolean; threshold: number; amount: number }) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const { t } = useLocale();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<{
enabled: boolean;
threshold: number;
amount: number;
}>({
defaultValues: {
enabled: defaultValues?.enabled ?? false,
threshold: defaultValues?.threshold ?? 50,
amount: defaultValues?.amount ?? 100,
},
});

const enabled = watch("enabled");

return (
<Dialog open onOpenChange={onCancel}>
<DialogContent>
<DialogHeader title={t("auto_recharge_settings")} />
<p className="text-subtle mb-4">{t("auto_recharge_description")}</p>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4 py-4">
<div className="flex items-center">
<Checkbox
{...register("enabled")}
defaultChecked={defaultValues?.enabled}
id="auto-recharge-toggle"
/>
<Label className="ml-2" htmlFor="auto-recharge-toggle">
{t("enable_auto_recharge")}
</Label>
</div>

{enabled && (
<>
<div>
<Label htmlFor="threshold">{t("recharge_threshold")}</Label>
<TextField
id="threshold"
type="number"
{...register("threshold", {
required: t("error_required_field"),
min: { value: 10, message: t("minimum_threshold") },
valueAsNumber: true,
})}
placeholder="50"
/>
{errors.threshold && (
<InputError message={errors.threshold.message ?? t("invalid_input")} />
)}
<p className="text-subtle mt-1 text-sm">{t("threshold_description")}</p>
</div>

<div>
<Label htmlFor="amount">{t("recharge_amount")}</Label>
<TextField
id="amount"
type="number"
{...register("amount", {
required: t("error_required_field"),
min: { value: 50, message: t("minimum_amount") },
valueAsNumber: true,
})}
placeholder="100"
/>
{errors.amount && <InputError message={errors.amount.message ?? t("invalid_input")} />}
<p className="text-subtle mt-1 text-sm">{t("amount_description")}</p>
</div>
</>
)}
</div>

<DialogFooter>
<Button type="button" color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
<Button type="submit" loading={isLoading}>
{t("save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
116 changes: 87 additions & 29 deletions packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,45 +17,103 @@ export const CreditBalanceLimitReachedEmail = (
email: string;
t: TFunction;
};
autoRechargeEnabled?: boolean;
autoRechargeFailed?: boolean;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { team, user } = props;
const { team, user, autoRechargeEnabled, autoRechargeFailed } = props;

// Show different content based on auto-recharge status
const getContent = () => {
if (autoRechargeFailed) {
return (
<>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{user.t("hi_user_name", { name: user.name })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
{team
? user.t("auto_recharge_payment_failed", { teamName: team.name })
: user.t("auto_recharge_payment_failed_user")}
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("update_payment_method")}
href={
team ? `${WEBAPP_URL}/settings/teams/${team.id}/billing` : `${WEBAPP_URL}/settings/billing`
}
endIconName="linkIcon"
/>
</div>
</>
);
}

if (autoRechargeEnabled) {
return (
<>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{user.t("hi_user_name", { name: user.name })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
{team
? user.t("credit_limit_reached_auto_recharge", { teamName: team.name })
: user.t("credit_limit_reached_auto_recharge_user")}
</p>
</>
);
}

// Default content (no auto-recharge)
if (team) {
return (
<>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
{user.t("hi_user_name", { name: user.name })},
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
{user.t("credit_limit_reached_message", { teamName: team.name })}
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
endIconName="linkIcon"
/>
</div>
</>
);
}

if (team) {
return (
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>{user.t("hi_user_name", { name: user.name })},</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
{user.t("credit_limit_reached_message_user")}
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>{" "}
</V2BaseEmailHtml>
</div>
</>
);
}

return (
<V2BaseEmailHtml subject={user.t("action_required_user_out_of_credits")}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("credit_limit_reached_message_user")}</>
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>
</V2BaseEmailHtml>
);
};

// Determine email subject based on status
const getSubject = () => {
if (autoRechargeFailed) {
return user.t("auto_recharge_failed");
}

if (team) {
return user.t("action_required_out_of_credits", { teamName: team.name });
}

return user.t("action_required_user_out_of_credits");
};

return <V2BaseEmailHtml subject={getSubject()}>{getContent()}</V2BaseEmailHtml>;
};
Loading
Loading