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>
This commit is contained in:
co-authored by
unknown <>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
1ad4ad6e4e
commit
c652c61e0c
@@ -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 (
|
||||
<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") {
|
||||
@@ -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<SessionContextValue, "data"> }) => {
|
||||
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 [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 newLicenseKeyFormMethods = useForm<FormValues>({
|
||||
|
||||
const licenseForm = useForm<LicenseFormValues>({
|
||||
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 ? (
|
||||
<Form
|
||||
form={newLicenseKeyFormMethods}
|
||||
className="stack-y-5"
|
||||
id="createOrg"
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}>
|
||||
<div>
|
||||
{serverErrorMessage && (
|
||||
<div className="mb-5">
|
||||
<Alert severity="error" message={serverErrorMessage} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<Controller
|
||||
name="billingPeriod"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label htmlFor="billingPeriod">Billing Period</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
id="billingPeriod"
|
||||
defaultValue={value}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
options={[
|
||||
{
|
||||
value: "MONTHLY",
|
||||
label: "Monthly",
|
||||
},
|
||||
{
|
||||
value: "ANNUALLY",
|
||||
label: "Annually",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{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={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: t("must_enter_billing_email"),
|
||||
}}
|
||||
control={couponForm.control}
|
||||
rules={{ required: "Billing email is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex">
|
||||
<TextField
|
||||
containerClassName="w-full"
|
||||
placeholder="john@acme.com"
|
||||
name="billingEmail"
|
||||
disabled={!isAdmin}
|
||||
label="Billing Email for Customer"
|
||||
defaultValue={value}
|
||||
<Field>
|
||||
<FieldLabel>Billing Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="customer@acme.com"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<Controller
|
||||
name="billingType"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
name="code"
|
||||
control={couponForm.control}
|
||||
rules={{ required: "Promo code is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<Label htmlFor="bookingType">Booking Type</Label>
|
||||
<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
|
||||
isFullWidth
|
||||
id="bookingType"
|
||||
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}
|
||||
onValueChange={(e) => 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
|
||||
/>
|
||||
</>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 *:flex-1">
|
||||
<Controller
|
||||
name="entityCount"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter a total of billable users",
|
||||
}}
|
||||
name="duration"
|
||||
control={couponForm.control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="entityCount"
|
||||
label="Total entities included"
|
||||
placeholder="100"
|
||||
defaultValue={value}
|
||||
onChange={(event) => onChange(+event.target.value)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="entityPrice"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter fixed price per user",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextField
|
||||
className="mt-2"
|
||||
name="entityPrice"
|
||||
label="Fixed price per entity"
|
||||
addOnSuffix="$"
|
||||
defaultValue={value / 100}
|
||||
onChange={(event) => onChange(+event.target.value * 100)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Controller
|
||||
name="overages"
|
||||
control={newLicenseKeyFormMethods.control}
|
||||
rules={{
|
||||
required: "Must enter overages",
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<TextField
|
||||
className="mt-2"
|
||||
placeholder="Acme"
|
||||
name="overages"
|
||||
addOnSuffix="$"
|
||||
label="Overages"
|
||||
disabled={!isAdmin}
|
||||
defaultValue={value / 100}
|
||||
onChange={(event) => onChange(+event.target.value * 100)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
{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
|
||||
disabled={newLicenseKeyFormMethods.formState.isSubmitting}
|
||||
color="primary"
|
||||
type="submit"
|
||||
form="createOrg"
|
||||
loading={mutation.isPending}
|
||||
className="w-full justify-center">
|
||||
{t("continue")} - {calculateMonthlyPrice()}
|
||||
disabled={
|
||||
couponForm.formState.isSubmitting || couponMutation.isPending
|
||||
}
|
||||
>
|
||||
Create Coupon
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<div className="">
|
||||
<TextField className="flex-1" disabled value={stripeCheckoutUrl} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2 *:flex-1 *:justify-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
newLicenseKeyFormMethods.reset();
|
||||
setStripeCheckoutUrl(null);
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<WizardLayout currentStep={1} maxSteps={2}>
|
||||
<CreateANewLicenseKeyForm />
|
||||
</WizardLayout>
|
||||
<div className="bg-default flex min-h-screen items-start justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg">
|
||||
<CreateANewLicenseKeyForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -167,7 +167,7 @@ function DialogPanel({
|
||||
<ScrollArea scrollFade={scrollFade}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1 in-[[data-slot=dialog-popup]:not(:has([data-slot=dialog-header]))]:pt-6 in-[[data-slot=dialog-popup]:not(:has([data-slot=dialog-footer]))]:pb-6! in-[[data-slot=dialog-popup]:not(:has([data-slot=dialog-footer].border-t))]:pb-1 pb-6",
|
||||
"p-6 in-[[data-slot=dialog-popup]:has([data-slot=dialog-header])]:pt-1 in-[[data-slot=dialog-popup]:has([data-slot=dialog-footer]:not(.border-t))]:pb-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="dialog-panel"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { authedAdminProcedure } from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZAdminAssignFeatureToTeamSchema } from "./assignFeatureToTeam.schema";
|
||||
import { ZBillingPortalLinkSchema } from "./billingPortalLink.schema";
|
||||
import { ZCreateCouponSchema } from "./createCoupon.schema";
|
||||
import { ZCreateSelfHostedLicenseSchema } from "./createSelfHostedLicenseKey.schema";
|
||||
import { ZAdminGetTeamsForFeatureSchema } from "./getTeamsForFeature.schema";
|
||||
import { ZListMembersSchema } from "./listPaginated.schema";
|
||||
@@ -58,6 +59,10 @@ export const adminRouter = router({
|
||||
const { default: handler } = await import("./createSelfHostedLicenseKey.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
createCoupon: authedAdminProcedure.input(ZCreateCouponSchema).mutation(async (opts) => {
|
||||
const { default: handler } = await import("./createCoupon.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
resendPurchaseCompleteEmail: authedAdminProcedure
|
||||
.input(ZResendPurchaseCompleteEmailSchema)
|
||||
.mutation(async (opts) => {
|
||||
|
||||
@@ -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<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateCouponSchema;
|
||||
};
|
||||
|
||||
const generateNonce = (): string => {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
};
|
||||
|
||||
const createSignature = (body: Record<string, unknown>, nonce: string, secretKey: string): string => {
|
||||
return crypto
|
||||
.createHmac("sha256", secretKey)
|
||||
.update(JSON.stringify(body) + nonce)
|
||||
.digest("hex");
|
||||
};
|
||||
|
||||
const fetchWithSignature = async (
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
secretKey: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> => {
|
||||
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;
|
||||
@@ -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<typeof ZCreateCouponSchema>;
|
||||
Reference in New Issue
Block a user