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
5 changes: 5 additions & 0 deletions .changeset/chilly-trams-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/service-utils": patch
---

Update `TeamResponse` type
35 changes: 32 additions & 3 deletions apps/dashboard/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Inter as interFont } from "next/font/google";
// biome-ignore lint/style/useImportType: <explanation>
import React from "react";
import { useEffect } from "react";
import { Toaster } from "sonner";
import { Button } from "../src/@/components/ui/button";

const queryClient = new QueryClient();
Expand All @@ -16,8 +17,30 @@ const fontSans = interFont({
variable: "--font-sans",
});

const customViewports = {
xs: {
// Regular sized phones (iphone 15 / 15 pro)
name: "iPhone",
styles: {
width: "390px",
height: "844px",
},
},
sm: {
// Larger phones (iphone 15 plus / 15 pro max)
name: "iPhone Plus",
styles: {
width: "430px",
height: "932px",
},
},
};

const preview: Preview = {
parameters: {
viewport: {
viewports: customViewports,
},
controls: {
matchers: {
color: /(background|color)$/i,
Expand Down Expand Up @@ -57,13 +80,13 @@ function StoryLayout(props: {

return (
<QueryClientProvider client={queryClient}>
<div className="flex min-h-screen min-w-0 flex-col bg-background text-foreground">
<div className="flex min-h-dvh min-w-0 flex-col bg-background text-foreground">
<div className="flex justify-end gap-2 border-b p-4">
<Button
onClick={() => setTheme("dark")}
size="sm"
variant={theme === "dark" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<MoonIcon className="size-4" />
</Button>
Expand All @@ -72,14 +95,20 @@ function StoryLayout(props: {
onClick={() => setTheme("light")}
size="sm"
variant={theme === "light" ? "default" : "outline"}
className="h-auto w-auto rounded-full p-2"
className="h-auto w-auto shrink-0 rounded-full p-2"
>
<SunIcon className="size-4" />
</Button>
</div>

<div className="flex min-w-0 grow flex-col">{props.children}</div>
<ToasterSetup />
</div>
</QueryClientProvider>
);
}

function ToasterSetup() {
const { theme } = useTheme();
return <Toaster richColors theme={theme === "light" ? "light" : "dark"} />;
}
34 changes: 19 additions & 15 deletions apps/dashboard/src/@/actions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@

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

export type RedirectCheckoutOptions = {
export type GetBillingCheckoutUrlOptions = {
teamSlug: string;
sku: ProductSKU;
redirectUrl: string;
metadata?: Record<string, string>;
};

export async function redirectToCheckout(
options: RedirectCheckoutOptions,
): Promise<{ status: number }> {
export async function getBillingCheckoutUrl(
options: GetBillingCheckoutUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
Expand Down Expand Up @@ -49,27 +48,30 @@ export async function redirectToCheckout(
status: res.status,
};
}

const json = await res.json();
if (!json.result) {
return {
status: 500,
};
}

// redirect to the stripe checkout session
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}

export type RedirectBillingCheckoutAction = typeof redirectToCheckout;
export type GetBillingCheckoutUrlAction = typeof getBillingCheckoutUrl;

export type BillingPortalOptions = {
export type GetBillingPortalUrlOptions = {
teamSlug: string | undefined;
redirectUrl: string;
};

export async function redirectToBillingPortal(
options: BillingPortalOptions,
): Promise<{ status: number }> {
export async function getBillingPortalUrl(
options: GetBillingPortalUrlOptions,
): Promise<{ status: number; url?: string }> {
if (!options.teamSlug) {
return {
status: 400,
Expand Down Expand Up @@ -110,8 +112,10 @@ export async function redirectToBillingPortal(
};
}

// redirect to the stripe billing portal
redirect(json.result);
return {
status: 200,
url: json.result as string,
};
}

export type BillingBillingPortalAction = typeof redirectToBillingPortal;
export type GetBillingPortalUrlAction = typeof getBillingPortalUrl;
19 changes: 0 additions & 19 deletions apps/dashboard/src/@/components/TextDivider.tsx

This file was deleted.

128 changes: 93 additions & 35 deletions apps/dashboard/src/@/components/billing.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,136 @@
"use client";

import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
BillingBillingPortalAction,
BillingPortalOptions,
RedirectBillingCheckoutAction,
RedirectCheckoutOptions,
GetBillingCheckoutUrlAction,
GetBillingCheckoutUrlOptions,
GetBillingPortalUrlAction,
GetBillingPortalUrlOptions,
} from "../actions/billing";
import { cn } from "../lib/utils";
import { Spinner } from "./ui/Spinner/Spinner";
import { Button, type ButtonProps } from "./ui/button";

type CheckoutButtonProps = Omit<RedirectCheckoutOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToCheckout: RedirectBillingCheckoutAction;
};
type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};

export function CheckoutButton({
onClick,
teamSlug,
sku,
metadata,
redirectPath,
getBillingCheckoutUrl,
children,
redirectToCheckout,
...restProps
buttonProps,
}: CheckoutButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingCheckoutUrl({
teamSlug,
sku,
metadata,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});

const errorMessage = "Failed to open checkout page";

return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToCheckout({
teamSlug,
sku,
metadata,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess: (res) => {
if (!res.url) {
toast.error(errorMessage);
return;
}

const tab = window.open(res.url, "_blank");

if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}

type BillingPortalButtonProps = Omit<BillingPortalOptions, "redirectUrl"> &
ButtonProps & {
redirectPath: string;
redirectToBillingPortal: BillingBillingPortalAction;
};
type BillingPortalButtonProps = Omit<
GetBillingPortalUrlOptions,
"redirectUrl"
> & {
getBillingPortalUrl: GetBillingPortalUrlAction;
buttonProps?: Omit<ButtonProps, "children">;
children: React.ReactNode;
};

export function BillingPortalButton({
onClick,
teamSlug,
redirectPath,
children,
redirectToBillingPortal,
...restProps
getBillingPortalUrl,
buttonProps,
}: BillingPortalButtonProps) {
const getUrlMutation = useMutation({
mutationFn: async () => {
return getBillingPortalUrl({
teamSlug,
redirectUrl: getAbsoluteUrl("/stripe-redirect"),
});
},
});

const errorMessage = "Failed to open billing portal";

return (
<Button
{...restProps}
{...buttonProps}
className={cn(buttonProps?.className, "gap-2")}
disabled={getUrlMutation.isPending || buttonProps?.disabled}
onClick={async (e) => {
onClick?.(e);
await redirectToBillingPortal({
teamSlug,
redirectUrl: getRedirectUrl(redirectPath),
buttonProps?.onClick?.(e);
getUrlMutation.mutate(undefined, {
onSuccess(res) {
if (!res.url) {
toast.error(errorMessage);
return;
}

const tab = window.open(res.url, "_blank");
if (!tab) {
toast.error(errorMessage);
return;
}
},
onError: () => {
toast.error(errorMessage);
},
});
}}
>
{getUrlMutation.isPending && <Spinner className="size-4" />}
{children}
</Button>
);
}

function getRedirectUrl(path: string) {
function getAbsoluteUrl(path: string) {
const url = new URL(window.location.origin);
url.pathname = path;
return url.toString();
Expand Down
Loading
Loading