diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 20003c5e68..59f12a4048 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -61,6 +61,7 @@ export type CheckboxProps = Omit< description?: string; badges?: string[]; className?: string; + labelClassName?: string; onChange?: (isChecked: boolean) => void; }; @@ -78,6 +79,7 @@ export const CheckboxWithLabel = React.forwardRef e.preventDefault()} > diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 505b46202b..51e85ca114 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -82,7 +82,7 @@ DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx index 2d37455008..2f304600dc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx @@ -117,6 +117,7 @@ export default function ChoosePlanPage() { subscription={v3Subscription} organizationSlug={organizationSlug} hasPromotedPlan={false} + periodEnd={periodEnd} />
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 869a161421..dc61a77bd1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -5,7 +5,6 @@ import { z } from "zod"; import { RouteErrorDisplay } from "~/components/ErrorDisplay"; import { MainBody } from "~/components/layout/AppLayout"; import { SideMenu } from "~/components/navigation/SideMenu"; -import { featuresForRequest } from "~/features.server"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; import { useUser } from "~/hooks/useUser"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx index 3641b37b96..e1561becf4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug_.select-plan/route.tsx @@ -37,11 +37,15 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const currentPlan = await getCurrentPlan(organization.id); - return typedjson({ ...plans, ...currentPlan, organizationSlug }); + const periodEnd = new Date(); + periodEnd.setMonth(periodEnd.getMonth() + 1); + + return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd }); } export default function ChoosePlanPage() { - const { plans, v3Subscription, organizationSlug } = useTypedLoaderData(); + const { plans, v3Subscription, organizationSlug, periodEnd } = + useTypedLoaderData(); return ( @@ -52,6 +56,7 @@ export default function ChoosePlanPage() { organizationSlug={organizationSlug} hasPromotedPlan showGithubVerificationBadge + periodEnd={periodEnd} /> ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index dac05d2193..b4411761b3 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -1,11 +1,15 @@ import { + ArrowUpRightIcon, CheckIcon, ExclamationTriangleIcon, ShieldCheckIcon, XMarkIcon, } from "@heroicons/react/20/solid"; +import { ArrowDownCircleIcon } from "@heroicons/react/24/outline"; import { Form, useLocation, useNavigation } from "@remix-run/react"; import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { PlainClient, uiComponent } from "@team-plain/typescript-sdk"; +import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { FreePlanDefinition, Limits, @@ -14,11 +18,14 @@ import { SetPlanBody, SubscriptionResult, } from "@trigger.dev/platform/v3"; -import { GitHubLightIcon } from "@trigger.dev/companyicons"; +import React, { useEffect, useState } from "react"; +import { inspect } from "util"; import { z } from "zod"; import { DefinitionTip } from "~/components/DefinitionTooltip"; import { Feedback } from "~/components/Feedback"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Button } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, @@ -26,13 +33,17 @@ import { DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; +import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; +import { TextArea } from "~/components/primitives/TextArea"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { redirectWithErrorMessage } from "~/models/message.server"; +import { logger } from "~/services/logger.server"; import { setPlan } from "~/services/platform.v3.server"; -import { requireUserId } from "~/services/session.server"; +import { requireUser } from "~/services/session.server"; import { cn } from "~/utils/cn"; const Params = z.object({ @@ -43,6 +54,8 @@ const schema = z.object({ type: z.enum(["free", "paid"]), planCode: z.string().optional(), callerPath: z.string(), + reasons: z.union([z.string(), z.array(z.string())]).optional(), + message: z.string().optional(), }); export async function action({ request, params }: ActionFunctionArgs) { @@ -51,11 +64,16 @@ export async function action({ request, params }: ActionFunctionArgs) { } const { organizationSlug } = Params.parse(params); - - const userId = await requireUserId(request); - - const formData = Object.fromEntries(await request.formData()); - const form = schema.parse(formData); + const user = await requireUser(request); + const formData = await request.formData(); + const reasons = formData.getAll("reasons"); + const message = formData.get("message"); + + const form = schema.parse({ + ...Object.fromEntries(formData), + reasons, + message: message || undefined, + }); const organization = await prisma.organization.findUnique({ where: { slug: organizationSlug }, @@ -69,9 +87,106 @@ export async function action({ request, params }: ActionFunctionArgs) { switch (form.type) { case "free": { + try { + if (!env.PLAIN_API_KEY) { + throw new Error("PLAIN_API_KEY is not set"); + } + + const client = new PlainClient({ + apiKey: env.PLAIN_API_KEY, + }); + + const upsertCustomerRes = await client.upsertCustomer({ + identifier: { + emailAddress: user.email, + }, + onCreate: { + externalId: user.id, + fullName: user.name ?? "", + email: { + email: user.email, + isVerified: true, + }, + }, + onUpdate: { + externalId: { value: user.id }, + fullName: { value: user.name ?? "" }, + email: { + email: user.email, + isVerified: true, + }, + }, + }); + + if (upsertCustomerRes.error) { + console.error( + inspect(upsertCustomerRes.error, { + showHidden: false, + depth: null, + colors: true, + }) + ); + throw redirectWithErrorMessage(form.callerPath, request, upsertCustomerRes.error.message); + } + + // Only create a thread if there are reasons or a message + if (reasons.length > 0 || (message && message.toString().trim() !== "")) { + const createThreadRes = await client.createThread({ + customerIdentifier: { + customerId: upsertCustomerRes.data.customer.id, + }, + title: "Plan cancelation feedback", + components: [ + uiComponent.text({ + text: `${user.name} (${user.email}) just canceled their plan.`, + }), + uiComponent.divider({ spacingSize: "M" }), + ...(reasons.length > 0 + ? [ + uiComponent.spacer({ size: "L" }), + uiComponent.text({ + size: "L", + color: "NORMAL", + text: "Reasons:", + }), + uiComponent.text({ + text: reasons.join(", "), + }), + ] + : []), + ...(message + ? [ + uiComponent.spacer({ size: "L" }), + uiComponent.text({ + size: "L", + color: "NORMAL", + text: "Comment:", + }), + uiComponent.text({ + text: message.toString(), + }), + ] + : []), + ], + }); + + if (createThreadRes.error) { + console.error( + inspect(createThreadRes.error, { + showHidden: false, + depth: null, + colors: true, + }) + ); + throw redirectWithErrorMessage(form.callerPath, request, createThreadRes.error.message); + } + } + } catch (e) { + logger.error("Failed to submit to Plain the unsubscribe reason", { error: e }); + } payload = { type: "free" as const, - userId, + userId: user.id, }; break; } @@ -82,10 +197,13 @@ export async function action({ request, params }: ActionFunctionArgs) { payload = { type: "paid" as const, planCode: form.planCode, - userId, + userId: user.id, }; break; } + default: { + throw new Error("Invalid form type"); + } } return setPlan(organization, request, form.callerPath, payload); @@ -134,6 +252,7 @@ type PricingPlansProps = { organizationSlug: string; hasPromotedPlan: boolean; showGithubVerificationBadge?: boolean; + periodEnd: Date; }; export function PricingPlans({ @@ -142,6 +261,7 @@ export function PricingPlans({ organizationSlug, hasPromotedPlan, showGithubVerificationBadge, + periodEnd, }: PricingPlansProps) { return (
@@ -151,6 +271,7 @@ export function PricingPlans({ subscription={subscription} organizationSlug={organizationSlug} showGithubVerificationBadge={showGithubVerificationBadge} + periodEnd={periodEnd} /> { + setIsDialogOpen(false); + }, [subscription]); + return (
+ + ${plan.limits.includedUsage / 100} free monthly usage + {showGithubVerificationBadge && status === "approved" && (
) : ( -
- - - - ${plan.limits.includedUsage / 100} free usage - -
- {status === "requires_connect" ? ( - - + <> + {status === "requires_connect" ? ( + + +
- - +
+
+ + + + Unlock the Free plan -
+
To unlock the Free plan, we need to verify that you have an active GitHub @@ -282,16 +412,104 @@ export function TierFree({ fullWidth disabled={isLoading} LeadingIcon={isLoading ? Spinner : undefined} - form="subscribe" + form="subscribe-free" > Connect to GitHub - -
- ) : ( + + +
+ ) : subscription?.plan !== undefined && + subscription.plan.type !== "free" && + subscription.canceledAt === undefined ? ( + + +
+ +
+
+ +
+ + + Downgrade plan? +
+ + + Are you sure you want to downgrade? You will lose access to your current + plan's features on{" "} + + . + +
+
+
+ Why are you thinking of downgrading? +
    + {[ + "Subscription or usage costs too expensive", + "Bugs or technical issues", + "No longer need the service", + "Found a better alternative", + "Lacking features I need", + ].map((label, index) => ( +
  • + { + if (label === "Lacking features I need") { + setIsLackingFeaturesChecked(isChecked); + } + }} + /> +
  • + ))} +
+
+
+ + {isLackingFeaturesChecked + ? "What features do you need? Or how can we improve?" + : "What can we do to improve?"} + +