From c652c61e0c984e8f8a722f9aa2ae8605c743cc29 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:05:57 +0000 Subject: [PATCH] feat: refactor UI to use coss + coupons UI (#27647) * feat: refactor UI to use coss + coupons UI * fix: use TRPCError and remove sensitive logging in createCoupon handler - Replace plain Error with TRPCError for consistent tRPC error handling - Use INTERNAL_SERVER_ERROR for missing env configuration - Use UNAUTHORIZED for permission denied errors - Use BAD_REQUEST for API failure errors - Remove console.warn that logged username (sensitive info) Co-Authored-By: unknown <> * feat: add copy button and fix 0 on % off --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/CreateLicenseKeyForm.tsx | 741 +++++++++++++----- .../settings/license-key/new/new-view.tsx | 11 +- packages/coss-ui/src/components/card.tsx | 111 ++- packages/coss-ui/src/components/dialog.tsx | 2 +- .../server/routers/viewer/admin/_router.ts | 5 + .../viewer/admin/createCoupon.handler.ts | 83 ++ .../viewer/admin/createCoupon.schema.ts | 32 + 7 files changed, 787 insertions(+), 198 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/admin/createCoupon.handler.ts create mode 100644 packages/trpc/server/routers/viewer/admin/createCoupon.schema.ts diff --git a/apps/web/modules/ee/deployment/components/CreateLicenseKeyForm.tsx b/apps/web/modules/ee/deployment/components/CreateLicenseKeyForm.tsx index 844c0b196d..1878a36e6b 100644 --- a/apps/web/modules/ee/deployment/components/CreateLicenseKeyForm.tsx +++ b/apps/web/modules/ee/deployment/components/CreateLicenseKeyForm.tsx @@ -1,20 +1,68 @@ "use client"; import type { SessionContextValue } from "next-auth/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useSession } from "next-auth/react"; -import { useState } from "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 { Alert } from "@calcom/ui/components/alert"; -import { Button } from "@calcom/ui/components/button"; -import { Label, TextField, ToggleGroup, Form } from "@calcom/ui/components/form"; 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 ( + + ); +} + export const CreateANewLicenseKeyForm = () => { const session = useSession(); if (session.data?.user.role !== "ADMIN") { @@ -34,7 +82,7 @@ enum BillingPeriod { ANNUALLY = "ANNUALLY", } -interface FormValues { +interface LicenseFormValues { billingType: BillingType; entityCount: number; entityPrice: number; @@ -43,232 +91,553 @@ interface FormValues { billingEmail: string; } -const CreateANewLicenseKeyFormChild = ({ session }: { session: Ensure }) => { +interface CouponFormValues { + couponName: string; + billingEmail: string; + code: string; + discountType: "percent" | "fixed"; + discountAmount: number; + duration: "once" | "repeating" | "forever"; + durationInMonths: number; +} + +const CreateANewLicenseKeyFormChild = ({ + session, +}: { + session: Ensure; +}) => { const { t } = useLocale(); - const [serverErrorMessage, setServerErrorMessage] = useState(null); - const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState(null); + const [serverErrorMessage, setServerErrorMessage] = useState( + null + ); + const [stripeCheckoutUrl, setStripeCheckoutUrl] = useState( + null + ); + const [couponCode, setCouponCode] = useState(null); const isAdmin = session.data.user.role === UserPermissionRole.ADMIN; - const newLicenseKeyFormMethods = useForm({ + + const licenseForm = useForm({ defaultValues: { billingType: BillingType.PER_BOOKING, billingPeriod: BillingPeriod.MONTHLY, entityCount: 500, - overages: 99, // $0.99 - entityPrice: 50, // $0.5 + overages: 99, + entityPrice: 50, billingEmail: undefined, }, }); - const mutation = 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 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 = newLicenseKeyFormMethods.watch("billingPeriod"); - const watchedEntityCount = newLicenseKeyFormMethods.watch("entityCount"); - const watchedEntityPrice = newLicenseKeyFormMethods.watch("entityPrice"); + 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 ( - <> - {!stripeCheckoutUrl ? ( -
{ - mutation.mutate(values); - }}> -
- {serverErrorMessage && ( -
- + + + Create License Key + + Configure pricing and generate a Stripe checkout URL for the customer. + + + + {!stripeCheckoutUrl ? ( + + + { + event.preventDefault(); + licenseForm.handleSubmit((values) => { + licenseMutation.mutate(values); + })(event); + }} + > + {serverErrorMessage && ( + + {serverErrorMessage} + + )} + + ( + + Billing Period + { + if (newValue.length > 0) onChange(newValue[0]); + }} + > + + Monthly + + + Annually + + + + )} + /> + + ( + + Billing Email for Customer + + + )} + /> + + ( + + Booking Type + { + if (newValue.length > 0) onChange(newValue[0]); + }} + > + + Per Booking + + + Per User + + + + )} + /> + +
+ ( + + Total entities included + onChange(+event.target.value)} + /> + + )} + /> + ( + + Fixed price per entity ($) + + onChange(+event.target.value * 100) + } + /> + + )} + /> +
+ + ( + + Overages ($) + + onChange(+event.target.value * 100) + } + autoComplete="off" + /> + + )} + /> + + + +
+
+ ) : ( + + +
+ + Checkout URL +
+ + +
+
+ {couponCode && ( + + Coupon Code +
+ + +
+
+ )} +
+
+
+ )} + + +

+ 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 ( + + + } + > + Create a coupon + + +
{ + event.preventDefault(); + couponForm.handleSubmit((values) => { + setCouponError(null); + setCouponResult(null); + couponMutation.mutate(values); + })(event); + }} + > + + Create Coupon + + Create a Stripe coupon with a promotion code restricted to a + specific customer. + + + + {couponError && ( + + {couponError} + )} -
- ( - <> - - onChange(e)} - options={[ - { - value: "MONTHLY", - label: "Monthly", - }, - { - value: "ANNUALLY", - label: "Annually", - }, - ]} - /> - - )} - /> -
+ {couponResult && ( + + +
+ + Promo Code: {couponResult.promotionCode} + + + Coupon ID: {couponResult.couponId} + +
+
+
+ )} + + ( + + Coupon Name (optional) + + + )} + /> ( -
- + Billing Email + -
+ )} /> -
-
+ ( - <> - + + 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;