Skip to content
Draft
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
89 changes: 69 additions & 20 deletions client/dashboard/src/components/product-tier-badge.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
import { cva, type VariantProps } from "class-variance-authority";
import { useSession } from "@/contexts/Auth";
import { cn } from "@/lib/utils";

export type ProductTier = "free" | "pro" | "enterprise";

export const ProductTierBadge = ({ tier }: { tier?: ProductTier }) => {
const productTierBadgeVariants = cva(
"inline-flex items-center text-xs uppercase font-mono px-1 py-0.5 rounded-xs w-fit",
{
variants: {
tier: {
free: "border border-neutral-600 text-neutral-600 dark:border-neutral-400 dark:text-neutral-400",
pro: "border border-success-foreground text-success-foreground dark:border-success dark:text-success",
enterprise: "text-foreground", // Enterprise uses gradient wrapper, so different styling
},
},
defaultVariants: {
tier: "free",
},
}
);

const productTierRingVariants = cva("", {
variants: {
tier: {
free: "ring-neutral-600/50",
pro: "ring-success-foreground/50 dark:ring-success/50",
enterprise: "ring-brand-gradient-end/50",
},
},
defaultVariants: {
tier: "free",
},
});

type ProductTierBadgeProps = VariantProps<typeof productTierBadgeVariants> & {
tier?: ProductTier;
className?: string;
};

export const ProductTierBadge = ({ tier, className }: ProductTierBadgeProps) => {
const session = useSession();

const finalTier = tier ?? (session.gramAccountType as ProductTier);
Expand All @@ -13,33 +49,46 @@ export const ProductTierBadge = ({ tier }: { tier?: ProductTier }) => {
enterprise: "Enterprise",
}[finalTier];

const classes = productTierColors(finalTier);
// Enterprise tier uses gradient border technique
if (finalTier === "enterprise") {
return (
<div className={cn("inline-flex rounded-xs p-[1px] bg-gradient-primary w-fit", className)}>
<div className="inline-flex items-center text-xs uppercase font-mono px-1 py-0.5 rounded-[3px] bg-background text-foreground">
{name}
</div>
</div>
);
}

// Free and Pro use regular borders
return (
<div
className={`text-xs text-muted-foreground px-1 py-0.5 rounded-sm ${classes.bg} ${classes.text}`}
className={cn(
productTierBadgeVariants({ tier: finalTier }),
className
)}
>
{name}
</div>
);
};

export const productTierColors = (tier: ProductTier) => {
// Return classes that can be used for other components
if (tier === "enterprise") {
return {
bg: '', // No background color for enterprise (uses gradient wrapper)
border: '', // No simple border for enterprise
text: 'text-foreground',
ring: productTierRingVariants({ tier }),
};
}

const variantClasses = productTierBadgeVariants({ tier }).split(' ');
return {
free: {
bg: "bg-neutral-600",
text: "text-white",
ring: "ring-neutral-600/50",
},
pro: {
bg: "bg-violet-500",
text: "text-white",
ring: "ring-violet-500/50",
},
enterprise: {
bg: "bg-success-foreground dark:bg-success",
text: "text-success dark:text-white",
ring: "ring-success-foreground/50 dark:ring-success/50",
},
}[tier];
};
bg: '', // No background color anymore
border: variantClasses.filter(c => c.startsWith('border-')).join(' '),
text: variantClasses.find(c => c.startsWith('text-')) || '',
ring: productTierRingVariants({ tier }),
};
};
8 changes: 4 additions & 4 deletions client/dashboard/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
Expand All @@ -32,8 +32,8 @@ const buttonVariants = cva(
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
inline: "h-7 rounded-sm px-1.5 py-1.5 gap-0.5",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
sm: "h-8 rounded-sm gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-sm px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
},
Expand Down Expand Up @@ -64,7 +64,7 @@ export function Button({
caps?: boolean;
href?: string;
}) {
const Comp: React.ElementType = asChild ? Slot : (props.href ? "a" : "button");
const Comp: React.ElementType = asChild ? Slot : props.href ? "a" : "button";

const iconColors = {
default: "text-primary-foreground/60 group-hover:text-primary-foreground",
Expand Down
128 changes: 100 additions & 28 deletions client/dashboard/src/pages/billing/Billing.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { FeatureRequestModal } from "@/components/FeatureRequestModal";
import { Page } from "@/components/page-layout";
import {
ProductTier,
ProductTierBadge,
productTierColors,
} from "@/components/product-tier-badge";
import { ProductTier } from "@/components/product-tier-badge";
import { Button } from "@/components/ui/button";
import { Card, Cards, CardSkeleton } from "@/components/ui/card";
import { Heading } from "@/components/ui/heading";
Expand Down Expand Up @@ -57,11 +53,11 @@ const UsageSection = () => {
billing portal to see complete details or manage your account.
</Page.Section.Description>
<Page.Section.Body>
<div className="space-y-4">
<div className="flex flex-col gap-6">
{periodUsage ? (
<>
<div>
<Stack direction="horizontal" align="center" gap={1}>
<div className="flex flex-col gap-3">
<Stack direction="horizontal" align="center" gap={2}>
<Type variant="body" className="font-medium">
Tool Calls
</Type>
Expand All @@ -76,8 +72,8 @@ const UsageSection = () => {
noMax={session.gramAccountType === "enterprise"}
/>
</div>
<div>
<Stack direction="horizontal" align="center" gap={1}>
<div className="flex flex-col gap-3">
<Stack direction="horizontal" align="center" gap={2}>
<Type variant="body" className="font-medium">
Servers
</Type>
Expand All @@ -102,8 +98,8 @@ const UsageSection = () => {
</>
)}
{creditUsage ? (
<div>
<Stack direction="horizontal" align="center" gap={1}>
<div className="flex flex-col gap-3">
<Stack direction="horizontal" align="center" gap={2}>
<Type variant="body" className="font-medium">
Playground Credits
</Type>
Expand Down Expand Up @@ -163,15 +159,88 @@ const UsageTiers = () => {
? "Tailored pricing"
: `$${tier.basePrice.toLocaleString()}`;

const ringColor = productTierColors(name.toLowerCase() as ProductTier).ring;
if (active) {
const gradientStyle =
name === "Pro" ? { background: "var(--gradient-brand-green)" } : {};

return (
<div
className={`flex-1 p-[2px] rounded-sm ${
name === "Pro" ? "" : "bg-gradient-primary"
}`}
style={gradientStyle}
>
<Card className="w-full h-full p-6 rounded-[5px] border-none">
<Card.Header>
<Card.Title>
<Stack gap={2}>
<Heading variant="h4" className="capitalize">
{name}
</Heading>
<Type variant="body" className="text-2xl">
{price}
</Type>
</Stack>
</Card.Title>
</Card.Header>
<Card.Content>
<Stack gap={8}>
<Stack gap={1}>
<Type
mono
muted
small
variant="subheading"
className="font-medium uppercase"
>
{previousTier
? `Everything from ${previousTier}, plus`
: "Features"}
</Type>
<ul className="list-inside space-y-1">
{(tier.featureBullets || []).map((bullet) => (
<li key={bullet}>
<span className="text-muted-foreground/60">✓</span>{" "}
{bullet}
</li>
))}
</ul>
</Stack>
<Stack gap={1}>
<Type
mono
muted
small
variant="subheading"
className="font-medium uppercase"
>
Included
</Type>
<ul className="list-inside space-y-1">
{(tier.includedBullets || []).map((bullet) => (
<li key={bullet}>
<span className="text-muted-foreground/60">✓</span>{" "}
{bullet}
</li>
))}
</ul>
</Stack>
</Stack>
</Card.Content>
</Card>
</div>
);
}

return (
<Card className={cn("w-full p-6", active && `ring-2 ${ringColor}`)}>
<Card className="flex-1 p-6 rounded-sm">
<Card.Header>
<Card.Title>
<Stack gap={1}>
<ProductTierBadge tier={name.toLowerCase() as ProductTier} />
<Heading variant="h2">{price}</Heading>
<Stack gap={2}>
<Heading variant="h4">{name}</Heading>
<Type variant="body" className="text-2xl">
{price}
</Type>
</Stack>
</Card.Title>
</Card.Header>
Expand All @@ -190,8 +259,8 @@ const UsageTiers = () => {
: "Features"}
</Type>
<ul className="list-inside space-y-1">
{tier.featureBullets.map((bullet) => (
<li>
{(tier.featureBullets || []).map((bullet) => (
<li key={bullet}>
<span className="text-muted-foreground/60">✓</span> {bullet}
</li>
))}
Expand All @@ -208,8 +277,8 @@ const UsageTiers = () => {
Included
</Type>
<ul className="list-inside space-y-1">
{tier.includedBullets.map((bullet) => (
<li>
{(tier.includedBullets || []).map((bullet) => (
<li key={bullet}>
<span className="text-muted-foreground/60">✓</span> {bullet}
</li>
))}
Expand All @@ -235,7 +304,7 @@ const UsageTiers = () => {
)}
</Page.Section.CTA>
<Page.Section.Body>
<Stack direction={"horizontal"} gap={4}>
<Stack direction={"horizontal"} gap={4} className="items-stretch">
{isLoading ? (
<>
<CardSkeleton />
Expand Down Expand Up @@ -297,29 +366,31 @@ const UsageProgress = ({
const includedProgress = (
<div
className={cn(
"h-4 bg-muted dark:bg-neutral-800 rounded-md overflow-hidden relative",
"h-4 bg-muted dark:bg-neutral-800 rounded-sm overflow-hidden relative",
anyOverage && "rounded-r-none"
)}
style={{ width: `${includedWidth}%` }}
>
<div
className="h-full bg-gradient-to-r from-green-400 to-green-600 dark:from-green-700 dark:to-green-500 transition-all duration-300"
className="h-full transition-all duration-300"
style={{
width: `${Math.min((value / included) * 100, 100)}%`,
backgroundColor: "#5A8250", // TODO: use design system color when available
}}
/>
</div>
);

const overageProgress = anyOverage ? (
<div
className="h-4 bg-muted dark:bg-neutral-800 rounded-r-md overflow-hidden relative"
className="h-4 bg-muted dark:bg-neutral-800 rounded-r-sm overflow-hidden relative"
style={{ width: `${overageWidth}%` }}
>
<div
className="h-full bg-gradient-to-r from-yellow-400 to-yellow-600 dark:from-yellow-700 dark:to-yellow-500 transition-all duration-300"
className="h-full transition-all duration-300"
style={{
width: `${Math.min(((value - included) / overageMax) * 100, 100)}%`,
backgroundColor: "#DB6F32", // TODO: use design system color when available
}}
/>
</div>
Expand All @@ -334,7 +405,7 @@ const UsageProgress = ({
</div>
{/* Included label underneath, always show */}
<div
className="absolute top-5 text-xs text-muted-foreground whitespace-nowrap"
className="absolute top-6 text-xs text-muted-foreground whitespace-nowrap"
style={{ right: `${101 - includedWidth}%` }}
>
{anyOverage
Expand All @@ -353,7 +424,7 @@ const UsageProgress = ({
/>
{/* Overage label underneath */}
<div
className="absolute top-5 text-xs text-muted-foreground whitespace-nowrap"
className="absolute top-6 text-xs text-muted-foreground whitespace-nowrap"
style={{ left: `${includedWidth + 1}%` }}
>
Extra: {(value - included).toLocaleString()}
Expand Down Expand Up @@ -398,6 +469,7 @@ const PolarPortalLink = ({ children }: { children: React.ReactNode }) => {
? "Enterprise: Contact support to manage billing"
: undefined
}
caps
>
{children}
</Button>
Expand Down