Skip to content

Commit 48a0b83

Browse files
authored
feat(webapp): promo credits — /promo signup landing, redeem at plan selection, usage display (#4138)
## What & why Signup promo credits. A new logged-out `/promo?code=<code>` landing page validates the code and carries it through signup via a cookie. When the new organization is activated by selecting a plan, the code is redeemed and its credits are applied; the usage page then shows the remaining promo credits and their expiry. ## Notes - The code is redeemed at **plan selection**, not org creation: the credit grant targets the org's usage allowance, which only exists once a plan is selected — applying at creation would have nothing to grant onto. Redemption is best-effort and never blocks plan selection. - Pairs with the corresponding billing-service change (promo code validate/apply/credits + grant issuance); the two are released together. ## Testing Verified locally end to end: `/promo` shows the offer, a new account carries the code through signup, selecting the Free plan redeems it, and the usage page shows the remaining credits. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 983bd03 commit 48a0b83

10 files changed

Lines changed: 506 additions & 52 deletions

File tree

.server-changes/promo-credits.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

apps/webapp/app/components/LoginPageLayout.tsx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ const quotes: QuoteType[] = [
3636
},
3737
];
3838

39-
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
39+
export function LoginPageLayout({
40+
children,
41+
rightContent,
42+
}: {
43+
children: React.ReactNode;
44+
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
45+
rightContent?: React.ReactNode;
46+
}) {
4047
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
4148
useEffect(() => {
4249
const randomIndex = Math.floor(Math.random() * quotes.length);
@@ -62,23 +69,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
6269
</div>
6370
</div>
6471
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
65-
<div className="flex h-full flex-col items-center justify-center px-16">
66-
<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">
67-
{randomQuote?.quote}
68-
</Header3>
69-
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
70-
</div>
71-
<div className="flex flex-col items-center gap-4 px-8">
72-
<Paragraph>Trusted by developers at</Paragraph>
73-
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
74-
<LyftLogo className="w-11" />
75-
<UnkeyLogo />
76-
<MiddayLogo />
77-
<AppsmithLogo />
78-
<CalComLogo />
79-
<TldrawLogo />
80-
</div>
81-
</div>
72+
{rightContent ?? (
73+
<>
74+
<div className="flex h-full flex-col items-center justify-center px-16">
75+
<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">
76+
{randomQuote?.quote}
77+
</Header3>
78+
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
79+
</div>
80+
<div className="flex flex-col items-center gap-4 px-8">
81+
<Paragraph>Trusted by developers at</Paragraph>
82+
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
83+
<LyftLogo className="w-11" />
84+
<UnkeyLogo />
85+
<MiddayLogo />
86+
<AppsmithLogo />
87+
<CalComLogo />
88+
<TldrawLogo />
89+
</div>
90+
</div>
91+
</>
92+
)}
8293
</div>
8394
</main>
8495
);

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { prisma } from "~/db.server";
2929
import { featuresForRequest } from "~/features.server";
3030
import { useSearchParams } from "~/hooks/useSearchParam";
3131
import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
32+
import { getPromoCredits } from "~/services/platform.v3.server";
3233
import { requireUserId } from "~/services/session.server";
3334
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
3435
import { useBillingLimit } from "~/hooks/useOrganizations";
@@ -81,22 +82,35 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
8182
startDate,
8283
});
8384

85+
// Credit-grant balance (promo now, other grant types later). Cheap + cached +
86+
// fails to null, and applies to any org with grants — not gated on plan tier.
87+
const promoCredits = await getPromoCredits(organization.id);
88+
8489
return typeddefer({
8590
usage,
8691
tasks,
8792
months,
8893
isCurrentMonth: startDate.toISOString() === months[0].toISOString(),
94+
promoCredits,
8995
});
9096
}
9197

98+
const creditExpiryFormatter = new Intl.DateTimeFormat("en-US", {
99+
month: "short",
100+
day: "numeric",
101+
year: "numeric",
102+
timeZone: "utc",
103+
});
104+
92105
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
93106
month: "long",
94107
year: "numeric",
95108
timeZone: "utc",
96109
});
97110

98111
export default function Page() {
99-
const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData<typeof loader>();
112+
const { usage, tasks, months, isCurrentMonth, promoCredits } =
113+
useTypedLoaderData<typeof loader>();
100114
const currentPlan = useCurrentPlan();
101115
const billingLimit = useBillingLimit();
102116
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0;
@@ -139,6 +153,45 @@ export default function Page() {
139153
))
140154
}
141155
</Select>
156+
{promoCredits && (
157+
<div className="flex flex-col gap-1 border-t border-grid-dimmed p-3">
158+
<div className="flex items-end gap-8">
159+
<div className="flex flex-col gap-1">
160+
<Header2 className="whitespace-nowrap">Promo credits</Header2>
161+
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
162+
{formatCurrency(promoCredits.remainingCents / 100, false)}
163+
</p>
164+
</div>
165+
<div className="flex w-full flex-1 flex-col gap-1 pb-1">
166+
<div className="h-2 w-full overflow-hidden rounded-full bg-charcoal-700">
167+
<div
168+
className="h-full rounded-full bg-blue-500"
169+
style={{
170+
width: `${
171+
promoCredits.grantedCents > 0
172+
? Math.min(
173+
100,
174+
Math.max(
175+
0,
176+
(promoCredits.remainingCents / promoCredits.grantedCents) * 100
177+
)
178+
)
179+
: 0
180+
}%`,
181+
}}
182+
/>
183+
</div>
184+
<Paragraph variant="extra-small" className="text-text-dimmed">
185+
{formatCurrency(promoCredits.remainingCents / 100, false)} of{" "}
186+
{formatCurrency(promoCredits.grantedCents / 100, false)} remaining
187+
{promoCredits.expiresAt
188+
? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}`
189+
: ""}
190+
</Paragraph>
191+
</div>
192+
</div>
193+
</div>
194+
)}
142195
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
143196
<Suspense fallback={<Spinner />}>
144197
<Await
@@ -171,6 +224,7 @@ export default function Page() {
171224
</Suspense>
172225
</div>
173226
</div>
227+
174228
<div className="px-3">
175229
<Card>
176230
<Card.Header>Usage by day</Card.Header>

apps/webapp/app/routes/_app.orgs.new/route.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export const action: ActionFunction = async ({ request }) => {
7979
avatar,
8080
});
8181

82+
// A promo code carried over from the /promo landing page (via cookie) is
83+
// redeemed later, once the org is activated through plan selection and its
84+
// usage entitlement exists — not here, where there's nothing to grant onto.
85+
8286
const url = new URL(request.url);
8387
const code = url.searchParams.get("code");
8488
const configurationId = url.searchParams.get("configurationId");
@@ -94,8 +98,7 @@ export const action: ActionFunction = async ({ request }) => {
9498
if (next) {
9599
params.set("next", next);
96100
}
97-
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
98-
return redirect(redirectUrl);
101+
return redirect(`${organizationPath(organization)}/projects/new?${params.toString()}`);
99102
}
100103

101104
return redirect(organizationPath(organization));

apps/webapp/app/routes/promo.tsx

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { EnvelopeIcon } from "@heroicons/react/20/solid";
2+
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
3+
import { Form } from "@remix-run/react";
4+
import { GitHubLightIcon } from "@trigger.dev/companyicons";
5+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
6+
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
7+
import { LoginPageLayout } from "~/components/LoginPageLayout";
8+
import { Button, LinkButton } from "~/components/primitives/Buttons";
9+
import { Callout } from "~/components/primitives/Callout";
10+
import { Fieldset } from "~/components/primitives/Fieldset";
11+
import { Header2 } from "~/components/primitives/Headers";
12+
import { Paragraph } from "~/components/primitives/Paragraph";
13+
import { TextLink } from "~/components/primitives/TextLink";
14+
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
15+
import { validatePromoCode } from "~/services/platform.v3.server";
16+
import { setPromoCodeCookie } from "~/services/promoCode.server";
17+
import { getUserId } from "~/services/session.server";
18+
import { requestUrl } from "~/utils/requestUrl.server";
19+
20+
export const meta: MetaFunction = () => [{ title: "Claim your Trigger.dev credits" }];
21+
22+
export async function loader({ request }: LoaderFunctionArgs) {
23+
const userId = await getUserId(request);
24+
const url = requestUrl(request);
25+
const code = url.searchParams.get("code")?.trim() || null;
26+
27+
const authMethods = {
28+
showGithubAuth: isGithubAuthSupported,
29+
showGoogleAuth: isGoogleAuthSupported,
30+
};
31+
32+
// Credits are only granted to brand-new accounts, so an already-signed-in
33+
// user can't redeem a code.
34+
if (userId) {
35+
return typedjson({ view: "signed_in" as const, ...authMethods });
36+
}
37+
38+
if (!code) {
39+
return typedjson({ view: "invalid" as const, ...authMethods });
40+
}
41+
42+
const validated = await validatePromoCode(code);
43+
if (!validated || !validated.valid) {
44+
return typedjson({ view: "invalid" as const, ...authMethods });
45+
}
46+
47+
// Stash the code so it survives the OAuth round-trip and can be applied once
48+
// the new org selects a plan.
49+
return typedjson(
50+
{
51+
view: "valid" as const,
52+
amountInCents: validated.amountInCents ?? 0,
53+
expiresAt: validated.expiresAt ?? null,
54+
...authMethods,
55+
},
56+
{ headers: { "Set-Cookie": await setPromoCodeCookie(code) } }
57+
);
58+
}
59+
60+
function formatDollars(cents: number) {
61+
const dollars = cents / 100;
62+
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
63+
}
64+
65+
function formatExpiry(iso: string | null) {
66+
if (!iso) return null;
67+
const date = new Date(iso);
68+
if (Number.isNaN(date.getTime())) return null;
69+
return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
70+
}
71+
72+
function SignInForm({
73+
showGithubAuth,
74+
showGoogleAuth,
75+
}: {
76+
showGithubAuth: boolean;
77+
showGoogleAuth: boolean;
78+
}) {
79+
return (
80+
<Fieldset className="w-full">
81+
<div className="flex flex-col items-center gap-y-3">
82+
{showGithubAuth && (
83+
<Form action="/auth/github" method="post" className="w-full">
84+
<Button
85+
type="submit"
86+
variant="secondary/extra-large"
87+
fullWidth
88+
data-action="continue with github"
89+
>
90+
<GitHubLightIcon className="mr-2 size-5" />
91+
<span className="text-text-bright">Continue with GitHub</span>
92+
</Button>
93+
</Form>
94+
)}
95+
{showGoogleAuth && (
96+
<Form action="/auth/google" method="post" className="w-full">
97+
<Button
98+
type="submit"
99+
variant="secondary/extra-large"
100+
fullWidth
101+
data-action="continue with google"
102+
>
103+
<GoogleLogo className="mr-2 size-5" />
104+
<span className="text-text-bright">Continue with Google</span>
105+
</Button>
106+
</Form>
107+
)}
108+
<LinkButton
109+
to="/login/magic"
110+
variant="secondary/extra-large"
111+
fullWidth
112+
data-action="continue with email"
113+
className="text-text-bright"
114+
>
115+
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
116+
Continue with Email
117+
</LinkButton>
118+
</div>
119+
<Paragraph variant="extra-small" className="mt-2 text-center">
120+
By signing up you agree to our{" "}
121+
<TextLink href="https://trigger.dev/legal" target="_blank">
122+
terms
123+
</TextLink>{" "}
124+
and{" "}
125+
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
126+
privacy
127+
</TextLink>{" "}
128+
policy.
129+
</Paragraph>
130+
</Fieldset>
131+
);
132+
}
133+
134+
export default function PromoPage() {
135+
const data = useTypedLoaderData<typeof loader>();
136+
137+
return (
138+
<LoginPageLayout>
139+
<div className="flex w-full flex-col">
140+
{data.view === "signed_in" ? (
141+
<>
142+
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
143+
Promo codes are for new accounts
144+
</Header2>
145+
<Paragraph variant="base" spacing>
146+
You're already signed in. Promo credits can only be added to a brand-new account.
147+
</Paragraph>
148+
<LinkButton to="/" variant="secondary/medium">
149+
Go to dashboard
150+
</LinkButton>
151+
</>
152+
) : (
153+
<>
154+
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
155+
{data.view === "valid"
156+
? `Claim ${formatDollars(data.amountInCents)} credits`
157+
: "Create your account"}
158+
</Header2>
159+
{data.view === "valid" ? (
160+
<Paragraph variant="base" spacing>
161+
These are only available for new accounts on the Free plan.
162+
{formatExpiry(data.expiresAt)
163+
? ` The credits expire on ${formatExpiry(data.expiresAt)}.`
164+
: ""}
165+
</Paragraph>
166+
) : (
167+
<Callout variant="warning" className="mb-6 w-full">
168+
That promo code isn't valid. You can still sign up below but credits won't be added.
169+
</Callout>
170+
)}
171+
<SignInForm showGithubAuth={data.showGithubAuth} showGoogleAuth={data.showGoogleAuth} />
172+
</>
173+
)}
174+
</div>
175+
</LoginPageLayout>
176+
);
177+
}

0 commit comments

Comments
 (0)