- {serverErrorMessage && (
-
-
+
+
+ Create License Key
+
+ Configure pricing and generate a Stripe checkout URL for the customer.
+
+
+
+ {!stripeCheckoutUrl ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+ Need a coupon for this customer?{" "}
+ setCouponCode(code)}
+ />
+
+
+
+ );
+};
+
+function CreateCouponDialog({
+ billingEmail,
+ onCouponCreated,
+}: {
+ billingEmail: string;
+ onCouponCreated: (code: string) => void;
+}) {
+ const [couponResult, setCouponResult] = useState<{
+ promotionCode: string;
+ couponId: string;
+ } | null>(null);
+ const [couponError, setCouponError] = useState
(null);
+ const [open, setOpen] = useState(false);
+
+ const couponForm = useForm({
+ 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 (
+
-
+
(
- <>
-
+
+ Promo Code
+
+
+ )}
+ />
+
+ (
+
+ Discount Type
{
+ if (newValue.length > 0) onChange(newValue[0]);
+ }}
+ >
+
+ Percentage
+
+
+ Fixed Amount
+
+
+
+ )}
+ />
+
+ (
+
+
+ Discount Amount{" "}
+ {couponForm.watch("discountType") === "percent"
+ ? "(%)"
+ : "(cents)"}
+
+ onChange(e)}
- options={[
- {
- value: "PER_BOOKING",
- label: "Per Booking",
- tooltip: "Configure pricing on a per booking basis",
- },
- {
- value: "PER_USER",
- label: "Per User",
- tooltip: "Configure pricing on a per user basis",
- },
- ]}
+ onChange={(event) => {
+ const raw = event.target.value;
+ onChange(raw === "" ? "" : +raw);
+ }}
+ required
/>
- >
+
)}
/>
-
-
(
- onChange(+event.target.value)}
- />
+
+ Duration
+
+
)}
/>
- (
- onChange(+event.target.value * 100)}
- />
- )}
- />
-
-
- (
- <>
- onChange(+event.target.value * 100)}
- autoComplete="off"
- />
- >
- )}
- />
-
-
-
+ {watchedDuration === "repeating" && (
+ (
+
+ Duration in Months
+ onChange(+event.target.value)}
+ required
+ />
+
+ )}
+ />
+ )}
+
+
+ }>
+ Cancel
+
-
+
- ) : (
-
-
-
-
-
-
-
-
-
- )}
- >
+
+
);
-};
+}
diff --git a/apps/web/modules/settings/license-key/new/new-view.tsx b/apps/web/modules/settings/license-key/new/new-view.tsx
index b7ca12063b..29ae86777a 100644
--- a/apps/web/modules/settings/license-key/new/new-view.tsx
+++ b/apps/web/modules/settings/license-key/new/new-view.tsx
@@ -1,12 +1,13 @@
"use client";
-import { WizardLayout } from "@calcom/ui/components/layout";
import { CreateANewLicenseKeyForm } from "~/ee/deployment/components/CreateLicenseKeyForm";
export default function SettingsNewView() {
return (
-
-
-
+
);
-};
+}
diff --git a/packages/coss-ui/src/components/card.tsx b/packages/coss-ui/src/components/card.tsx
index d71f17948b..72416168d5 100644
--- a/packages/coss-ui/src/components/card.tsx
+++ b/packages/coss-ui/src/components/card.tsx
@@ -12,7 +12,7 @@ function Card({
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(
- "relative flex flex-col gap-6 rounded-2xl border bg-card not-dark:bg-clip-padding py-6 text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
+ "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
),
"data-slot": "card",
@@ -25,6 +25,94 @@ function Card({
});
}
+function CardFrame({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn(
+ "flex flex-col relative rounded-2xl border bg-background before:absolute before:inset-0 before:rounded-[inherit] before:bg-muted/72 before:pointer-events-none not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:-m-px *:not-last:data-[slot=card]:rounded-b-lg *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-lg)-1px)] *:not-first:data-[slot=card]:rounded-t-lg *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-lg)-1px)] *:data-[slot=card]:[clip-path:inset(-1rem_1px)] *:data-[slot=card]:first:[clip-path:inset(1px_1px_-1rem_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:last:[clip-path:inset(-1rem_1px_1px_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:data-[slot=card]:bg-clip-padding",
+ className,
+ ),
+ "data-slot": "card-frame",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+function CardFrameHeader({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn("flex flex-col px-6 py-4", className),
+ "data-slot": "card-frame-header",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+function CardFrameTitle({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn("font-semibold text-sm", className),
+ "data-slot": "card-frame-title",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+function CardFrameDescription({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn("text-muted-foreground text-sm", className),
+ "data-slot": "card-frame-description",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
+function CardFrameFooter({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div">) {
+ const defaultProps = {
+ className: cn("px-6 py-4", className),
+ "data-slot": "card-frame-footer",
+ };
+
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(defaultProps, props),
+ render,
+ });
+}
+
function CardHeader({
className,
render,
@@ -32,7 +120,7 @@ function CardHeader({
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(
- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+ "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className,
),
"data-slot": "card-header",
@@ -86,7 +174,7 @@ function CardAction({
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(
- "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end inline-flex",
className,
),
"data-slot": "card-action",
@@ -105,8 +193,11 @@ function CardPanel({
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
- className: cn("px-6", className),
- "data-slot": "card-content",
+ className: cn(
+ "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0",
+ className,
+ ),
+ "data-slot": "card-panel",
};
return useRender({
@@ -122,7 +213,10 @@ function CardFooter({
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
- className: cn("flex items-center px-6 [.border-t]:pt-6", className),
+ className: cn(
+ "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4",
+ className,
+ ),
"data-slot": "card-footer",
};
@@ -135,6 +229,11 @@ function CardFooter({
export {
Card,
+ CardFrame,
+ CardFrameHeader,
+ CardFrameTitle,
+ CardFrameDescription,
+ CardFrameFooter,
CardAction,
CardDescription,
CardFooter,
diff --git a/packages/coss-ui/src/components/dialog.tsx b/packages/coss-ui/src/components/dialog.tsx
index 1c402693d4..5e0a9649c5 100644
--- a/packages/coss-ui/src/components/dialog.tsx
+++ b/packages/coss-ui/src/components/dialog.tsx
@@ -167,7 +167,7 @@ function DialogPanel({
{
+ const { default: handler } = await import("./createCoupon.handler");
+ return handler(opts);
+ }),
resendPurchaseCompleteEmail: authedAdminProcedure
.input(ZResendPurchaseCompleteEmailSchema)
.mutation(async (opts) => {
diff --git a/packages/trpc/server/routers/viewer/admin/createCoupon.handler.ts b/packages/trpc/server/routers/viewer/admin/createCoupon.handler.ts
new file mode 100644
index 0000000000..eadc52de97
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/createCoupon.handler.ts
@@ -0,0 +1,83 @@
+import * as crypto from "node:crypto";
+import { z } from "zod";
+
+import { CALCOM_PRIVATE_API_ROUTE } from "@calcom/lib/constants";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../types";
+import type { TCreateCouponSchema } from "./createCoupon.schema";
+
+type CreateCouponOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TCreateCouponSchema;
+};
+
+const generateNonce = (): string => {
+ return crypto.randomBytes(16).toString("hex");
+};
+
+const createSignature = (body: Record, nonce: string, secretKey: string): string => {
+ return crypto
+ .createHmac("sha256", secretKey)
+ .update(JSON.stringify(body) + nonce)
+ .digest("hex");
+};
+
+const fetchWithSignature = async (
+ url: string,
+ body: Record,
+ secretKey: string,
+ options: RequestInit = {}
+): Promise => {
+ const nonce = generateNonce();
+ const signature = createSignature(body, nonce, secretKey);
+
+ const headers = {
+ ...options.headers,
+ "Content-Type": "application/json",
+ nonce: nonce,
+ signature: signature,
+ };
+
+ return await fetch(url, {
+ ...options,
+ method: "POST",
+ headers: headers,
+ body: JSON.stringify(body),
+ });
+};
+
+const createCoupon = async ({ input, ctx }: CreateCouponOptions) => {
+ const privateApiUrl = CALCOM_PRIVATE_API_ROUTE;
+ const signatureToken = process.env.CAL_SIGNATURE_TOKEN;
+
+ if (!privateApiUrl || !signatureToken) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Private API route is not configured" });
+ }
+
+ if (ctx.user.role !== "ADMIN") {
+ throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have permission to do this" });
+ }
+
+ const request = await fetchWithSignature(`${privateApiUrl}/v1/license/coupon`, input, signatureToken, {
+ method: "POST",
+ });
+
+ const data = await request.json();
+
+ if (!request.ok) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: data.message ?? "Failed to create coupon" });
+ }
+
+ const schema = z.object({
+ promotionCode: z.string(),
+ couponId: z.string(),
+ });
+
+ return schema.parse(data);
+};
+
+export default createCoupon;
diff --git a/packages/trpc/server/routers/viewer/admin/createCoupon.schema.ts b/packages/trpc/server/routers/viewer/admin/createCoupon.schema.ts
new file mode 100644
index 0000000000..45de197aeb
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/admin/createCoupon.schema.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+import { emailSchema } from "@calcom/lib/emailSchema";
+
+const DiscountType = z.enum(["percent", "fixed"]);
+const CouponDuration = z.enum(["once", "repeating", "forever"]);
+
+export const ZCreateCouponSchema = z
+ .object({
+ billingEmail: emailSchema,
+ discountType: DiscountType,
+ discountAmount: z.number().int().min(1),
+ currency: z.string().optional().default("USD"),
+ duration: CouponDuration,
+ durationInMonths: z.number().int().min(1).max(36).optional(),
+ code: z.string().min(1),
+ couponName: z.string().optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.duration === "repeating") {
+ return data.durationInMonths !== undefined;
+ }
+ return true;
+ },
+ {
+ message: "durationInMonths is required when duration is 'repeating'",
+ path: ["durationInMonths"],
+ }
+ );
+
+export type TCreateCouponSchema = z.infer;