* 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>
589 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|