-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(webapp): promo credits — /promo signup landing, redeem at plan selection, usage display #4138
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
14ba919
74c160e
2b95f4c
3f8c4f2
0715d10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| Promo credits: a /promo signup landing page, redeeming a promo code when a new org selects a plan, and showing remaining credits on the usage page. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import { EnvelopeIcon } from "@heroicons/react/20/solid"; | ||
| import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; | ||
| import { Form } from "@remix-run/react"; | ||
| import { GitHubLightIcon } from "@trigger.dev/companyicons"; | ||
| import { typedjson, useTypedLoaderData } from "remix-typedjson"; | ||
| import { GoogleLogo } from "~/assets/logos/GoogleLogo"; | ||
| import { LoginPageLayout } from "~/components/LoginPageLayout"; | ||
| import { Button, LinkButton } from "~/components/primitives/Buttons"; | ||
| import { Callout } from "~/components/primitives/Callout"; | ||
| import { Fieldset } from "~/components/primitives/Fieldset"; | ||
| import { Header2 } from "~/components/primitives/Headers"; | ||
| import { Paragraph } from "~/components/primitives/Paragraph"; | ||
| import { TextLink } from "~/components/primitives/TextLink"; | ||
| import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server"; | ||
| import { validatePromoCode } from "~/services/platform.v3.server"; | ||
| import { setPromoCodeCookie } from "~/services/promoCode.server"; | ||
| import { getUserId } from "~/services/session.server"; | ||
| import { requestUrl } from "~/utils/requestUrl.server"; | ||
|
|
||
| export const meta: MetaFunction = () => [{ title: "Claim your Trigger.dev credits" }]; | ||
|
|
||
| export async function loader({ request }: LoaderFunctionArgs) { | ||
| const userId = await getUserId(request); | ||
| const url = requestUrl(request); | ||
| const code = url.searchParams.get("code")?.trim() || null; | ||
|
|
||
| const authMethods = { | ||
| showGithubAuth: isGithubAuthSupported, | ||
| showGoogleAuth: isGoogleAuthSupported, | ||
| }; | ||
|
|
||
| // Credits are only granted to brand-new accounts, so an already-signed-in | ||
| // user can't redeem a code. | ||
| if (userId) { | ||
| return typedjson({ view: "signed_in" as const, ...authMethods }); | ||
| } | ||
|
|
||
| if (!code) { | ||
| return typedjson({ view: "invalid" as const, ...authMethods }); | ||
| } | ||
|
|
||
| const validated = await validatePromoCode(code); | ||
| if (!validated || !validated.valid) { | ||
| return typedjson({ view: "invalid" as const, ...authMethods }); | ||
| } | ||
|
|
||
| // Stash the code so it survives the OAuth round-trip and can be applied when | ||
| // the new org is created. | ||
| return typedjson( | ||
| { | ||
| view: "valid" as const, | ||
| amountInCents: validated.amountInCents ?? 0, | ||
| expiresAt: validated.expiresAt ?? null, | ||
| ...authMethods, | ||
| }, | ||
| { headers: { "Set-Cookie": await setPromoCodeCookie(code) } } | ||
| ); | ||
| } | ||
|
|
||
| function formatDollars(cents: number) { | ||
| const dollars = cents / 100; | ||
| return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`; | ||
| } | ||
|
|
||
| function formatExpiry(iso: string | null) { | ||
| if (!iso) return null; | ||
| const date = new Date(iso); | ||
| if (Number.isNaN(date.getTime())) return null; | ||
| return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); | ||
| } | ||
|
|
||
| function SignInForm({ | ||
| showGithubAuth, | ||
| showGoogleAuth, | ||
| }: { | ||
| showGithubAuth: boolean; | ||
| showGoogleAuth: boolean; | ||
| }) { | ||
| return ( | ||
| <Fieldset className="w-full"> | ||
| <div className="flex flex-col items-center gap-y-3"> | ||
| {showGithubAuth && ( | ||
| <Form action="/auth/github" method="post" className="w-full"> | ||
| <Button | ||
| type="submit" | ||
| variant="secondary/extra-large" | ||
| fullWidth | ||
| data-action="continue with github" | ||
| > | ||
| <GitHubLightIcon className="mr-2 size-5" /> | ||
| <span className="text-text-bright">Continue with GitHub</span> | ||
| </Button> | ||
| </Form> | ||
| )} | ||
| {showGoogleAuth && ( | ||
| <Form action="/auth/google" method="post" className="w-full"> | ||
| <Button | ||
| type="submit" | ||
| variant="secondary/extra-large" | ||
| fullWidth | ||
| data-action="continue with google" | ||
| > | ||
| <GoogleLogo className="mr-2 size-5" /> | ||
| <span className="text-text-bright">Continue with Google</span> | ||
| </Button> | ||
| </Form> | ||
| )} | ||
| <LinkButton | ||
| to="/login/magic" | ||
| variant="secondary/extra-large" | ||
| fullWidth | ||
| data-action="continue with email" | ||
| className="text-text-bright" | ||
| > | ||
| <EnvelopeIcon className="mr-2 size-5 text-text-bright" /> | ||
| Continue with Email | ||
| </LinkButton> | ||
| </div> | ||
| <Paragraph variant="extra-small" className="mt-2 text-center"> | ||
| By signing up you agree to our{" "} | ||
| <TextLink href="https://trigger.dev/legal" target="_blank"> | ||
| terms | ||
| </TextLink>{" "} | ||
| and{" "} | ||
| <TextLink href="https://trigger.dev/legal/privacy" target="_blank"> | ||
| privacy | ||
| </TextLink>{" "} | ||
| policy. | ||
| </Paragraph> | ||
| </Fieldset> | ||
| ); | ||
| } | ||
|
|
||
| export default function PromoPage() { | ||
| const data = useTypedLoaderData<typeof loader>(); | ||
|
|
||
| return ( | ||
| <LoginPageLayout> | ||
| <div className="flex w-full flex-col"> | ||
| {data.view === "signed_in" ? ( | ||
| <> | ||
| <Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing> | ||
| Promo codes are for new accounts | ||
| </Header2> | ||
| <Paragraph variant="base" spacing> | ||
| You're already signed in. Promo credits can only be added to a brand-new account. | ||
| </Paragraph> | ||
| <LinkButton to="/" variant="secondary/medium"> | ||
| Go to dashboard | ||
| </LinkButton> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing> | ||
| {data.view === "valid" ? `Claim ${formatDollars(data.amountInCents)} credits` : "Create your account"} | ||
| </Header2> | ||
| {data.view === "valid" ? ( | ||
| <Paragraph variant="base" spacing> | ||
| These are only available for new accounts. The credits expire on {formatExpiry(data.expiresAt)}. | ||
| </Paragraph> | ||
| ) : ( | ||
| <Callout variant="warning" className="mb-6 w-full"> | ||
| That promo code isn't valid. You can still sign up below but credits won't be | ||
| added. | ||
| </Callout> | ||
| )} | ||
| <SignInForm showGithubAuth={data.showGithubAuth} showGoogleAuth={data.showGoogleAuth} /> | ||
| </> | ||
| )} | ||
| </div> | ||
| </LoginPageLayout> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,7 +34,8 @@ import { prisma } from "~/db.server"; | |
| import { redirectWithErrorMessage } from "~/models/message.server"; | ||
| import { resolveOrgIdFromSlug } from "~/models/organization.server"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { setPlan } from "~/services/platform.v3.server"; | ||
| import { applyPromoCode, setPlan } from "~/services/platform.v3.server"; | ||
| import { clearPromoCodeCookie, getPromoCodeFromCookie } from "~/services/promoCode.server"; | ||
| import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; | ||
| import { engine } from "~/v3/runEngine.server"; | ||
| import { cn } from "~/utils/cn"; | ||
|
|
@@ -153,9 +154,24 @@ export const action = dashboardAction( | |
| } | ||
| } | ||
|
|
||
| return await setPlan(organization, request, form.callerPath, payload, { | ||
| const result = await setPlan(organization, request, form.callerPath, payload, { | ||
| invalidateBillingCache: engine.invalidateBillingCache.bind(engine), | ||
| }); | ||
|
|
||
| // Redeem a promo code carried from the /promo landing page now that selecting | ||
| // a plan has provisioned the org's usage entitlement (the grant target). | ||
| // Best-effort: it must never change the plan-selection outcome. | ||
| if (form.type === "free") { | ||
| const promoCode = await getPromoCodeFromCookie(request); | ||
| if (promoCode) { | ||
| const applied = await applyPromoCode(organization.id, user.id, promoCode); | ||
| if (applied?.applied) { | ||
| result.headers.append("Set-Cookie", await clearPromoCodeCookie()); | ||
| } | ||
| } | ||
|
matt-aitken marked this conversation as resolved.
|
||
| } | ||
|
Comment on lines
+164
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Promo credits are silently lost when a new user selects a paid plan instead of the free plan Promo code redemption is only attempted when the user selects the free plan ( Impact: Users arriving from the promo landing page who choose a paid plan permanently lose their promotional credits. Full mechanism: promo cookie is set unconditionally but only consumed on the free-plan path
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| return result; | ||
|
matt-aitken marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| ); | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.