Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
177 changes: 177 additions & 0 deletions apps/webapp/app/routes/promo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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 once
// the new org selects a plan.
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.
{formatExpiry(data.expiresAt)
? ` 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>
);
}
Loading
Loading