diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx index e93b194f2..ea00748da 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -1,12 +1,14 @@ "use client"; +import { TRIAL_DAYS } from "@openstatus/api/src/router/stripe/trial-config"; import { allPlans } from "@openstatus/db/src/schema/plan/config"; import type { Limits } from "@openstatus/db/src/schema/plan/schema"; import { Button } from "@openstatus/ui/components/ui/button"; +import { useCookieState } from "@openstatus/ui/hooks/use-cookie-state"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; -import { useEffect, useMemo, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { BillingAddons } from "@/components/content/billing-addons"; @@ -23,6 +25,7 @@ import { SectionTitle, } from "@/components/content/section"; import { DataTable } from "@/components/data-table/billing/data-table"; +import { UpgradeIntentDialog } from "@/components/dialogs/upgrade-intent"; import { FormCard, FormCardContent, @@ -76,9 +79,26 @@ export function Client() { const router = useRouter(); const [isPending, startTransition] = useTransition(); const queryClient = useQueryClient(); - const [{ success, setup }, setSearchParams] = + const [{ success, setup, trial }, setSearchParams] = useQueryStates(searchParamsParsers); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + // Captured on mount: the toasts clear the params while the dialog is open. + const [askIntent, setAskIntent] = useState(Boolean(success || trial)); + const { data: hasIntent } = useQuery({ + ...trpc.feedback.hasSubmitted.queryOptions({ source: "upgrade-intent" }), + enabled: askIntent, + }); + const [currency] = useCookieState("x-currency", "USD"); + const trialCheckoutMutation = useMutation( + trpc.stripeRouter.getCheckoutSession.mutationOptions({ + onSuccess: (data) => { + if (data?.type === "checkout" && data.session.url) { + window.location.assign(data.session.url); + } + }, + onError: (error) => toast.error(error.message), + }), + ); const { data: usage } = useQuery(trpc.workspace.usage.queryOptions()); const paymentMethodSetupMutation = useMutation( trpc.stripeRouter.getPaymentMethodSetupSession.mutationOptions({ @@ -122,6 +142,38 @@ export function Client() { } }, [success, setSearchParams]); + useEffect(() => { + if (!trial) return; + let cancelled = false; + queryClient.invalidateQueries({ + queryKey: trpc.stripeRouter.getBillingInterval.queryKey(), + }); + // The webhook can land after the redirect, and anyone can open + // `?trial=true`, so only toast once the workspace is actually trialing. + (async () => { + for (let attempt = 0; attempt < 5 && !cancelled; attempt++) { + const ws = await queryClient.fetchQuery({ + ...trpc.workspace.get.queryOptions(), + staleTime: 0, + }); + if (cancelled) return; + if (ws.trialDaysLeft !== null) { + toast.success(`Your ${TRIAL_DAYS}-day Starter trial has started`, { + duration: 5_000, + onAutoClose: () => setSearchParams({ trial: null }), + onDismiss: () => setSearchParams({ trial: null }), + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + if (!cancelled) setSearchParams({ trial: null }); + })(); + return () => { + cancelled = true; + }; + }, [trial, setSearchParams, queryClient, trpc]); + useEffect(() => { if (setup) { queryClient.invalidateQueries({ @@ -157,6 +209,10 @@ export function Client() { return ( +
Billing @@ -165,6 +221,48 @@ export function Client() { + {workspace?.trial.eligible ? ( + + + + Try Starter free for {TRIAL_DAYS} days + + + 1 min checks, more monitors, custom domain, Slack and on-call + integrations. + + + + + + No credit card needed. + {" "} + The workspace returns to Hobby after {TRIAL_DAYS} days unless + you add a card. + + + + + ) : null} {workspace.trialEndsAt && trialDaysLeft ? ( diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts index 0e6b7e14c..84e66c386 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts @@ -3,6 +3,7 @@ import { createSearchParamsCache, parseAsBoolean } from "nuqs/server"; export const searchParamsParsers = { success: parseAsBoolean, setup: parseAsBoolean, + trial: parseAsBoolean, }; export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx index 326b19966..26453ac01 100644 --- a/apps/dashboard/src/components/data-table/billing/data-table.tsx +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -1,5 +1,9 @@ "use client"; +import { + TRIAL_DAYS, + type TrialSurface, +} from "@openstatus/api/src/router/stripe/trial-config"; import type { WorkspacePlan } from "@openstatus/db/src/schema"; import type { Addons, @@ -49,7 +53,13 @@ function getQuantitySuffix(addon: keyof Addons, interval: BillingInterval) { return packSize > 1 ? `${period}/${packSize}` : `${period}/each`; } -export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { +export function DataTable({ + restrictTo, + surface = "plan_table", +}: { + restrictTo?: WorkspacePlan[]; + surface?: TrialSurface; +}) { const [interval, setInterval] = useState("monthly"); const [currency] = useCookieState("x-currency", "USD"); const trpc = useTRPC(); @@ -61,6 +71,7 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { trpc.stripeRouter.getBillingInterval.queryOptions(), ); const currentInterval = intervalQuery.data; + const canTrial = workspace?.trial.eligible === true; const checkoutSessionMutation = useMutation( trpc.stripeRouter.getCheckoutSession.mutationOptions({ @@ -128,7 +139,11 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { {isTrialing ? "Upgrading ends your trial and charges your card today." - : "A list to compare the different features by plan."} + : canTrial && + interval === "monthly" && + filteredPlans.some(({ id }) => id === "starter") + ? `Starter can be tried free for ${TRIAL_DAYS} days, no credit card needed.` + : "A list to compare the different features by plan."} @@ -157,6 +172,8 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { currentInterval !== null && currentInterval !== interval; const price = getPriceConfig(id, currency, interval); + const isTrialOffer = + canTrial && id === "starter" && interval === "monthly"; return ( {plan.description}

+ {isTrialOffer ? ( +

+ Free for {TRIAL_DAYS} days. No credit card needed. +

+ ) : null}
{isFreePlan ? ( @@ -196,6 +218,19 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { onClick={() => { startTransition(async () => { try { + if (isTrialOffer) { + await checkoutSessionMutation.mutateAsync({ + trial: true, + plan: "starter", + interval: "monthly", + surface, + currency: currency || "USD", + workspaceSlug: workspace.slug, + successUrl: `${BASE_URL}/settings/billing?trial=true`, + cancelUrl: `${BASE_URL}/settings/billing`, + }); + return; + } if (id === "free") { await customerPortalMutation.mutateAsync({ workspaceSlug: workspace.slug, @@ -232,9 +267,11 @@ export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { ? "Choosing..." : isIntervalSwitch ? `Switch to ${interval}` - : isTrialing && !isFreePlan - ? "Upgrade now" - : "Choose"} + : isTrialOffer + ? `Start ${TRIAL_DAYS}-day trial` + : isTrialing && !isFreePlan + ? "Upgrade now" + : "Choose"}
diff --git a/apps/dashboard/src/components/dialogs/upgrade-intent.tsx b/apps/dashboard/src/components/dialogs/upgrade-intent.tsx new file mode 100644 index 000000000..b2b424c7b --- /dev/null +++ b/apps/dashboard/src/components/dialogs/upgrade-intent.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@openstatus/ui/components/ui/dialog"; +import { Label } from "@openstatus/ui/components/ui/label"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; + +/** Asked after an upgrade or trial start; stored as `upgrade-intent` feedback. */ +export function UpgradeIntentDialog(props: DialogProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const id = useId(); + const [message, setMessage] = useState(""); + const feedbackMutation = useMutation( + trpc.feedback.submit.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.feedback.hasSubmitted.queryKey({ + source: "upgrade-intent", + }), + }); + toast.success("Thanks for sharing"); + props.onOpenChange?.(false); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + + What are you using openstatus for? + + A sentence or two helps us tailor openstatus to you. + + +
+ +