Skip to content
Open
Show file tree
Hide file tree
Changes from all 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