Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/promo-credits.md
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.
47 changes: 29 additions & 18 deletions apps/webapp/app/components/LoginPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ const quotes: QuoteType[] = [
},
];

export function LoginPageLayout({ children }: { children: React.ReactNode }) {
export function LoginPageLayout({
children,
rightContent,
}: {
children: React.ReactNode;
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
rightContent?: React.ReactNode;
}) {
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
useEffect(() => {
const randomIndex = Math.floor(Math.random() * quotes.length);
Expand Down Expand Up @@ -69,23 +76,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
</div>
</div>
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
<div className="flex h-full flex-col items-center justify-center px-16">
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
{randomQuote?.quote}
</Header3>
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
</div>
<div className="flex flex-col items-center gap-4 px-8">
<Paragraph>Trusted by developers at</Paragraph>
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-charcoal-500 xl:justify-between xl:gap-0">
<LyftLogo className="w-11" />
<UnkeyLogo />
<MiddayLogo />
<AppsmithLogo />
<CalComLogo />
<TldrawLogo />
</div>
</div>
{rightContent ?? (
<>
<div className="flex h-full flex-col items-center justify-center px-16">
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
{randomQuote?.quote}
</Header3>
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
</div>
<div className="flex flex-col items-center gap-4 px-8">
<Paragraph>Trusted by developers at</Paragraph>
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-charcoal-500 xl:justify-between xl:gap-0">
<LyftLogo className="w-11" />
<UnkeyLogo />
<MiddayLogo />
<AppsmithLogo />
<CalComLogo />
<TldrawLogo />
</div>
</div>
</>
)}
</div>
</main>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useSearchParams } from "~/hooks/useSearchParam";
import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
import { getPromoCredits } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { useBillingLimit } from "~/hooks/useOrganizations";
Expand Down Expand Up @@ -81,22 +82,35 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
startDate,
});

// Credit-grant balance (promo now, other grant types later). Cheap + cached +
// fails to null, and applies to any org with grants — not gated on plan tier.
const promoCredits = await getPromoCredits(organization.id);

return typeddefer({
usage,
tasks,
months,
isCurrentMonth: startDate.toISOString() === months[0].toISOString(),
promoCredits,
});
}

const creditExpiryFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "utc",
});

const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "utc",
});

export default function Page() {
const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData<typeof loader>();
const { usage, tasks, months, isCurrentMonth, promoCredits } =
useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const billingLimit = useBillingLimit();
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0;
Expand Down Expand Up @@ -139,6 +153,45 @@ export default function Page() {
))
}
</Select>
{promoCredits && (
<div className="flex flex-col gap-1 border-t border-grid-dimmed p-3">
<div className="flex items-end gap-8">
<div className="flex flex-col gap-1">
<Header2 className="whitespace-nowrap">Promo credits</Header2>
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
{formatCurrency(promoCredits.remainingCents / 100, false)}
</p>
</div>
<div className="flex w-full flex-1 flex-col gap-1 pb-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-charcoal-700">
<div
className="h-full rounded-full bg-blue-500"
style={{
width: `${
promoCredits.grantedCents > 0
? Math.min(
100,
Math.max(
0,
(promoCredits.remainingCents / promoCredits.grantedCents) * 100
)
)
: 0
}%`,
}}
/>
</div>
<Paragraph variant="extra-small" className="text-text-dimmed">
{formatCurrency(promoCredits.remainingCents / 100, false)} of{" "}
{formatCurrency(promoCredits.grantedCents / 100, false)} remaining
{promoCredits.expiresAt
? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}`
: ""}
</Paragraph>
</div>
</div>
</div>
)}
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
<Suspense fallback={<Spinner />}>
<Await
Expand Down Expand Up @@ -171,6 +224,7 @@ export default function Page() {
</Suspense>
</div>
</div>

<div className="px-3">
<Card>
<Card.Header>Usage by day</Card.Header>
Expand Down
7 changes: 5 additions & 2 deletions apps/webapp/app/routes/_app.orgs.new/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export const action: ActionFunction = async ({ request }) => {
avatar,
});

// A promo code carried over from the /promo landing page (via cookie) is
// redeemed later, once the org is activated through plan selection and its
// usage entitlement exists — not here, where there's nothing to grant onto.

const url = new URL(request.url);
const code = url.searchParams.get("code");
const configurationId = url.searchParams.get("configurationId");
Expand All @@ -94,8 +98,7 @@ export const action: ActionFunction = async ({ request }) => {
if (next) {
params.set("next", next);
}
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
return redirect(redirectUrl);
return redirect(`${organizationPath(organization)}/projects/new?${params.toString()}`);
}

return redirect(organizationPath(organization));
Expand Down
173 changes: 173 additions & 0 deletions apps/webapp/app/routes/promo.tsx
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>
Comment thread
matt-aitken marked this conversation as resolved.
Outdated
) : (
<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
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
}
}
Comment thread
matt-aitken marked this conversation as resolved.
}
Comment on lines +164 to +173

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 (form.type === "free" at resources.orgs.$organizationSlug.select-plan.tsx:164), but the cookie is set for all users who visit the promo landing page regardless of which plan they will choose, so users who select a paid plan never have their promo credits applied.

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
  1. The /promo loader (promo.tsx:56) sets the promo-code cookie for every valid code, regardless of which plan the user will later choose.
  2. After signup and org creation, the user lands on the plan-selection page.
  3. In the select-plan action (resources.orgs.$organizationSlug.select-plan.tsx:157-175), setPlan is called, and then the promo-code block only runs when form.type === "free" (line 164).
  4. For paid plans (form.type === "paid"), setPlan returns a redirect to Stripe checkout (create_subscription_flow_start). The promo code block is skipped entirely.
  5. After Stripe checkout completes and the subscription is provisioned (via webhook), there is no code in this PR that reads the promo-code cookie or calls applyPromoCode.
  6. The cookie expires after 1 hour (promoCode.server.ts:8), so the promo code is permanently lost.
Prompt for agents
The promo code redemption block in the select-plan action (lines 164-173) is gated on form.type === 'free', which means paid plan users from the /promo landing page never get their promo credits applied. The cookie is set unconditionally in promo.tsx:56 for all valid codes.

To fix this, you need to handle promo code redemption for paid plans as well. There are two approaches:

1. Also attempt promo code redemption for paid plans in this action. For the 'create_subscription_flow_start' case, the subscription isn't provisioned yet (user goes to Stripe checkout), so you'd need to apply the promo code in the Stripe webhook handler or the return-from-checkout callback route. The cookie should still be present in the browser when Stripe redirects back.

2. If promo codes are intentionally only for free-plan users, update the /promo landing page (promo.tsx) to communicate this restriction to users, and don't set the cookie if the intent is free-only.

The first approach is more likely correct given the promo page makes no mention of plan restrictions. You'd need to find the Stripe checkout success webhook/callback handler and add promo code reading and application there, similar to the pattern at lines 165-171.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return result;
Comment thread
matt-aitken marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
);

Expand Down
Loading
Loading