Skip to content

Commit 14ba919

Browse files
committed
feat(webapp): promo credits
Add a /promo signup landing page that carries a promo code through signup via cookie, redeem it once the org is activated by selecting a plan (when its usage entitlement exists), and show remaining promo credits on the usage page.
1 parent fd4f02b commit 14ba919

8 files changed

Lines changed: 484 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, applying a promo code when an org is created, 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
@@ -38,7 +38,14 @@ const quotes: QuoteType[] = [
3838
},
3939
];
4040

41-
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
41+
export function LoginPageLayout({
42+
children,
43+
rightContent,
44+
}: {
45+
children: React.ReactNode;
46+
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
47+
rightContent?: React.ReactNode;
48+
}) {
4249
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
4350
useEffect(() => {
4451
const randomIndex = Math.floor(Math.random() * quotes.length);
@@ -69,23 +76,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
6976
</div>
7077
</div>
7178
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
72-
<div className="flex h-full flex-col items-center justify-center px-16">
73-
<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">
74-
{randomQuote?.quote}
75-
</Header3>
76-
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
77-
</div>
78-
<div className="flex flex-col items-center gap-4 px-8">
79-
<Paragraph>Trusted by developers at</Paragraph>
80-
<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">
81-
<LyftLogo className="w-11" />
82-
<UnkeyLogo />
83-
<MiddayLogo />
84-
<AppsmithLogo />
85-
<CalComLogo />
86-
<TldrawLogo />
87-
</div>
88-
</div>
79+
{rightContent ?? (
80+
<>
81+
<div className="flex h-full flex-col items-center justify-center px-16">
82+
<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">
83+
{randomQuote?.quote}
84+
</Header3>
85+
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
86+
</div>
87+
<div className="flex flex-col items-center gap-4 px-8">
88+
<Paragraph>Trusted by developers at</Paragraph>
89+
<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">
90+
<LyftLogo className="w-11" />
91+
<UnkeyLogo />
92+
<MiddayLogo />
93+
<AppsmithLogo />
94+
<CalComLogo />
95+
<TldrawLogo />
96+
</div>
97+
</div>
98+
</>
99+
)}
89100
</div>
90101
</main>
91102
);

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: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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 { Header1, Header2, Header3 } 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 when
48+
// the new org is created.
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+
function PromoPanel({
135+
amountInCents,
136+
expiresAt,
137+
}: {
138+
amountInCents: number;
139+
expiresAt: string | null;
140+
}) {
141+
const expiry = formatExpiry(expiresAt);
142+
return (
143+
<div className="flex h-full flex-col items-center justify-center px-16">
144+
<Paragraph variant="small" className="uppercase tracking-wide text-text-dimmed">
145+
Promo applied
146+
</Paragraph>
147+
<Header3 className="mt-3 text-center text-5xl font-semibold text-text-bright">
148+
{formatDollars(amountInCents)} in free credits
149+
</Header3>
150+
<Paragraph className="mt-4 text-center text-text-dimmed">
151+
Added to your new Trigger.dev account when you sign up
152+
{expiry ? `, valid until ${expiry}` : ""}.
153+
</Paragraph>
154+
</div>
155+
);
156+
}
157+
158+
export default function PromoPage() {
159+
const data = useTypedLoaderData<typeof loader>();
160+
161+
const rightContent =
162+
data.view === "valid" ? (
163+
<PromoPanel amountInCents={data.amountInCents} expiresAt={data.expiresAt} />
164+
) : undefined;
165+
166+
return (
167+
<LoginPageLayout rightContent={rightContent}>
168+
<div className="flex w-full flex-col">
169+
{data.view === "signed_in" ? (
170+
<>
171+
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
172+
Promo codes are for new accounts
173+
</Header2>
174+
<Paragraph variant="base" spacing>
175+
You're already signed in. Promo credits can only be added to a brand-new account.
176+
</Paragraph>
177+
<LinkButton to="/" variant="secondary/medium">
178+
Go to dashboard
179+
</LinkButton>
180+
</>
181+
) : (
182+
<>
183+
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
184+
{data.view === "valid" ? "Claim your credits" : "Create your account"}
185+
</Header2>
186+
<Paragraph variant="base" spacing>
187+
Create an account or login
188+
</Paragraph>
189+
{data.view === "invalid" && (
190+
<Callout variant="warning" className="mb-6 w-full">
191+
That promo code isn't valid. You can still sign up below — credits just won't be
192+
added.
193+
</Callout>
194+
)}
195+
<SignInForm showGithubAuth={data.showGithubAuth} showGoogleAuth={data.showGoogleAuth} />
196+
</>
197+
)}
198+
</div>
199+
</LoginPageLayout>
200+
);
201+
}

0 commit comments

Comments
 (0)