Files
calendar/apps/web/modules/ee/deployment/components/CreateLicenseKeyForm.tsx
T
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81e4241f85 refactor: apply biome formatting to apps/web (#27692)
* refactor: apply biome formatting to apps/web (batch 1)

Formats apps/web non-modules directories:
- apps/web/app
- apps/web/lib
- apps/web/pages
- apps/web/styles
- apps/web/server
- apps/web/test
- Root-level .ts and .mjs files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 2)

Formats smaller apps/web/modules directories:
- onboarding, data-table, users, apps, auth
- integration-attribute-sync, shell, availability
- feature-flags, team, webhooks, getting-started
- feature-opt-in, troubleshooter, schedules, notifications
- signup-view.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 3)

Formats medium apps/web/modules directories:
- bookings
- event-types
- insights
- embed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules/ee (batch 4)

Formats apps/web/modules/ee directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web (batch 5)

Formats remaining apps/web directories:
- apps/web/modules/settings
- apps/web/modules/booking-audit
- apps/web/playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/components (batch 6)

Formats remaining apps/web/components files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Formats newly added/modified files from main merge:
- WorkflowStepContainer.tsx (resolved merge conflicts)
- Newly moved files (blocklist, form-builder, schedules, etc.)
- Other files modified in main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-10 23:25:24 +05:30

589 lines
19 KiB
TypeScript

"use client";
import type { SessionContextValue } from "next-auth/react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useCallback, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { showToast } from "@calcom/ui/components/toast";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { Alert, AlertDescription } from "@coss/ui/components/alert";
import { Button } from "@coss/ui/components/button";
import {
Card,
CardFrame,
CardFrameDescription,
CardFrameFooter,
CardFrameHeader,
CardFrameTitle,
CardPanel,
} from "@coss/ui/components/card";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "@coss/ui/components/dialog";
import { Field, FieldLabel } from "@coss/ui/components/field";
import { Form } from "@coss/ui/components/form";
import { Input } from "@coss/ui/components/input";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@coss/ui/components/select";
import { ToggleGroup, Toggle } from "@coss/ui/components/toggle-group";
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [value]);
return (
<Button type="button" variant="ghost" size="icon-sm" onClick={handleCopy}>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
</Button>
);
}
export const CreateANewLicenseKeyForm = () => {
const session = useSession();
if (session.data?.user.role !== "ADMIN") {
return null;
}
// @ts-expect-error session can't be null due to the early return
return <CreateANewLicenseKeyFormChild session={session} />;
};
enum BillingType {
PER_BOOKING = "PER_BOOKING",
PER_USER = "PER_USER",
}
enum BillingPeriod {
MONTHLY = "MONTHLY",
ANNUALLY = "ANNUALLY",
}
interface LicenseFormValues {
billingType: BillingType;
entityCount: number;
entityPrice: number;
billingPeriod: BillingPeriod;
overages: number;
billingEmail: string;
}
interface CouponFormValues {
couponName: string;
billingEmail: string;
code: string;
discountType: "percent" | "fixed";
discountAmount: number;
duration: "once" | "repeating" | "forever";
durationInMonths: number;
}
const CreateANewLicenseKeyFormChild = ({ session }: { session: Ensure<SessionContextValue, "data"> }) => {
const { t } = useLocale();
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState<string | null>(null);
const [couponCode, setCouponCode] = useState<string | null>(null);
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
const licenseForm = useForm<LicenseFormValues>({
defaultValues: {
billingType: BillingType.PER_BOOKING,
billingPeriod: BillingPeriod.MONTHLY,
entityCount: 500,
overages: 99,
entityPrice: 50,
billingEmail: undefined,
},
});
const licenseMutation = trpc.viewer.admin.createSelfHostedLicense.useMutation({
onSuccess: async (values) => {
showToast("Success: We have created a stripe payment URL for this billing email", "success");
setStripeCheckoutUrl(values.stripeCheckoutUrl);
},
onError: async (err) => {
setServerErrorMessage(err.message);
},
});
const watchedBillingPeriod = licenseForm.watch("billingPeriod");
const watchedBillingEmail = licenseForm.watch("billingEmail");
const watchedEntityCount = licenseForm.watch("entityCount");
const watchedEntityPrice = licenseForm.watch("entityPrice");
function calculateMonthlyPrice() {
const occurrence = watchedBillingPeriod === "MONTHLY" ? 1 : 12;
const sum = watchedEntityCount * watchedEntityPrice;
return `$ ${sum / 100} / ${occurrence} months`;
}
return (
<CardFrame className="w-full">
<CardFrameHeader>
<CardFrameTitle>Create License Key</CardFrameTitle>
<CardFrameDescription>
Configure pricing and generate a Stripe checkout URL for the customer.
</CardFrameDescription>
</CardFrameHeader>
{!stripeCheckoutUrl ? (
<Card>
<CardPanel>
<Form
onSubmit={(event) => {
event.preventDefault();
licenseForm.handleSubmit((values) => {
licenseMutation.mutate(values);
})(event);
}}>
{serverErrorMessage && (
<Alert variant="error">
<AlertDescription>{serverErrorMessage}</AlertDescription>
</Alert>
)}
<Controller
name="billingPeriod"
control={licenseForm.control}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Billing Period</FieldLabel>
<ToggleGroup
className="w-full"
variant="outline"
value={[value]}
onValueChange={(newValue) => {
if (newValue.length > 0) onChange(newValue[0]);
}}>
<Toggle value="MONTHLY" className="flex-1">
Monthly
</Toggle>
<Toggle value="ANNUALLY" className="flex-1">
Annually
</Toggle>
</ToggleGroup>
</Field>
)}
/>
<Controller
name="billingEmail"
control={licenseForm.control}
rules={{ required: t("must_enter_billing_email") }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Billing Email for Customer</FieldLabel>
<Input
placeholder="john@acme.com"
disabled={!isAdmin}
defaultValue={value}
onChange={onChange}
autoComplete="off"
/>
</Field>
)}
/>
<Controller
name="billingType"
control={licenseForm.control}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Booking Type</FieldLabel>
<ToggleGroup
className="w-full"
variant="outline"
value={[value]}
onValueChange={(newValue) => {
if (newValue.length > 0) onChange(newValue[0]);
}}>
<Toggle value="PER_BOOKING" className="flex-1">
Per Booking
</Toggle>
<Toggle value="PER_USER" className="flex-1">
Per User
</Toggle>
</ToggleGroup>
</Field>
)}
/>
<div className="flex flex-wrap gap-2 *:flex-1">
<Controller
name="entityCount"
control={licenseForm.control}
rules={{ required: "Must enter a total of billable users" }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Total entities included</FieldLabel>
<Input
type="number"
placeholder="100"
defaultValue={value}
onChange={(event) => onChange(+event.target.value)}
/>
</Field>
)}
/>
<Controller
name="entityPrice"
control={licenseForm.control}
rules={{ required: "Must enter fixed price per user" }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Fixed price per entity ($)</FieldLabel>
<Input
type="number"
defaultValue={value / 100}
onChange={(event) => onChange(+event.target.value * 100)}
/>
</Field>
)}
/>
</div>
<Controller
name="overages"
control={licenseForm.control}
rules={{ required: "Must enter overages" }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Overages ($)</FieldLabel>
<Input
type="number"
placeholder="0.99"
disabled={!isAdmin}
defaultValue={value / 100}
onChange={(event) => onChange(+event.target.value * 100)}
autoComplete="off"
/>
</Field>
)}
/>
<Button
type="submit"
disabled={licenseForm.formState.isSubmitting || licenseMutation.isPending}
className="w-full">
{t("continue")} - {calculateMonthlyPrice()}
</Button>
</Form>
</CardPanel>
</Card>
) : (
<Card>
<CardPanel>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel>Checkout URL</FieldLabel>
<div className="flex w-full items-center gap-1">
<Input disabled value={stripeCheckoutUrl} className="min-w-0 flex-1" />
<CopyButton value={stripeCheckoutUrl} />
</div>
</Field>
{couponCode && (
<Field>
<FieldLabel>Coupon Code</FieldLabel>
<div className="flex w-full items-center gap-1">
<Input disabled value={couponCode} className="min-w-0 flex-1" />
<CopyButton value={couponCode} />
</div>
</Field>
)}
<Button
variant="secondary"
className="w-full"
onClick={() => {
licenseForm.reset();
setStripeCheckoutUrl(null);
setCouponCode(null);
}}>
Back
</Button>
</div>
</CardPanel>
</Card>
)}
<CardFrameFooter>
<p className="text-muted-foreground text-xs">
Need a coupon for this customer?{" "}
<CreateCouponDialog
billingEmail={watchedBillingEmail ?? ""}
onCouponCreated={(code) => setCouponCode(code)}
/>
</p>
</CardFrameFooter>
</CardFrame>
);
};
function CreateCouponDialog({
billingEmail,
onCouponCreated,
}: {
billingEmail: string;
onCouponCreated: (code: string) => void;
}) {
const [couponResult, setCouponResult] = useState<{
promotionCode: string;
couponId: string;
} | null>(null);
const [couponError, setCouponError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const couponForm = useForm<CouponFormValues>({
defaultValues: {
couponName: "",
billingEmail: "",
code: "",
discountType: "percent",
discountAmount: 10,
duration: "once",
durationInMonths: 3,
},
});
const couponMutation = trpc.viewer.admin.createCoupon.useMutation({
onSuccess: (data) => {
showToast("Coupon created successfully", "success");
setCouponResult(data);
setCouponError(null);
onCouponCreated(data.promotionCode);
},
onError: (err) => {
setCouponError(err.message);
setCouponResult(null);
},
});
const watchedDuration = couponForm.watch("duration");
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen);
if (isOpen) {
couponForm.setValue("billingEmail", billingEmail);
}
if (!isOpen) {
couponForm.reset();
setCouponResult(null);
setCouponError(null);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger
render={
<button
type="button"
className="cursor-pointer font-medium text-foreground underline underline-offset-2"
/>
}>
Create a coupon
</DialogTrigger>
<DialogPopup className="sm:max-w-md">
<Form
className="contents"
onSubmit={(event) => {
event.preventDefault();
couponForm.handleSubmit((values) => {
setCouponError(null);
setCouponResult(null);
couponMutation.mutate(values);
})(event);
}}>
<DialogHeader>
<DialogTitle>Create Coupon</DialogTitle>
<DialogDescription>
Create a Stripe coupon with a promotion code restricted to a specific customer.
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-4">
{couponError && (
<Alert variant="error">
<AlertDescription>{couponError}</AlertDescription>
</Alert>
)}
{couponResult && (
<Alert variant="success">
<AlertDescription>
<div className="flex flex-col gap-1">
<span>
Promo Code: <strong>{couponResult.promotionCode}</strong>
</span>
<span>
Coupon ID: <strong>{couponResult.couponId}</strong>
</span>
</div>
</AlertDescription>
</Alert>
)}
<Controller
name="couponName"
control={couponForm.control}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Coupon Name (optional)</FieldLabel>
<Input placeholder="e.g. ACME Corp Discount" value={value} onChange={onChange} />
</Field>
)}
/>
<Controller
name="billingEmail"
control={couponForm.control}
rules={{ required: "Billing email is required" }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Billing Email</FieldLabel>
<Input
type="email"
placeholder="customer@acme.com"
value={value}
onChange={onChange}
required
/>
</Field>
)}
/>
<Controller
name="code"
control={couponForm.control}
rules={{ required: "Promo code is required" }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Promo Code</FieldLabel>
<Input placeholder="e.g. ACME50OFF" value={value} onChange={onChange} required />
</Field>
)}
/>
<Controller
name="discountType"
control={couponForm.control}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Discount Type</FieldLabel>
<ToggleGroup
className="w-full"
variant="outline"
value={[value]}
onValueChange={(newValue) => {
if (newValue.length > 0) onChange(newValue[0]);
}}>
<Toggle value="percent" className="flex-1">
Percentage
</Toggle>
<Toggle value="fixed" className="flex-1">
Fixed Amount
</Toggle>
</ToggleGroup>
</Field>
)}
/>
<Controller
name="discountAmount"
control={couponForm.control}
rules={{ required: "Discount amount is required", min: 1 }}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>
Discount Amount {couponForm.watch("discountType") === "percent" ? "(%)" : "(cents)"}
</FieldLabel>
<Input
type="number"
min={1}
defaultValue={value}
onChange={(event) => {
const raw = event.target.value;
onChange(raw === "" ? "" : +raw);
}}
required
/>
</Field>
)}
/>
<Controller
name="duration"
control={couponForm.control}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Duration</FieldLabel>
<Select
value={value}
onValueChange={(newValue) => {
if (newValue) onChange(newValue);
}}>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectPopup>
<SelectItem value="once">Once</SelectItem>
<SelectItem value="repeating">Repeating</SelectItem>
<SelectItem value="forever">Forever</SelectItem>
</SelectPopup>
</Select>
</Field>
)}
/>
{watchedDuration === "repeating" && (
<Controller
name="durationInMonths"
control={couponForm.control}
rules={{
required: "Duration in months is required",
min: 1,
max: 36,
}}
render={({ field: { value, onChange } }) => (
<Field>
<FieldLabel>Duration in Months</FieldLabel>
<Input
type="number"
min={1}
max={36}
value={value}
onChange={(event) => onChange(+event.target.value)}
required
/>
</Field>
)}
/>
)}
</DialogPanel>
<DialogFooter variant="bare">
<DialogClose render={<Button variant="ghost" />}>Cancel</DialogClose>
<Button type="submit" disabled={couponForm.formState.isSubmitting || couponMutation.isPending}>
Create Coupon
</Button>
</DialogFooter>
</Form>
</DialogPopup>
</Dialog>
);
}