Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/dashboard/framer-rewrites.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
module.exports = [
// -- home
"/",
"/pricing",
// -- product landing pages --
// -- connect
"/connect/sign-in",
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
};

const baseNextConfig: NextConfig = {
serverExternalPackages: ["pino-pretty"],
async headers() {
return [
{
Expand Down
2 changes: 0 additions & 2 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@
"@radix-ui/react-tooltip": "1.1.4",
"@sentry/nextjs": "8.38.0",
"@shazow/whatsabi": "^0.16.0",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^3.5.0",
"@tanstack/react-query": "5.60.2",
"@tanstack/react-table": "^8.17.3",
"@thirdweb-dev/service-utils": "workspace:*",
Expand Down
117 changes: 117 additions & 0 deletions apps/dashboard/src/@/actions/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use server";

import "server-only";
import { API_SERVER_URL, getAbsoluteUrlFromPath } from "@/constants/env";
import { redirect } from "next/navigation";
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import type { ProductSKU } from "../lib/billing";

export type RedirectCheckoutOptions = {
teamSlug: string;
sku: ProductSKU;
redirectPath?: string;
metadata?: Record<string, string>;
};
export async function redirectToCheckout(
options: RedirectCheckoutOptions,
): Promise<{ status: number }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();

if (!token) {
return {
status: 401,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-link`,
{
method: "POST",
body: JSON.stringify({
sku: options.sku,
redirectTo: getAbsoluteUrlFromPath(
options.redirectPath ||
`/team/${options.teamSlug}/~/settings/billing`,
).toString(),
metadata: options.metadata || {},
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) {
return {
status: res.status,
};
}
const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}

// redirect to the stripe checkout session
redirect(json.result);
}

export type BillingPortalOptions = {
teamSlug: string | undefined;
redirectPath?: string;
};
export async function redirectToBillingPortal(
options: BillingPortalOptions,
): Promise<{ status: number }> {
if (!options.teamSlug) {
return {
status: 400,
};
}
const token = await getAuthToken();
if (!token) {
return {
status: 401,
};
}

const res = await fetch(
`${API_SERVER_URL}/v1/teams/${options.teamSlug}/checkout/create-session-link`,
{
method: "POST",
body: JSON.stringify({
redirectTo: getAbsoluteUrlFromPath(
options.redirectPath ||
`/team/${options.teamSlug}/~/settings/billing`,
).toString(),
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
},
);

if (!res.ok) {
return {
status: res.status,
};
}

const json = await res.json();

if (!json.result) {
return {
status: 500,
};
}

// redirect to the stripe billing portal
redirect(json.result);
}
62 changes: 62 additions & 0 deletions apps/dashboard/src/@/api/team-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { getAuthToken } from "../../app/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";
import type { ProductSKU } from "../lib/billing";

type InvoiceLine = {
// amount for this line item
amount: number;
// statement descriptor
description: string | null;
// the thirdweb product sku or null if it is not recognized
thirdwebSku: ProductSKU | null;
};

type Invoice = {
// total amount excluding tax
amount: number | null;
// the ISO currency code (e.g. USD)
currency: string;
// the line items on the invoice
lines: InvoiceLine[];
};

export type TeamSubscription = {
id: string;
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
status:
| "incomplete"
| "incomplete_expired"
| "trialing"
| "active"
| "past_due"
| "canceled"
| "unpaid"
| "paused";
currentPeriodStart: string;
currentPeriodEnd: string;
trialStart: string | null;
trialEnd: string | null;
upcomingInvoice: Invoice;
};

export async function getTeamSubscriptions(slug: string) {
const token = await getAuthToken();

if (!token) {
return null;
}

const teamRes = await fetch(
`${API_SERVER_URL}/v1/teams/${slug}/subscriptions`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);

if (teamRes.ok) {
return (await teamRes.json())?.result as TeamSubscription[];
}
return null;
}
3 changes: 2 additions & 1 deletion apps/dashboard/src/@/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export type Team = {
deletedAt?: string;
bannedAt?: string;
image?: string;
billingPlan: "pro" | "growth" | "free";
billingPlan: "pro" | "growth" | "free" | "starter";
billingStatus: "validPayment" | (string & {}) | null;
billingEmail: string | null;
growthTrialEligible: boolean | null;
};

export async function getTeamBySlug(slug: string) {
Expand Down
61 changes: 61 additions & 0 deletions apps/dashboard/src/@/components/billing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import {
type BillingPortalOptions,
type RedirectCheckoutOptions,
redirectToBillingPortal,
redirectToCheckout,
} from "../actions/billing";
import { Button, type ButtonProps } from "./ui/button";

type CheckoutButtonProps = RedirectCheckoutOptions & ButtonProps;
export function CheckoutButton({
onClick,
teamSlug,
sku,
metadata,
redirectPath,
children,
...restProps
}: CheckoutButtonProps) {
return (
<Button
{...restProps}
onClick={async (e) => {
onClick?.(e);
await redirectToCheckout({
teamSlug,
sku,
metadata,
redirectPath,
});
}}
>
{children}
</Button>
);
}

type BillingPortalButtonProps = BillingPortalOptions & ButtonProps;
export function BillingPortalButton({
onClick,
teamSlug,
redirectPath,
children,
...restProps
}: BillingPortalButtonProps) {
return (
<Button
{...restProps}
onClick={async (e) => {
onClick?.(e);
await redirectToBillingPortal({
teamSlug,
redirectPath,
});
}}
>
{children}
</Button>
);
}
13 changes: 13 additions & 0 deletions apps/dashboard/src/@/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
// Comma-separated list of chain IDs to disable faucet for.
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;

export function getAbsoluteUrlFromPath(path: string) {
const url = new URL(
isProd
? "https://thirdweb.com"
: (process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: "http://localhost:3000") || "https://thirdweb-dev.com",
);

url.pathname = path;
return url;
}
13 changes: 13 additions & 0 deletions apps/dashboard/src/@/lib/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// keep in line with product SKUs in the backend
export type ProductSKU =
| "plan:starter"
| "plan:growth"
| "plan:custom"
| "product:ecosystem_wallets"
| "product:engine_standard"
| "product:engine_premium"
| "usage:storage"
| "usage:in_app_wallet"
| "usage:aa_sponsorship"
| "usage:aa_sponsorship_op_grant"
| null;
Loading