Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
185 changes: 185 additions & 0 deletions apps/web/modules/settings/billing/components/BillingCredits.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/* eslint-disable prettier/prettier */

/* eslint-disable react/jsx-no-undef */
"use client";

import { useSession } from "next-auth/react";
Expand All @@ -22,6 +25,9 @@ import { showToast } from "@calcom/ui/components/toast";

import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";

/* eslint-disable prettier/prettier */
/* eslint-disable react/jsx-no-undef */

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

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

const {
register,
handleSubmit,
Expand Down Expand Up @@ -91,6 +99,11 @@ export default function BillingCredits() {
{ enabled: shouldRender }
);

const { data: autoRechargeData } = trpc.viewer.credits.getAutoRechargeSettings.useQuery(
{ teamId },
{ enabled: shouldRender }
);

if (!shouldRender) return null;

const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
Expand All @@ -104,6 +117,17 @@ export default function BillingCredits() {
},
});

const updateAutoRechargeMutation = trpc.viewer.credits.updateAutoRechargeSettings.useMutation({
onSuccess: () => {
showToast(t("auto_recharge_settings_updated"), "success");
setShowAutoRechargeModal(false);
utils.viewer.credits.getAutoRechargeSettings.invalidate({ teamId });
},
onError: () => {
showToast(t("auto_recharge_settings_failed"), "error");
},
});

const handleDownload = async () => {
setIsDownloading(true);
try {
Expand Down Expand Up @@ -132,11 +156,22 @@ export default function BillingCredits() {
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
};

const handleAutoRechargeSubmit = (data: { enabled: boolean; threshold: number; amount: number }) => {
updateAutoRechargeMutation.mutate({
teamId,
enabled: data.enabled,
threshold: data.threshold,
amount: data.amount,
});
};

const teamCreditsPercentageUsed =
creditsData.credits.totalMonthlyCredits > 0
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
: 0;

const autoRechargeSettings = autoRechargeData?.settings;

return (
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
Expand Down Expand Up @@ -186,6 +221,38 @@ export default function BillingCredits() {
{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>
Expand Down Expand Up @@ -243,6 +310,124 @@ export default function BillingCredits() {
</div>
</div>
</div>

{/* Auto-recharge modal */}
{showAutoRechargeModal && (
<AutoRechargeModal
defaultValues={autoRechargeSettings}
onSubmit={handleAutoRechargeSubmit}
onCancel={() => setShowAutoRechargeModal(false)}
isLoading={updateAutoRechargeMutation.isLoading}
/>
)}
</div>
);
}

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>
<DialogTitle>{t("auto_recharge_settings")}</DialogTitle>
<DialogDescription>{t("auto_recharge_description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4 py-4">
<div className="flex items-center">
<Switch
{...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