import { getFormProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { EnvelopeIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { useFetcher } from "@remix-run/react"; import { type ReactNode, useEffect, useState } from "react"; import { Feedback } from "~/components/Feedback"; import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header3 } from "~/components/primitives/Headers"; import { InputGroup } from "~/components/primitives/InputGroup"; import { InputNumberStepper } from "~/components/primitives/InputNumberStepper"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { PurchaseSchema } from "~/routes/resources.orgs.$organizationSlug.schedules-addon"; import { cn } from "~/utils/cn"; import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; export type SchedulePricing = { stepSize: number; centsPerStep: number; }; type Props = { /** Action URL the purchase form posts to. */ actionPath: string; schedulePricing: SchedulePricing; extraSchedules: number; usedSchedules: number; maxQuota: number; planScheduleLimit: number; triggerButton?: ReactNode; }; export function PurchaseSchedulesModal({ actionPath, schedulePricing, extraSchedules, usedSchedules, maxQuota, planScheduleLimit, triggerButton, }: Props) { const showSelfServe = useShowSelfServe(); const fetcher = useFetcher(); const lastSubmission = fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data ? fetcher.data : undefined; const [form, { amount }] = useForm({ id: "purchase-schedules", lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema: PurchaseSchema }); }, shouldRevalidate: "onSubmit", }); const stepSize = schedulePricing.stepSize; const [bundles, setBundles] = useState(Math.round(extraSchedules / stepSize)); const amountValue = bundles * stepSize; const isLoading = fetcher.state !== "idle"; const [open, setOpen] = useState(false); // Reset the bundle stepper to the user's current extra-schedules count on // each open. Earlier this only re-synced when `extraSchedules`/`stepSize` // props changed, so if the user opened the modal, typed a value, cancelled, // and reopened without purchasing, the stale draft persisted. useEffect(() => { if (open) setBundles(Math.round(extraSchedules / stepSize)); }, [open, extraSchedules, stepSize]); useEffect(() => { const data = fetcher.data; if ( fetcher.state === "idle" && data !== null && typeof data === "object" && "ok" in data && data.ok ) { setOpen(false); } }, [fetcher.state, fetcher.data]); const state = updateScheduleState({ value: amountValue, existingValue: extraSchedules, quota: maxQuota, usedSchedules, planScheduleLimit, }); const changeClassName = state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined; const pricePerSchedule = schedulePricing.centsPerStep / stepSize / 100; const pricePerStep = schedulePricing.centsPerStep / 100; const stepUnit = formatNumber(stepSize); const title = extraSchedules === 0 ? "Purchase extra schedules…" : "Add/remove extra schedules…"; if (!showSelfServe) { return ( Request more} /> ); } return ( {triggerButton ?? ( )} {title}
Schedules are purchased in bundles of {stepUnit}, at{" "} {formatCurrency(pricePerStep, false)}/month per bundle. Reducing will take effect at the start of the next billing cycle (1st of the month).
setBundles(Number(e.target.value))} disabled={isLoading} /> {formatNumber(bundles)} {bundles === 1 ? "bundle" : "bundles"} ={" "} {formatNumber(amountValue)} schedules {amount.errors} {form.errors}
{state === "need_to_delete" ? (
You need to delete{" "} {formatNumber(usedSchedules - (planScheduleLimit + amountValue))} more{" "} {usedSchedules - (planScheduleLimit + amountValue) === 1 ? "schedule" : "schedules"}{" "} before you can reduce to this level.
) : state === "above_quota" ? (
Currently you can only have up to {formatNumber(maxQuota)} extra schedules. Send a request below to lift your current limit. We'll get back to you soon.
) : (
Summary Total
{formatNumber(extraSchedules)} current extra {formatCurrency(extraSchedules * pricePerSchedule, true)}
({formatNumber(extraSchedules)}{" "} {extraSchedules === 1 ? "schedule" : "schedules"}) /mth
{state === "increase" ? "+" : null} {formatNumber(amountValue - extraSchedules)} {state === "increase" ? "+" : null} {formatCurrency((amountValue - extraSchedules) * pricePerSchedule, true)}
({formatNumber(Math.abs(amountValue - extraSchedules))}{" "} {Math.abs(amountValue - extraSchedules) === 1 ? "schedule" : "schedules"} @{" "} {formatCurrency(pricePerStep, false)}/mth per {stepUnit}) /mth
{formatNumber(amountValue)} new total {formatCurrency(amountValue * pricePerSchedule, true)}
({formatNumber(amountValue)} {amountValue === 1 ? "schedule" : "schedules"}) /mth
)}
) : state === "decrease" || state === "need_to_delete" ? ( <> ) : ( <> ) } cancelButton={ } />
); } function updateScheduleState({ value, existingValue, quota, usedSchedules, planScheduleLimit, }: { value: number; existingValue: number; quota: number; usedSchedules: number; planScheduleLimit: number; }): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_delete" { if (value === existingValue) return "no_change"; if (value < existingValue) { const newTotalLimit = planScheduleLimit + value; if (usedSchedules > newTotalLimit) { return "need_to_delete"; } return "decrease"; } if (value > quota) return "above_quota"; return "increase"; }