From 14ba919b5fa3fd333aa1ec9d50cd7b33640e12d9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 2 Jul 2026 16:13:03 +0100 Subject: [PATCH 1/5] 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. --- .server-changes/promo-credits.md | 6 + .../webapp/app/components/LoginPageLayout.tsx | 47 ++-- .../route.tsx | 56 ++++- .../webapp/app/routes/_app.orgs.new/route.tsx | 7 +- apps/webapp/app/routes/promo.tsx | 201 ++++++++++++++++++ ...ces.orgs.$organizationSlug.select-plan.tsx | 20 +- .../webapp/app/services/platform.v3.server.ts | 173 ++++++++++++--- apps/webapp/app/services/promoCode.server.ts | 26 +++ 8 files changed, 484 insertions(+), 52 deletions(-) create mode 100644 .server-changes/promo-credits.md create mode 100644 apps/webapp/app/routes/promo.tsx create mode 100644 apps/webapp/app/services/promoCode.server.ts diff --git a/.server-changes/promo-credits.md b/.server-changes/promo-credits.md new file mode 100644 index 00000000000..c03885fa337 --- /dev/null +++ b/.server-changes/promo-credits.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Promo credits: a /promo signup landing page, applying a promo code when an org is created, and showing remaining credits on the usage page. diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index 323faf4ea26..63fbafc57e6 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -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(null); useEffect(() => { const randomIndex = Math.floor(Math.random() * quotes.length); @@ -69,23 +76,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
-
- - {randomQuote?.quote} - - {randomQuote?.person} -
-
- Trusted by developers at -
- - - - - - -
-
+ {rightContent ?? ( + <> +
+ + {randomQuote?.quote} + + {randomQuote?.person} +
+
+ Trusted by developers at +
+ + + + + + +
+
+ + )}
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index fb0c5d4d83f..2cc8b01110b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -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"; @@ -81,14 +82,26 @@ 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", @@ -96,7 +109,8 @@ const monthDateFormatter = new Intl.DateTimeFormat("en-US", { }); export default function Page() { - const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData(); + const { usage, tasks, months, isCurrentMonth, promoCredits } = + useTypedLoaderData(); const currentPlan = useCurrentPlan(); const billingLimit = useBillingLimit(); const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0; @@ -139,6 +153,45 @@ export default function Page() { )) } + {promoCredits && ( +
+
+
+ Promo credits +

+ {formatCurrency(promoCredits.remainingCents / 100, false)} +

+
+
+
+
0 + ? Math.min( + 100, + Math.max( + 0, + (promoCredits.remainingCents / promoCredits.grantedCents) * 100 + ) + ) + : 0 + }%`, + }} + /> +
+ + {formatCurrency(promoCredits.remainingCents / 100, false)} of{" "} + {formatCurrency(promoCredits.grantedCents / 100, false)} remaining + {promoCredits.expiresAt + ? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}` + : ""} + +
+
+
+ )}
}>
+
Usage by day diff --git a/apps/webapp/app/routes/_app.orgs.new/route.tsx b/apps/webapp/app/routes/_app.orgs.new/route.tsx index d917265d9e6..462621dd34d 100644 --- a/apps/webapp/app/routes/_app.orgs.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.new/route.tsx @@ -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"); @@ -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)); diff --git a/apps/webapp/app/routes/promo.tsx b/apps/webapp/app/routes/promo.tsx new file mode 100644 index 00000000000..dcdc8514e19 --- /dev/null +++ b/apps/webapp/app/routes/promo.tsx @@ -0,0 +1,201 @@ +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 { Header1, Header2, Header3 } 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 ( +
+
+ {showGithubAuth && ( +
+ +
+ )} + {showGoogleAuth && ( +
+ +
+ )} + + + Continue with Email + +
+ + By signing up you agree to our{" "} + + terms + {" "} + and{" "} + + privacy + {" "} + policy. + +
+ ); +} + +function PromoPanel({ + amountInCents, + expiresAt, +}: { + amountInCents: number; + expiresAt: string | null; +}) { + const expiry = formatExpiry(expiresAt); + return ( +
+ + Promo applied + + + {formatDollars(amountInCents)} in free credits + + + Added to your new Trigger.dev account when you sign up + {expiry ? `, valid until ${expiry}` : ""}. + +
+ ); +} + +export default function PromoPage() { + const data = useTypedLoaderData(); + + const rightContent = + data.view === "valid" ? ( + + ) : undefined; + + return ( + +
+ {data.view === "signed_in" ? ( + <> + + Promo codes are for new accounts + + + You're already signed in. Promo credits can only be added to a brand-new account. + + + Go to dashboard + + + ) : ( + <> + + {data.view === "valid" ? "Claim your credits" : "Create your account"} + + + Create an account or login + + {data.view === "invalid" && ( + + That promo code isn't valid. You can still sign up below — credits just won't be + added. + + )} + + + )} +
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 02bd91cb9d5..f8ca254e696 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -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"; @@ -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()); + } + } + } + + return result; } ); diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 616e2a011a8..9dce7830f3c 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1,5 +1,3 @@ -import { createLRUMemoryStore } from "@internal/cache"; -import { metrics } from "@opentelemetry/api"; import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { @@ -8,7 +6,6 @@ import { machines as machinesFromPlatform, type BillingAlertsResult, type CreatePrivateLinkConnectionBody, - type CurrentPlan, type Limits, type MachineCode, type PrivateLinkConnection, @@ -18,20 +15,16 @@ import { type UpdateBillingAlertsRequest, type UsageResult, type UsageSeriesParams, + type CurrentPlan, } from "@trigger.dev/platform"; -import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; -import { existsSync, readFileSync } from "node:fs"; -import { redirect } from "remix-typedjson"; -import { z } from "zod"; -import { $replica } from "~/db.server"; -import { env } from "~/env.server"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { - asPlatformSchema, BillingLimitResultSchema, BillingLimitsActiveResultSchema, BillingLimitsPendingResolvesResultSchema, EntitlementResultSchema, + ResolveBillingLimitRequestSchema, + UpdateBillingLimitRequestSchema, + asPlatformSchema, type BillingLimitResult, type BillingLimitsActiveResult, type BillingLimitsPendingResolvesResult, @@ -39,10 +32,19 @@ import { type ResolveBillingLimitRequest, type UpdateBillingLimitRequest, } from "~/services/billingLimit.schemas"; +import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache"; +import { createLRUMemoryStore } from "@internal/cache"; +import { existsSync, readFileSync } from "node:fs"; +import { redirect } from "remix-typedjson"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder"; import { singleton } from "~/utils/singleton"; import { RedisCacheStore } from "./unkey/redisCacheStore.server"; +import { $replica } from "~/db.server"; +import { metrics } from "@opentelemetry/api"; function initializeClient() { if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) { @@ -82,6 +84,78 @@ function recordPlatformFailure(fn: string, kind: "caught" | "no_success") { platformClientFailuresCounter.add(1, { function: fn, kind }); } +export type ValidatedPromoCode = { + valid: boolean; + amountInCents?: number; + expiresAt?: string | null; +}; + +/** + * Validate a promo code (no org context). Returns `undefined` when billing + * isn't configured or the call fails, so callers fall back to treating the + * code as not-yet-validated rather than crashing the page. + */ +export async function validatePromoCode(code: string): Promise { + if (!client) { + return undefined; + } + + const [error, result] = await tryCatch(client.validatePromoCode(code)); + if (error) { + recordPlatformFailure("validatePromoCode", "caught"); + logger.error("validatePromoCode threw", { error }); + return undefined; + } + if (!result.success) { + recordPlatformFailure("validatePromoCode", "no_success"); + return undefined; + } + + return { + valid: result.valid, + amountInCents: result.amountInCents, + expiresAt: result.expiresAt, + }; +} + +export type AppliedPromoCode = { + applied: boolean; + amountInCents?: number; + reason?: string; +}; + +/** + * Apply a promo code to a newly created org. Returns `undefined` when billing + * isn't configured or the call fails — callers treat that as "not applied" and + * must never block org creation on it. + */ +export async function applyPromoCode( + orgId: string, + userId: string, + code: string +): Promise { + if (!client) { + return undefined; + } + + const [error, result] = await tryCatch(client.applyPromoCode(orgId, { code, userId })); + if (error) { + recordPlatformFailure("applyPromoCode", "caught"); + logger.error("applyPromoCode threw", { error }); + return undefined; + } + if (!result.success) { + recordPlatformFailure("applyPromoCode", "no_success"); + return undefined; + } + + return { + applied: result.applied, + amountInCents: result.amountInCents, + reason: result.reason, + }; +} + function initializePlatformCache() { const ctx = new DefaultStatefulContext(); const memory = createLRUMemoryStore(1000); @@ -119,6 +193,11 @@ function initializePlatformCache() { fresh: 60_000, stale: 120_000, }), + promoCredits: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, + stale: 120_000, + }), }); return cache; @@ -273,7 +352,7 @@ export async function getCurrentPlan(orgId: string) { }; return { ...result, usage }; - } catch (_e) { + } catch (e) { recordPlatformFailure("getCurrentPlan", "caught"); return undefined; } @@ -314,7 +393,7 @@ export async function getLimits(orgId: string) { } return result.v3Subscription?.plan?.limits; - } catch (_e) { + } catch (e) { recordPlatformFailure("getLimits", "caught"); return undefined; } @@ -390,7 +469,7 @@ export async function customerPortalUrl(orgId: string, orgSlug: string) { return client.createPortalSession(orgId, { returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`, }); - } catch (_e) { + } catch (e) { recordPlatformFailure("customerPortalUrl", "caught"); return undefined; } @@ -406,7 +485,7 @@ export async function getPlans() { return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("getPlans", "caught"); return undefined; } @@ -477,7 +556,7 @@ export async function setConcurrencyAddOn(organizationId: string, amount: number return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("setConcurrencyAddOn", "caught"); return undefined; } @@ -493,7 +572,7 @@ export async function setSeatsAddOn(organizationId: string, amount: number) { return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("setSeatsAddOn", "caught"); return undefined; } @@ -509,7 +588,7 @@ export async function setBranchesAddOn(organizationId: string, amount: number) { return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("setBranchesAddOn", "caught"); return undefined; } @@ -525,7 +604,7 @@ export async function setSchedulesAddOn(organizationId: string, amount: number) return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("setSchedulesAddOn", "caught"); return undefined; } @@ -541,7 +620,7 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("getUsage", "caught"); return undefined; } @@ -564,7 +643,7 @@ export async function getCachedUsage( ); return result.val; - } catch (_e) { + } catch (e) { recordPlatformFailure("getCachedUsage", "caught"); return undefined; } @@ -580,7 +659,7 @@ export async function getUsageSeries(organizationId: string, params: UsageSeries return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("getUsageSeries", "caught"); return undefined; } @@ -604,7 +683,7 @@ export async function reportInvocationUsage( return undefined; } return result; - } catch (_e) { + } catch (e) { recordPlatformFailure("reportInvocationUsage", "caught"); return undefined; } @@ -643,7 +722,7 @@ export async function getEntitlement( return undefined; } return response; - } catch (_e) { + } catch (e) { recordPlatformFailure("getEntitlement", "caught"); return undefined; } @@ -658,6 +737,42 @@ export async function getEntitlement( return result.val; } +export type PromoCreditsData = { + grantedCents: number; + remainingCents: number; + expiresAt: string | null; +}; + +/** + * Remaining promo/credit-grant balance for an org, or null when it has none. + * Billing-side gating keeps this cheap for orgs without credits; the SWR cache + * keeps repeated dashboard loads off the network. Fails closed to null so the + * display is simply hidden on any error — never blocks the page. + */ +export async function getPromoCredits(organizationId: string): Promise { + if (!client) return null; + + const result = await platformCache.promoCredits.swr(organizationId, async () => { + try { + const response = await client.promoCredits(organizationId); + if (!response.success) { + recordPlatformFailure("promoCredits", "no_success"); + return null; + } + return response.promoCredits; + } catch (e) { + recordPlatformFailure("promoCredits", "caught"); + logger.error("promoCredits threw", { error: e }); + return null; + } + }); + + if (result.err || result.val === undefined) { + return null; + } + return result.val; +} + export async function getBillingLimit( organizationId: string ): Promise { @@ -677,7 +792,7 @@ export async function getBillingLimit( return undefined; } return response; - } catch (_e) { + } catch (e) { recordPlatformFailure("getBillingLimit", "caught"); return undefined; } @@ -688,7 +803,7 @@ export async function getBillingLimit( } return result.val; - } catch (_e) { + } catch (e) { recordPlatformFailure("getBillingLimit", "caught"); return undefined; } @@ -762,7 +877,7 @@ export async function getActiveBillingLimits(): Promise { + return await promoCodeCookie.serialize(code); +} + +export async function getPromoCodeFromCookie(request: Request): Promise { + const value = await promoCodeCookie.parse(request.headers.get("Cookie")); + return typeof value === "string" && value.length > 0 ? value : null; +} + +export async function clearPromoCodeCookie(): Promise { + return await promoCodeCookie.serialize("", { maxAge: 0 }); +} From 74c160ec5815e6acfe5f82037c048ff5df43dfef Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 3 Jul 2026 12:25:24 +0100 Subject: [PATCH 2/5] Improved layout --- apps/webapp/app/routes/promo.tsx | 44 ++++++-------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/routes/promo.tsx b/apps/webapp/app/routes/promo.tsx index dcdc8514e19..fdbdfa49bc9 100644 --- a/apps/webapp/app/routes/promo.tsx +++ b/apps/webapp/app/routes/promo.tsx @@ -8,7 +8,7 @@ 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 { Header1, Header2, Header3 } from "~/components/primitives/Headers"; +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"; @@ -131,40 +131,11 @@ function SignInForm({ ); } -function PromoPanel({ - amountInCents, - expiresAt, -}: { - amountInCents: number; - expiresAt: string | null; -}) { - const expiry = formatExpiry(expiresAt); - return ( -
- - Promo applied - - - {formatDollars(amountInCents)} in free credits - - - Added to your new Trigger.dev account when you sign up - {expiry ? `, valid until ${expiry}` : ""}. - -
- ); -} - export default function PromoPage() { const data = useTypedLoaderData(); - const rightContent = - data.view === "valid" ? ( - - ) : undefined; - return ( - +
{data.view === "signed_in" ? ( <> @@ -181,14 +152,15 @@ export default function PromoPage() { ) : ( <> - {data.view === "valid" ? "Claim your credits" : "Create your account"} + {data.view === "valid" ? `Claim ${formatDollars(data.amountInCents)} credits` : "Create your account"} - - Create an account or login + {data.view === "valid" ? ( + + These are only available for new accounts. The credits expire on {formatExpiry(data.expiresAt)}. - {data.view === "invalid" && ( + ) : ( - That promo code isn't valid. You can still sign up below — credits just won't be + That promo code isn't valid. You can still sign up below but credits won't be added. )} From 2b95f4c0c00414144fde1f437a23d713428e9624 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 3 Jul 2026 13:15:03 +0100 Subject: [PATCH 3/5] fix(webapp): address review feedback on promo credits - getPromoCredits returns undefined (not null) on failure so SWR does not cache a transient error as "no credits" for the stale TTL - setPlan always returns a Response (no implicit undefined fall-through) - prefix unused catch bindings with _ to satisfy no-unused-vars - drop unused billing-limit schema imports - clarify the changelog wording (redeemed at plan selection) --- .server-changes/promo-credits.md | 2 +- .../webapp/app/services/platform.v3.server.ts | 53 +++++++++++-------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.server-changes/promo-credits.md b/.server-changes/promo-credits.md index c03885fa337..9ddf549acdb 100644 --- a/.server-changes/promo-credits.md +++ b/.server-changes/promo-credits.md @@ -3,4 +3,4 @@ area: webapp type: feature --- -Promo credits: a /promo signup landing page, applying a promo code when an org is created, and showing remaining credits on the usage page. +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. diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 9dce7830f3c..f9aa3643483 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -22,8 +22,6 @@ import { BillingLimitsActiveResultSchema, BillingLimitsPendingResolvesResultSchema, EntitlementResultSchema, - ResolveBillingLimitRequestSchema, - UpdateBillingLimitRequestSchema, asPlatformSchema, type BillingLimitResult, type BillingLimitsActiveResult, @@ -352,7 +350,7 @@ export async function getCurrentPlan(orgId: string) { }; return { ...result, usage }; - } catch (e) { + } catch (_e) { recordPlatformFailure("getCurrentPlan", "caught"); return undefined; } @@ -393,7 +391,7 @@ export async function getLimits(orgId: string) { } return result.v3Subscription?.plan?.limits; - } catch (e) { + } catch (_e) { recordPlatformFailure("getLimits", "caught"); return undefined; } @@ -469,7 +467,7 @@ export async function customerPortalUrl(orgId: string, orgSlug: string) { return client.createPortalSession(orgId, { returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`, }); - } catch (e) { + } catch (_e) { recordPlatformFailure("customerPortalUrl", "caught"); return undefined; } @@ -485,7 +483,7 @@ export async function getPlans() { return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("getPlans", "caught"); return undefined; } @@ -544,6 +542,12 @@ export async function setPlan( return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); } } + + // Unrecognised action shape — surface an error rather than falling through to + // an implicit undefined return, so callers always get a Response back. + return redirectWithErrorMessage(callerPath, request, "Error setting plan", { + ephemeral: false, + }); } export async function setConcurrencyAddOn(organizationId: string, amount: number) { @@ -556,7 +560,7 @@ export async function setConcurrencyAddOn(organizationId: string, amount: number return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("setConcurrencyAddOn", "caught"); return undefined; } @@ -572,7 +576,7 @@ export async function setSeatsAddOn(organizationId: string, amount: number) { return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("setSeatsAddOn", "caught"); return undefined; } @@ -588,7 +592,7 @@ export async function setBranchesAddOn(organizationId: string, amount: number) { return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("setBranchesAddOn", "caught"); return undefined; } @@ -604,7 +608,7 @@ export async function setSchedulesAddOn(organizationId: string, amount: number) return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("setSchedulesAddOn", "caught"); return undefined; } @@ -620,7 +624,7 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("getUsage", "caught"); return undefined; } @@ -643,7 +647,7 @@ export async function getCachedUsage( ); return result.val; - } catch (e) { + } catch (_e) { recordPlatformFailure("getCachedUsage", "caught"); return undefined; } @@ -659,7 +663,7 @@ export async function getUsageSeries(organizationId: string, params: UsageSeries return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("getUsageSeries", "caught"); return undefined; } @@ -683,7 +687,7 @@ export async function reportInvocationUsage( return undefined; } return result; - } catch (e) { + } catch (_e) { recordPlatformFailure("reportInvocationUsage", "caught"); return undefined; } @@ -722,7 +726,7 @@ export async function getEntitlement( return undefined; } return response; - } catch (e) { + } catch (_e) { recordPlatformFailure("getEntitlement", "caught"); return undefined; } @@ -757,13 +761,16 @@ export async function getPromoCredits(organizationId: string): Promise Date: Fri, 3 Jul 2026 13:18:20 +0100 Subject: [PATCH 4/5] fix(webapp): tidy promo landing copy and bust credits cache on redeem - omit the expiry clause on the promo page when a code has no expiry, so it no longer renders a dangling "The credits expire on ." - invalidate the promo-credits cache after a successful redeem so the usage page shows the new credits immediately instead of a stale value - align stale comment wording with redeem-at-plan-selection --- apps/webapp/app/routes/promo.tsx | 18 +++++++++++------- ...rces.orgs.$organizationSlug.select-plan.tsx | 3 ++- apps/webapp/app/services/platform.v3.server.ts | 6 ++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/promo.tsx b/apps/webapp/app/routes/promo.tsx index fdbdfa49bc9..182f1c03180 100644 --- a/apps/webapp/app/routes/promo.tsx +++ b/apps/webapp/app/routes/promo.tsx @@ -44,8 +44,8 @@ export async function loader({ request }: LoaderFunctionArgs) { 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. + // 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, @@ -152,16 +152,20 @@ export default function PromoPage() { ) : ( <> - {data.view === "valid" ? `Claim ${formatDollars(data.amountInCents)} credits` : "Create your account"} + {data.view === "valid" + ? `Claim ${formatDollars(data.amountInCents)} credits` + : "Create your account"} {data.view === "valid" ? ( - These are only available for new accounts. The credits expire on {formatExpiry(data.expiresAt)}. - + These are only available for new accounts. + {formatExpiry(data.expiresAt) + ? ` The credits expire on ${formatExpiry(data.expiresAt)}.` + : ""} + ) : ( - That promo code isn't valid. You can still sign up below but credits won't be - added. + That promo code isn't valid. You can still sign up below but credits won't be added. )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index f8ca254e696..617646888f4 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -34,7 +34,7 @@ import { prisma } from "~/db.server"; import { redirectWithErrorMessage } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { logger } from "~/services/logger.server"; -import { applyPromoCode, setPlan } from "~/services/platform.v3.server"; +import { applyPromoCode, bustPromoCreditsCache, 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"; @@ -166,6 +166,7 @@ export const action = dashboardAction( if (promoCode) { const applied = await applyPromoCode(organization.id, user.id, promoCode); if (applied?.applied) { + bustPromoCreditsCache(organization.id); result.headers.append("Set-Cookie", await clearPromoCodeCookie()); } } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index f9aa3643483..5c30ad38563 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -212,6 +212,12 @@ export function bustBillingLimitCaches(organizationId: string) { invalidateBillingLimitCaches(organizationId); } +// Clear the cached promo-credits read so a just-granted code shows on the usage +// page immediately rather than after the stale TTL. +export function bustPromoCreditsCache(organizationId: string) { + platformCache.promoCredits.remove(organizationId).catch(() => {}); +} + type Machines = typeof machinesFromPlatform; const MachineOverrideValues = z.object({ From 0715d10d041199b0d040ad9ce969f87d2f257887 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 3 Jul 2026 15:26:10 +0100 Subject: [PATCH 5/5] chore(webapp): pin @trigger.dev/platform to 1.2.0 Published release with the promo code seam (validate/apply/promoCredits). Exact pin, no range. --- apps/webapp/package.json | 2 +- pnpm-lock.yaml | 49 ++++++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index cede9262817..d988e3bf2b6 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -129,7 +129,7 @@ "@trigger.dev/rbac": "workspace:*", "@trigger.dev/sso": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.29", + "@trigger.dev/platform": "1.2.0", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1708fb773d7..00d7437bc90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,8 +573,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.29 - version: 1.0.29 + specifier: 1.2.0 + version: 1.2.0(zod@3.25.76) '@trigger.dev/plugins': specifier: workspace:* version: link:../../packages/plugins @@ -3121,10 +3121,18 @@ packages: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -3224,6 +3232,10 @@ packages: resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -8458,8 +8470,10 @@ packages: react: 18.3.1 react-dom: 18.3.1 - '@trigger.dev/platform@1.0.29': - resolution: {integrity: sha512-75lsz0igwY9tqWfT6U7Huj+94VWic3//B4Cux4muCzH/ZC8Hz22O9fsMe+R7JtQy7HsemG42R+Zwy5ITnSgFYg==} + '@trigger.dev/platform@1.2.0': + resolution: {integrity: sha512-Wa/XlMlmo1vhol5DEBYW1gMW4wgUUqr/ClDQb4IgwrRdPeLLmTpIVsgMHmYJXkERqR04xVaX7wBibWl4sW+1Hg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 '@types/acorn@4.0.6': resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -16936,9 +16950,6 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -19105,8 +19116,14 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/helper-string-parser@7.27.1': + optional: true + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': + optional: true + '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.22.15': {} @@ -19214,6 +19231,12 @@ snapshots: '@babel/helper-validator-identifier': 7.29.7 to-fast-properties: 2.0.0 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + optional: true + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -25128,9 +25151,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@trigger.dev/platform@1.0.29': + '@trigger.dev/platform@1.2.0(zod@3.25.76)': dependencies: - zod: 3.23.8 + zod: 3.25.76 '@types/acorn@4.0.6': dependencies: @@ -30038,8 +30061,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.27.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 optional: true @@ -31079,7 +31102,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.1 + semver: 7.7.3 optional: true node-abort-controller@3.1.1: {} @@ -35135,8 +35158,6 @@ snapshots: dependencies: zod: 3.25.76 - zod@3.23.8: {} - zod@3.25.76: {} zwitch@2.0.4: {}