mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-14 19:14:01 +00:00
## Summary - Documents that mono attaches Scale 14d trial + Company Brain after Slack OAuth from `/brain`. Stacks on #1310. Billing implementation is in mono.
1921 lines
58 KiB
TypeScript
1921 lines
58 KiB
TypeScript
"use client"
|
||
|
||
import { dmSans125ClassName } from "@/lib/fonts"
|
||
import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage"
|
||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||
import { getBrainTrialInfo } from "@/lib/billing-utils"
|
||
import { cn } from "@lib/utils"
|
||
import { useAuth } from "@lib/auth-context"
|
||
import { getCanceledSubscription } from "@lib/queries"
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogTrigger,
|
||
} from "@ui/components/dialog"
|
||
import { Logo } from "@ui/assets/Logo"
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||
import { useCustomer } from "autumn-js/react"
|
||
import { usePostHog } from "@lib/posthog"
|
||
import {
|
||
CANCEL_REASONS,
|
||
cancelReasonNeedsDetail,
|
||
type CancelReasonValue,
|
||
} from "./cancel-reasons"
|
||
import {
|
||
Check,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Coins,
|
||
ExternalLink,
|
||
LoaderIcon,
|
||
Plus,
|
||
ReceiptText,
|
||
Settings,
|
||
X,
|
||
} from "lucide-react"
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { toast } from "sonner"
|
||
|
||
const API_BASE =
|
||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||
|
||
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
|
||
|
||
function GoogleMeetIcon({ className }: { className?: string }) {
|
||
return (
|
||
<svg
|
||
className={className}
|
||
viewBox="0 0 87.5 72"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
aria-hidden="true"
|
||
>
|
||
<path
|
||
fill="#00832d"
|
||
d="M49.5 36l8.53 9.75 11.47 7.33 2-17.02-2-16.64-11.69 6.44z"
|
||
/>
|
||
<path
|
||
fill="#0066da"
|
||
d="M0 51.5V66c0 3.315 2.685 6 6 6h14.5l3-10.96-3-9.54-9.95-3z"
|
||
/>
|
||
<path fill="#e94235" d="M20.5 0L0 20.5l10.55 3 9.95-3 2.95-9.41z" />
|
||
<path fill="#2684fc" d="M20.5 20.5H0v31h20.5z" />
|
||
<path
|
||
fill="#00ac47"
|
||
d="M82.6 8.68L69.5 19.42v33.66l13.16 10.79c1.97 1.54 4.85.135 4.85-2.37V11c0-2.535-2.945-3.925-4.91-2.32zM49.5 36v15.5h-29V72h43c3.315 0 6-2.685 6-6V53.08z"
|
||
/>
|
||
<path
|
||
fill="#ffba00"
|
||
d="M63.5 0h-43v20.5h29V36l20-16.57V6c0-3.315-2.685-6-6-6z"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
const CREDIT_FEATURE_ID = "usd_credits"
|
||
const TOP_UP_PLAN_ID = "credits_topup"
|
||
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
|
||
const PLAN_CARD_ACTION_CLASS =
|
||
"inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] text-[14px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||
|
||
const SURFACE_SHADOW =
|
||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
|
||
|
||
type BillingInvoice = {
|
||
planIds?: string[]
|
||
stripeId: string
|
||
status: string
|
||
total: number
|
||
currency: string
|
||
createdAt: number
|
||
hostedInvoiceUrl?: string | null
|
||
}
|
||
|
||
type AutoTopupConfig = {
|
||
featureId: string
|
||
enabled: boolean
|
||
threshold: number
|
||
quantity: number
|
||
purchaseLimit: {
|
||
interval: "hour" | "day" | "week" | "month"
|
||
intervalCount: number
|
||
limit: number
|
||
} | null
|
||
}
|
||
|
||
type AutoTopupsResponse =
|
||
| {
|
||
ok: true
|
||
hasPaymentMethod: boolean
|
||
autoTopup: AutoTopupConfig | null
|
||
}
|
||
| { ok: false; reason: string; message?: string }
|
||
|
||
type PlanCardDefinition = {
|
||
id: "free" | "pro" | "max" | "scale" | "enterprise"
|
||
name: string
|
||
price: string
|
||
period: string
|
||
credits: string
|
||
productId: "api_free" | "api_pro" | "api_max" | "api_scale" | "api_enterprise"
|
||
description: string
|
||
includesFrom?: string
|
||
features: string[]
|
||
isContactSales?: boolean
|
||
mostPopular?: boolean
|
||
}
|
||
|
||
const PLAN_CARDS: PlanCardDefinition[] = [
|
||
{
|
||
id: "free",
|
||
name: "Free",
|
||
price: "$0",
|
||
period: "",
|
||
credits: "$5",
|
||
productId: "api_free",
|
||
description: "Try supermemory with no commitment",
|
||
features: [
|
||
"Pay-as-you-go after $5 runs out",
|
||
"Full search and memory access",
|
||
"Email support",
|
||
],
|
||
},
|
||
{
|
||
id: "pro",
|
||
name: "Pro",
|
||
price: "$19",
|
||
period: "/mo",
|
||
credits: "$20",
|
||
productId: "api_pro",
|
||
description: "For people building with AI memory",
|
||
features: [
|
||
"Auto top-up when balance runs low",
|
||
"Google Drive, Notion, OneDrive & Granola connectors",
|
||
"All plugins (Claude Code, Cursor, Hermes...)",
|
||
"Priority support",
|
||
],
|
||
},
|
||
]
|
||
|
||
const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
|
||
{
|
||
id: "max",
|
||
name: "Max",
|
||
price: "$100",
|
||
period: "/mo",
|
||
credits: "$130",
|
||
productId: "api_max",
|
||
description: "For power users who outgrow Pro",
|
||
includesFrom: "Pro",
|
||
mostPopular: true,
|
||
features: ["6× the credits of Pro", "Gmail connector", "Priority support"],
|
||
},
|
||
{
|
||
id: "scale",
|
||
name: "Scale",
|
||
price: "$399",
|
||
period: "/mo",
|
||
credits: "$600",
|
||
productId: "api_scale",
|
||
description: "For teams and production workloads",
|
||
includesFrom: "Max",
|
||
features: [
|
||
"Auto top-up & spend caps",
|
||
"S3 & Web Crawler connectors",
|
||
"Dedicated support",
|
||
],
|
||
},
|
||
{
|
||
id: "enterprise",
|
||
name: "Enterprise",
|
||
price: "Custom",
|
||
period: "",
|
||
credits: "Unlimited",
|
||
productId: "api_enterprise",
|
||
description: "Custom deployments with dedicated engineering",
|
||
includesFrom: "Scale",
|
||
features: [
|
||
"Custom metering & billing",
|
||
"Custom integrations & SSO",
|
||
"Forward-deployed engineer",
|
||
],
|
||
isContactSales: true,
|
||
},
|
||
]
|
||
|
||
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
|
||
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
|
||
{
|
||
id: "scale",
|
||
name: "Scale",
|
||
price: "$399",
|
||
period: "/mo",
|
||
credits: "$600",
|
||
productId: "api_scale",
|
||
description: "Company Brain for your team, with production usage",
|
||
mostPopular: true,
|
||
features: [
|
||
"Company Brain Slack agent & shared memory",
|
||
"$600 monthly usage credits when paid",
|
||
"Auto top-up & spend caps",
|
||
"Team connectors & dedicated support",
|
||
],
|
||
},
|
||
{
|
||
id: "enterprise",
|
||
name: "Enterprise",
|
||
price: "Custom",
|
||
period: "",
|
||
credits: "Unlimited",
|
||
productId: "api_enterprise",
|
||
description: "Custom deployments with dedicated engineering",
|
||
includesFrom: "Scale",
|
||
features: [
|
||
"Custom metering & billing",
|
||
"Custom integrations & SSO",
|
||
"Forward-deployed engineer",
|
||
],
|
||
isContactSales: true,
|
||
},
|
||
]
|
||
|
||
const PLAN_RANK: Record<PlanCardDefinition["id"], number> = {
|
||
free: 0,
|
||
pro: 1,
|
||
max: 2,
|
||
scale: 3,
|
||
enterprise: 4,
|
||
}
|
||
|
||
function SectionTitle({
|
||
children,
|
||
aside,
|
||
}: {
|
||
children: React.ReactNode
|
||
aside?: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 px-2">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{children}
|
||
</p>
|
||
{aside}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingsCard({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: React.ReactNode
|
||
className?: string
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative w-full overflow-hidden rounded-[14px] bg-[#14161A] p-5",
|
||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PlanCard({
|
||
action,
|
||
plan,
|
||
}: {
|
||
action: React.ReactNode
|
||
plan: PlanCardDefinition
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative flex min-h-[416px] flex-col overflow-hidden rounded-[14px] border p-5",
|
||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||
plan.mostPopular
|
||
? "border-[#4BA0FA]/40 bg-[#14161A]"
|
||
: "border-white/[0.08] bg-[#14161A]",
|
||
)}
|
||
>
|
||
{plan.mostPopular ? (
|
||
<span className="absolute right-5 top-5 inline-flex h-[18px] items-center rounded-[3px] bg-[#4BA0FA] px-1.5 text-[10px] font-bold uppercase tracking-[0.36px] text-[#00171A]">
|
||
Most popular
|
||
</span>
|
||
) : null}
|
||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
|
||
{plan.name}
|
||
</p>
|
||
|
||
<div className="mt-3 flex items-baseline gap-1">
|
||
<span
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[34px] font-bold leading-none tracking-[-0.34px] text-[#FAFAFA] tabular-nums",
|
||
)}
|
||
>
|
||
{plan.price}
|
||
</span>
|
||
{plan.period ? (
|
||
<span className="text-[13px] text-[#737373]">{plan.period}</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-2 text-[13px] leading-snug text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
{plan.description}
|
||
</p>
|
||
|
||
{plan.isContactSales ? null : (
|
||
<div className="mt-5 flex items-center gap-2 rounded-[8px] bg-white/[0.04] px-3 py-2.5 text-[#A3A3A3]">
|
||
<Coins className="size-3.5 shrink-0 text-[#737373]" />
|
||
<div className="min-w-0">
|
||
<p className="text-[12px] font-semibold leading-none text-[#C8D0DA] tabular-nums">
|
||
{plan.credits}
|
||
</p>
|
||
<p className="mt-0.5 text-[10px] leading-none text-[#737373]">
|
||
of usage included
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{plan.includesFrom ? (
|
||
<div className="mt-5 flex items-center gap-3">
|
||
<div className="h-px flex-1 bg-white/[0.08]" />
|
||
<span className="whitespace-nowrap text-[10px] text-[#737373]">
|
||
Everything in {plan.includesFrom}, plus
|
||
</span>
|
||
<div className="h-px flex-1 bg-white/[0.08]" />
|
||
</div>
|
||
) : null}
|
||
|
||
<ul
|
||
className={cn(
|
||
"mb-6 flex flex-1 flex-col gap-3",
|
||
plan.includesFrom ? "mt-5" : "mt-5",
|
||
plan.isContactSales && !plan.includesFrom && "mt-7",
|
||
)}
|
||
>
|
||
{plan.features.map((feature) => (
|
||
<li
|
||
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
|
||
key={feature}
|
||
>
|
||
<Check className="mt-0.5 size-3.5 shrink-0 text-[#737373]" />
|
||
<span>{feature}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
{action}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Pill({
|
||
children,
|
||
tone = "muted",
|
||
}: {
|
||
children: React.ReactNode
|
||
tone?: "active" | "muted" | "warning"
|
||
}) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex h-[18px] shrink-0 items-center rounded-[3px] px-1.5 text-[10px] font-bold uppercase tracking-[0.36px]",
|
||
tone === "active" && "bg-[#4BA0FA] text-[#00171A]",
|
||
tone === "muted" && "bg-[#2E353D] text-[#A3A3A3]",
|
||
tone === "warning" && "bg-[#290F0A] text-[#C73B1B]",
|
||
)}
|
||
>
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function FieldSelect({
|
||
value,
|
||
values,
|
||
prefix,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
value: number
|
||
values: readonly number[]
|
||
prefix?: string
|
||
onChange: (value: number) => void
|
||
disabled?: boolean
|
||
}) {
|
||
const cols = values.length <= 3 ? "grid-cols-3" : "grid-cols-4"
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"grid gap-1 rounded-[10px] border border-white/10 bg-[#0D121A] p-1",
|
||
cols,
|
||
)}
|
||
>
|
||
{values.map((item) => (
|
||
<button
|
||
key={item}
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => onChange(item)}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"h-8 rounded-[7px] text-[13px] font-semibold tabular-nums transition-colors",
|
||
item === value
|
||
? "bg-[#1C2B3E] text-[#FAFAFA]"
|
||
: "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]",
|
||
disabled && "cursor-not-allowed opacity-50",
|
||
)}
|
||
>
|
||
{prefix}
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatUsd(value: number) {
|
||
return value.toLocaleString(undefined, {
|
||
style: "currency",
|
||
currency: "USD",
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})
|
||
}
|
||
|
||
function formatInvoiceAmount(total: number, currency: string) {
|
||
const normalizedTotal =
|
||
Number.isInteger(total) && total > 100 ? total / 100 : total
|
||
return normalizedTotal.toLocaleString(undefined, {
|
||
style: "currency",
|
||
currency: currency?.toUpperCase() || "USD",
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})
|
||
}
|
||
|
||
function formatDate(timestamp: number) {
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}).format(new Date(timestamp))
|
||
}
|
||
|
||
function normalizeTimestamp(timestamp: number) {
|
||
return timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp
|
||
}
|
||
|
||
function getStatusTone(status: string): "active" | "muted" | "warning" {
|
||
const normalized = status.toLowerCase()
|
||
if (normalized === "paid" || normalized === "succeeded") return "active"
|
||
if (normalized === "open" || normalized === "draft") return "muted"
|
||
return "warning"
|
||
}
|
||
|
||
function getInvoiceProductLabel(productId: string | undefined): string {
|
||
if (!productId) return "Billing invoice"
|
||
if (productId === TOP_UP_PLAN_ID || productId === "api_topup")
|
||
return "Credits top-up"
|
||
const planMap: Record<string, string> = {
|
||
api_free: "Free",
|
||
api_pro: "Pro",
|
||
api_max: "Max",
|
||
api_scale: "Scale",
|
||
api_enterprise: "Enterprise",
|
||
memory_free: "Free",
|
||
memory_starter: "Pro",
|
||
memory_growth: "Scale",
|
||
memory_enterprise: "Enterprise",
|
||
}
|
||
if (planMap[productId]) return planMap[productId]
|
||
return productId
|
||
.split("_")
|
||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||
.join(" ")
|
||
}
|
||
|
||
export default function Billing() {
|
||
const queryClient = useQueryClient()
|
||
const { user, org } = useAuth()
|
||
const autumn = useCustomer()
|
||
const posthog = usePostHog()
|
||
const isCompanyBrain = useHasCompanyBrain()
|
||
const brainTrial = useMemo(
|
||
() => getBrainTrialInfo(org?.metadata as Record<string, unknown> | string),
|
||
[org?.metadata],
|
||
)
|
||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||
const [isCancelling, setIsCancelling] = useState(false)
|
||
const [isResuming, setIsResuming] = useState(false)
|
||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
|
||
const [cancelReason, setCancelReason] = useState<CancelReasonValue | null>(
|
||
null,
|
||
)
|
||
const [cancelDetail, setCancelDetail] = useState("")
|
||
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
|
||
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
|
||
const [planPage, setPlanPage] = useState<0 | 1>(0)
|
||
const [topUpAmount, setTopUpAmount] = useState<number>(25)
|
||
const [customTopUpAmount, setCustomTopUpAmount] = useState("")
|
||
const [topUpPendingAmount, setTopUpPendingAmount] = useState<number | null>(
|
||
null,
|
||
)
|
||
const [autoTopUpEnabled, setAutoTopUpEnabled] = useState(false)
|
||
const [autoTopUpThreshold, setAutoTopUpThreshold] = useState<number>(5)
|
||
const [autoTopUpAmount, setAutoTopUpAmount] = useState<number>(25)
|
||
const [isSavingAutoTopUp, setIsSavingAutoTopUp] = useState(false)
|
||
|
||
const currentMember = org?.members?.find(
|
||
(m: { userId: string }) => m.userId === user?.id,
|
||
)
|
||
const userRole = (currentMember?.role ?? "member") as string
|
||
const isAdmin = userRole === "owner" || userRole === "admin"
|
||
|
||
const {
|
||
usdIncluded,
|
||
usdSpent,
|
||
planUsagePct,
|
||
currentPlan,
|
||
hasPaidPlan,
|
||
isTrialing: autumnTrialing,
|
||
trialEndsAtMs: autumnTrialEndsAtMs,
|
||
isLoading: isCheckingStatus,
|
||
daysRemaining,
|
||
} = useTokenUsage(autumn)
|
||
|
||
const brainTrialStillOpen =
|
||
brainTrial.status === "active" &&
|
||
(brainTrial.endsAtMs == null || brainTrial.endsAtMs > Date.now())
|
||
const isBrainTrialEnded =
|
||
isCompanyBrain &&
|
||
(brainTrial.status === "expired" ||
|
||
brainTrial.status === "exhausted" ||
|
||
(brainTrial.status === "active" &&
|
||
brainTrial.endsAtMs != null &&
|
||
brainTrial.endsAtMs <= Date.now()))
|
||
const isOnTrial =
|
||
!isBrainTrialEnded &&
|
||
(autumnTrialing || (isCompanyBrain && brainTrialStillOpen))
|
||
const trialEndsAtMs = brainTrial.endsAtMs ?? autumnTrialEndsAtMs ?? null
|
||
const trialDaysLeft =
|
||
brainTrial.daysRemaining ??
|
||
(trialEndsAtMs != null
|
||
? Math.max(
|
||
0,
|
||
Math.ceil((trialEndsAtMs - Date.now()) / (1000 * 60 * 60 * 24)),
|
||
)
|
||
: null)
|
||
const trialEndsLabel =
|
||
trialEndsAtMs != null
|
||
? new Date(trialEndsAtMs).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
})
|
||
: null
|
||
const trialCredits = brainTrial.credits ?? (isOnTrial ? 200 : null)
|
||
const showPlanUsage = hasPaidPlan || isOnTrial || isCompanyBrain
|
||
|
||
// Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2).
|
||
// Company Brain orgs only list Scale + Enterprise — no carousel.
|
||
const didAutoOpenPlanPage = useRef(false)
|
||
useEffect(() => {
|
||
if (isCompanyBrain) return
|
||
if (didAutoOpenPlanPage.current || isCheckingStatus) return
|
||
didAutoOpenPlanPage.current = true
|
||
if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) {
|
||
setIsPlanCarouselActive(true)
|
||
setPlanPage(1)
|
||
}
|
||
}, [isCheckingStatus, currentPlan, isCompanyBrain])
|
||
|
||
const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID]
|
||
const creditRemaining =
|
||
balance?.remaining ?? Math.max(usdIncluded - usdSpent, 0)
|
||
|
||
// --- Invoices via dedicated billing API (matches console) ---
|
||
const invoicesQuery = useQuery({
|
||
queryKey: ["billing", org?.id ?? "", "invoices"],
|
||
queryFn: async () => {
|
||
const res = await fetch(`${API_BASE}/v3/auth/billing/invoices`, {
|
||
credentials: "include",
|
||
headers: { "X-App-Source": "nova" },
|
||
})
|
||
if (!res.ok) return []
|
||
const data = (await res.json()) as { invoices?: BillingInvoice[] }
|
||
return data.invoices ?? []
|
||
},
|
||
enabled: Boolean(org?.id),
|
||
staleTime: 60_000,
|
||
})
|
||
const invoices = useMemo(() => {
|
||
return [...(invoicesQuery.data ?? [])].sort(
|
||
(a, b) => b.createdAt - a.createdAt,
|
||
)
|
||
}, [invoicesQuery.data])
|
||
|
||
// --- Auto top-ups via dedicated billing API (matches console) ---
|
||
const autoTopupsQuery = useQuery({
|
||
queryKey: ["billing", org?.id ?? "", "auto-topups"],
|
||
queryFn: async () => {
|
||
const res = await fetch(`${API_BASE}/v3/auth/billing/auto-topups`, {
|
||
credentials: "include",
|
||
headers: { "X-App-Source": "nova" },
|
||
})
|
||
if (!res.ok) return null
|
||
return (await res.json()) as AutoTopupsResponse
|
||
},
|
||
enabled: Boolean(org?.id),
|
||
staleTime: 20_000,
|
||
})
|
||
|
||
const autoTopupData =
|
||
autoTopupsQuery.data &&
|
||
"ok" in autoTopupsQuery.data &&
|
||
autoTopupsQuery.data.ok
|
||
? autoTopupsQuery.data
|
||
: null
|
||
const hasPaymentMethod = Boolean(autoTopupData?.hasPaymentMethod)
|
||
const activeAutoTopUp = autoTopupData?.autoTopup ?? null
|
||
const selectedTopUpAmount = customTopUpAmount
|
||
? Number.parseFloat(customTopUpAmount) || 0
|
||
: topUpAmount
|
||
|
||
useEffect(() => {
|
||
if (!autoTopupData) return
|
||
if (!activeAutoTopUp) {
|
||
setAutoTopUpEnabled(false)
|
||
return
|
||
}
|
||
setAutoTopUpEnabled(activeAutoTopUp.enabled)
|
||
setAutoTopUpThreshold(activeAutoTopUp.threshold)
|
||
setAutoTopUpAmount(activeAutoTopUp.quantity)
|
||
}, [activeAutoTopUp, autoTopupData])
|
||
|
||
useEffect(() => {
|
||
if (!hasPaymentMethod && !activeAutoTopUp?.enabled) {
|
||
setAutoTopUpEnabled(false)
|
||
}
|
||
}, [activeAutoTopUp?.enabled, hasPaymentMethod])
|
||
|
||
const planDisplayNames = PLAN_DISPLAY_NAMES
|
||
|
||
const handleUpgrade = async (planId: "api_pro" | "api_max" | "api_scale") => {
|
||
setIsUpgrading(true)
|
||
try {
|
||
const result = await autumn.attach({
|
||
planId,
|
||
successUrl: `${window.location.origin}/settings#billing`,
|
||
})
|
||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||
return
|
||
}
|
||
autumn.refetch?.()
|
||
} catch (error) {
|
||
console.error(error)
|
||
toast.error("Failed to start checkout. Please try again.")
|
||
} finally {
|
||
setIsUpgrading(false)
|
||
}
|
||
}
|
||
|
||
const cancellablePlanId =
|
||
currentPlan === "pro" || currentPlan === "max" || currentPlan === "scale"
|
||
? (`api_${currentPlan}` as const)
|
||
: null
|
||
|
||
const cancelNeedsDetail =
|
||
cancelReason != null && cancelReasonNeedsDetail(cancelReason)
|
||
const canConfirmCancel =
|
||
cancelReason != null &&
|
||
(!cancelNeedsDetail || cancelDetail.trim().length > 0)
|
||
|
||
const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
|
||
const isPlanCanceling = canceledSub != null
|
||
const cancelEndsAt =
|
||
canceledSub?.endsAt != null ? normalizeTimestamp(canceledSub.endsAt) : null
|
||
const cancelEndsLabel =
|
||
cancelEndsAt != null
|
||
? new Date(cancelEndsAt).toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
})
|
||
: "the end of your billing period"
|
||
const cancelEndsDays =
|
||
cancelEndsAt != null
|
||
? Math.max(
|
||
0,
|
||
Math.ceil((cancelEndsAt - Date.now()) / (1000 * 60 * 60 * 24)),
|
||
)
|
||
: null
|
||
|
||
const handleResumeSubscription = async () => {
|
||
if (!canceledSub || isResuming) return
|
||
setIsResuming(true)
|
||
try {
|
||
await autumn.updateSubscription({
|
||
planId: canceledSub.planId,
|
||
cancelAction: "uncancel",
|
||
})
|
||
autumn.refetch?.()
|
||
toast.success(`${planDisplayNames[currentPlan]} subscription resumed.`)
|
||
} catch (error) {
|
||
console.error(error)
|
||
toast.error("Failed to resume subscription. Please try again.")
|
||
} finally {
|
||
setIsResuming(false)
|
||
}
|
||
}
|
||
|
||
const resetCancelForm = () => {
|
||
setCancelReason(null)
|
||
setCancelDetail("")
|
||
}
|
||
|
||
const handleCancelSubscription = async () => {
|
||
if (!cancellablePlanId) return
|
||
setIsCancelling(true)
|
||
try {
|
||
await autumn.updateSubscription({
|
||
planId: cancellablePlanId,
|
||
cancelAction: "cancel_end_of_cycle",
|
||
})
|
||
if (posthog?.__loaded) {
|
||
posthog.capture("subscription_cancelled", {
|
||
reason: cancelReason,
|
||
reason_detail: cancelDetail.trim() || null,
|
||
plan: currentPlan,
|
||
plan_id: cancellablePlanId,
|
||
surface: "nova",
|
||
})
|
||
}
|
||
autumn.refetch?.()
|
||
setIsCancelDialogOpen(false)
|
||
resetCancelForm()
|
||
toast.success(
|
||
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
|
||
)
|
||
} catch (error) {
|
||
console.error(error)
|
||
toast.error("Failed to cancel subscription. Please try again.")
|
||
} finally {
|
||
setIsCancelling(false)
|
||
}
|
||
}
|
||
|
||
const handleTopUp = async (amount: number) => {
|
||
if (!isAdmin) {
|
||
toast.error("Only owners/admins can purchase credits.")
|
||
return
|
||
}
|
||
if (!hasPaidPlan) {
|
||
toast.error("Upgrade to a paid plan before purchasing credits.")
|
||
return
|
||
}
|
||
setTopUpPendingAmount(amount)
|
||
try {
|
||
const result = await autumn.attach({
|
||
planId: TOP_UP_PLAN_ID,
|
||
featureQuantities: [{ featureId: CREDIT_FEATURE_ID, quantity: amount }],
|
||
successUrl: `${window.location.origin}/settings#billing`,
|
||
metadata: {
|
||
source: "nova_billing_topup",
|
||
amount: String(amount),
|
||
},
|
||
})
|
||
if ((result as { paymentUrl?: string })?.paymentUrl) {
|
||
window.location.href = (result as { paymentUrl: string }).paymentUrl
|
||
return
|
||
}
|
||
autumn.refetch?.()
|
||
toast.success(`${formatUsd(amount)} credit top-up added.`)
|
||
} catch (error) {
|
||
console.error(error)
|
||
toast.error("Failed to start top-up checkout. Please try again.")
|
||
} finally {
|
||
setTopUpPendingAmount(null)
|
||
}
|
||
}
|
||
|
||
const handleAutoReloadToggle = (next: boolean) => {
|
||
if (next && !hasPaymentMethod) {
|
||
toast.error(
|
||
"Add a payment method under Manage Billing before enabling auto reload.",
|
||
)
|
||
return
|
||
}
|
||
setAutoTopUpEnabled(next)
|
||
}
|
||
|
||
const handleSaveAutoTopUp = async () => {
|
||
if (!isAdmin) {
|
||
toast.error("Only owners/admins can change auto top-up settings.")
|
||
return
|
||
}
|
||
if (autoTopUpEnabled && !hasPaymentMethod) {
|
||
toast.error(
|
||
"Add a payment method under Manage Billing before enabling auto top-up.",
|
||
)
|
||
return
|
||
}
|
||
setIsSavingAutoTopUp(true)
|
||
try {
|
||
const response = await fetch(`${API_BASE}/v3/auth/billing/auto-topups`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-App-Source": "nova",
|
||
},
|
||
body: JSON.stringify({
|
||
enabled: autoTopUpEnabled,
|
||
threshold: autoTopUpThreshold,
|
||
quantity: autoTopUpAmount,
|
||
purchaseLimit: {
|
||
interval: "month" as const,
|
||
intervalCount: 1,
|
||
limit: 10,
|
||
},
|
||
}),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const body = (await response.json().catch(() => ({}))) as {
|
||
message?: string
|
||
}
|
||
throw new Error(body.message ?? "Failed to update auto top-up")
|
||
}
|
||
|
||
await queryClient.invalidateQueries({
|
||
queryKey: ["billing", org?.id ?? "", "auto-topups"],
|
||
})
|
||
await queryClient.invalidateQueries({ queryKey: ["autumn"] })
|
||
autumn.refetch?.()
|
||
toast.success(
|
||
autoTopUpEnabled
|
||
? "Auto top-up settings saved."
|
||
: "Auto top-up disabled.",
|
||
)
|
||
} catch (error) {
|
||
console.error(error)
|
||
toast.error(
|
||
error instanceof Error
|
||
? error.message
|
||
: "Failed to update auto top-up.",
|
||
)
|
||
} finally {
|
||
setIsSavingAutoTopUp(false)
|
||
}
|
||
}
|
||
|
||
const handleManageBilling = () => {
|
||
autumn.openCustomerPortal?.({
|
||
returnUrl: `${window.location.origin}/settings#billing`,
|
||
})
|
||
}
|
||
|
||
const getPlanCardAction = (plan: PlanCardDefinition) => {
|
||
const disabled = isUpgrading || isCheckingStatus || autumn.isLoading
|
||
const isCurrentPlan = plan.id === currentPlan
|
||
const isIncludedPlan = PLAN_RANK[currentPlan] > PLAN_RANK[plan.id]
|
||
|
||
if (plan.id === "free") {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||
)}
|
||
>
|
||
{hasPaidPlan ? "Included with current plan" : "Your current plan"}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
|
||
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleUpgrade("api_scale")}
|
||
disabled={disabled}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
|
||
)}
|
||
>
|
||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||
Activate Scale
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (isCurrentPlan) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||
)}
|
||
>
|
||
Your current plan
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (isIncludedPlan) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"border border-white/[0.04] bg-white/[0.02] text-[#737373]",
|
||
)}
|
||
>
|
||
Included with {planDisplayNames[currentPlan]}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (plan.isContactSales) {
|
||
return (
|
||
<a
|
||
href="mailto:support@supermemory.com?subject=Enterprise%20plan"
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"border border-white/[0.08] bg-transparent text-[#FAFAFA] hover:bg-white/[0.04]",
|
||
)}
|
||
>
|
||
Contact sales
|
||
</a>
|
||
)
|
||
}
|
||
|
||
const checkoutPlanId =
|
||
plan.productId === "api_pro" ||
|
||
plan.productId === "api_max" ||
|
||
plan.productId === "api_scale"
|
||
? plan.productId
|
||
: null
|
||
if (!checkoutPlanId) return null
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleUpgrade(checkoutPlanId)}
|
||
disabled={disabled}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
PLAN_CARD_ACTION_CLASS,
|
||
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
|
||
)}
|
||
>
|
||
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
|
||
{isCompanyBrain && plan.id === "scale"
|
||
? "Activate Scale"
|
||
: `Upgrade to ${plan.name}`}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex w-full flex-col gap-7">
|
||
<section id="billing-subscription" className="flex flex-col gap-4">
|
||
<SectionTitle>Billing & Subscription</SectionTitle>
|
||
<SettingsCard>
|
||
<div className="flex flex-col gap-5">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"font-semibold text-[18px] tracking-[-0.18px] text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{isOnTrial ||
|
||
isBrainTrialEnded ||
|
||
(isCompanyBrain && currentPlan === "scale")
|
||
? "Scale plan"
|
||
: hasPaidPlan
|
||
? `${planDisplayNames[currentPlan]} plan`
|
||
: "Free plan"}
|
||
</p>
|
||
<Pill
|
||
tone={
|
||
isPlanCanceling || isBrainTrialEnded
|
||
? "warning"
|
||
: isOnTrial
|
||
? "active"
|
||
: hasPaidPlan
|
||
? "active"
|
||
: "muted"
|
||
}
|
||
>
|
||
{isPlanCanceling
|
||
? "Cancelling"
|
||
: isBrainTrialEnded
|
||
? brainTrial.status === "exhausted"
|
||
? "Credits used up"
|
||
: "Trial ended"
|
||
: isOnTrial
|
||
? "Free trial"
|
||
: hasPaidPlan
|
||
? "Active"
|
||
: "Free"}
|
||
</Pill>
|
||
</div>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-1 text-[13px] leading-relaxed text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
{isPlanCanceling
|
||
? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}.${isCompanyBrain ? "" : " You'll move to Free after that."}`
|
||
: isOnTrial
|
||
? [
|
||
trialEndsLabel
|
||
? `Ends ${trialEndsLabel}${trialDaysLeft != null ? ` · ${trialDaysLeft} day${trialDaysLeft !== 1 ? "s" : ""} left` : ""}`
|
||
: null,
|
||
trialCredits != null
|
||
? `$${trialCredits} trial credits`
|
||
: null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")
|
||
: isBrainTrialEnded
|
||
? "Trial ended. Activate Scale to restore access."
|
||
: hasPaidPlan
|
||
? "Expanded memory, connections, and usage for this workspace."
|
||
: "Upgrade when you need more workspace usage and integrations."}
|
||
</p>
|
||
{isBrainTrialEnded ? (
|
||
<div className="mt-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleUpgrade("api_scale")}
|
||
disabled={
|
||
isUpgrading || isCheckingStatus || autumn.isLoading
|
||
}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-7 items-center gap-1.5 rounded-[7px] bg-[#0054AD] px-2.5 text-[12px] font-semibold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:opacity-60",
|
||
)}
|
||
>
|
||
{isUpgrading ? (
|
||
<LoaderIcon className="size-3 animate-spin" />
|
||
) : null}
|
||
Activate Scale
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={handleManageBilling}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 items-center gap-2 rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24]",
|
||
)}
|
||
>
|
||
<Settings className="size-3.5 text-[#737373]" />
|
||
Manage
|
||
</button>
|
||
{isPlanCanceling ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleResumeSubscription()}
|
||
disabled={isResuming}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-50",
|
||
)}
|
||
>
|
||
{isResuming ? (
|
||
<LoaderIcon className="size-3.5 animate-spin" />
|
||
) : null}
|
||
Resume plan
|
||
</button>
|
||
) : cancellablePlanId ? (
|
||
<Dialog
|
||
open={isCancelDialogOpen}
|
||
onOpenChange={(open) => {
|
||
setIsCancelDialogOpen(open)
|
||
if (!open) resetCancelForm()
|
||
}}
|
||
>
|
||
<DialogTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 items-center rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity hover:opacity-90",
|
||
)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</DialogTrigger>
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
className="w-[min(760px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)] sm:max-w-[760px]"
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[17px] font-semibold tracking-[-0.17px] text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
Cancel {planDisplayNames[currentPlan]}?
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-2 text-[13px] leading-relaxed text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
You keep paid features until the current billing
|
||
period ends
|
||
{daysRemaining !== null
|
||
? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)`
|
||
: ""}
|
||
.
|
||
</p>
|
||
</div>
|
||
<DialogClose asChild>
|
||
<button
|
||
type="button"
|
||
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
|
||
>
|
||
<X className="size-4" />
|
||
</button>
|
||
</DialogClose>
|
||
</div>
|
||
|
||
<div className="@container">
|
||
<div className="grid gap-4 @xl:grid-cols-[1fr_auto_1fr] @xl:gap-5">
|
||
<div className="relative flex flex-col overflow-hidden rounded-[14px] border border-[#1E4FA0]/25 bg-gradient-to-b from-[#121826] to-[#0C0E13] p-5">
|
||
<Logo className="pointer-events-none absolute -right-8 -bottom-10 z-0 h-56 w-auto opacity-[0.03]" />
|
||
<div className="relative z-10 flex flex-1 flex-col">
|
||
<div className="flex items-center gap-2.5">
|
||
<GoogleMeetIcon className="h-[18px] w-auto" />
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[15px] font-semibold tracking-[-0.01em] text-white",
|
||
)}
|
||
>
|
||
Talk to us first
|
||
</p>
|
||
</div>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-3 text-[13px] leading-relaxed text-[#AEBCD8]",
|
||
)}
|
||
>
|
||
Give us 15 minutes before you go — a quick call
|
||
with the team where we'll:
|
||
</p>
|
||
<ul className="mt-3 flex flex-col gap-2.5">
|
||
{[
|
||
"Fix anything that's not working for you",
|
||
"Help you integrate Supermemory the right way",
|
||
"Get a discount to stay",
|
||
].map((benefit) => (
|
||
<li
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"flex items-start gap-2 text-[13px] leading-snug text-[#D4DEEE]",
|
||
)}
|
||
key={benefit}
|
||
>
|
||
<Check
|
||
className="mt-0.5 size-[15px] shrink-0 text-[#3FD37E]"
|
||
strokeWidth={2.5}
|
||
/>
|
||
<span>{benefit}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<a
|
||
href={BOOK_CALL_HREF}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
onClick={() => {
|
||
if (posthog?.__loaded) {
|
||
posthog.capture(
|
||
"cancel_book_call_clicked",
|
||
{
|
||
plan: currentPlan,
|
||
surface: "nova",
|
||
},
|
||
)
|
||
}
|
||
}}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"group mt-auto inline-flex h-10 items-center justify-center gap-1.5 rounded-[10px] bg-[#0054AD] px-4 text-[13px] font-semibold text-white transition-colors hover:bg-[#0B65C9]",
|
||
)}
|
||
>
|
||
Book a call
|
||
<ChevronRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-center">
|
||
<span className="rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[#737373]">
|
||
or
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex flex-1 flex-col rounded-[14px] border border-white/10 bg-gradient-to-b from-[#12151B] to-[#0C0E13] p-5">
|
||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
|
||
Why are you leaving?
|
||
</p>
|
||
<div className="mt-2 flex flex-col gap-0.5">
|
||
{CANCEL_REASONS.map((option) => {
|
||
const selected = cancelReason === option.value
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
onClick={() =>
|
||
setCancelReason(option.value)
|
||
}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"flex items-center gap-2.5 rounded-[8px] px-2 py-1.5 text-left text-[13px] transition-colors",
|
||
selected
|
||
? "text-[#FAFAFA]"
|
||
: "text-[#A3A3A3] hover:bg-white/[0.03]",
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||
selected
|
||
? "border-[#0054AD]"
|
||
: "border-white/25",
|
||
)}
|
||
>
|
||
{selected ? (
|
||
<span className="size-2 rounded-full bg-[#0054AD]" />
|
||
) : null}
|
||
</span>
|
||
{option.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{cancelReason !== null ? (
|
||
<div className="mt-3">
|
||
<label
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[13px] text-[#A3A3A3]",
|
||
)}
|
||
htmlFor="cancel-detail"
|
||
>
|
||
{cancelNeedsDetail
|
||
? "Tell us more (required)"
|
||
: "Anything else? (optional)"}
|
||
</label>
|
||
<textarea
|
||
className="mt-2 min-h-[72px] w-full resize-none rounded-[9px] border border-white/10 bg-[#0D121A] px-3 py-2 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
|
||
id="cancel-detail"
|
||
onChange={(e) =>
|
||
setCancelDetail(e.target.value)
|
||
}
|
||
placeholder="What could we have done better?"
|
||
value={cancelDetail}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="mt-auto pt-5">
|
||
<DialogClose asChild>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"h-9 w-full text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
Keep plan
|
||
</button>
|
||
</DialogClose>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleCancelSubscription()}
|
||
disabled={isCancelling || !canConfirmCancel}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-1.5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-[#C73B1B]/25 bg-[#290F0A] text-[13px] font-semibold text-[#E5604A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50",
|
||
)}
|
||
>
|
||
{isCancelling ? (
|
||
<LoaderIcon className="size-3.5 animate-spin" />
|
||
) : null}
|
||
Cancel subscription
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{showPlanUsage ? (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[13px] font-medium text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
{isOnTrial ? "Trial credit usage" : "Plan usage"}
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[13px] font-semibold tabular-nums text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{formatUsd(usdSpent)}
|
||
{usdIncluded > 0 ? (
|
||
<span className="font-medium text-[#737373]">
|
||
{" "}
|
||
/ {formatUsd(usdIncluded)}
|
||
</span>
|
||
) : null}
|
||
<span className="ml-2 text-[#A3A3A3]">
|
||
{planUsagePct < 1 && planUsagePct > 0
|
||
? "< 1"
|
||
: Math.round(planUsagePct)}
|
||
% used
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full bg-[#2E353D]">
|
||
<div
|
||
className="h-full rounded-full bg-[#4BA0FA] transition-all"
|
||
style={{
|
||
width: `${planUsagePct}%`,
|
||
background:
|
||
planUsagePct > 80
|
||
? "#C73B1B"
|
||
: "linear-gradient(90deg, #2368D2 0%, #4BA0FA 100%)",
|
||
}}
|
||
/>
|
||
</div>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[12px] text-[#737373]",
|
||
)}
|
||
>
|
||
{isOnTrial
|
||
? "Credits apply for the trial period"
|
||
: daysRemaining !== null
|
||
? `Resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
|
||
: "Usage resets with your billing cycle"}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SettingsCard>
|
||
</section>
|
||
|
||
<section id="billing-plans" className="flex flex-col gap-4">
|
||
<SectionTitle
|
||
aside={
|
||
!isCompanyBrain && isPlanCarouselActive ? (
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlanPage(0)}
|
||
disabled={planPage === 0}
|
||
className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
|
||
aria-label="Show Free and Pro plans"
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlanPage(1)}
|
||
disabled={planPage === 1}
|
||
className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
|
||
aria-label="Show Max, Scale and Enterprise plans"
|
||
>
|
||
<ChevronRight className="size-4" />
|
||
</button>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
>
|
||
Plans
|
||
</SectionTitle>
|
||
{isCompanyBrain ? (
|
||
<div className="grid w-full gap-4 md:grid-cols-2">
|
||
{COMPANY_BRAIN_PLAN_CARDS.map((plan) => (
|
||
<PlanCard
|
||
action={getPlanCardAction(plan)}
|
||
key={plan.id}
|
||
plan={
|
||
plan.id === "scale" && isOnTrial && trialCredits != null
|
||
? {
|
||
...plan,
|
||
credits: `$${trialCredits} trial / $600 paid`,
|
||
description:
|
||
"14-day free trial with Company Brain — activate anytime",
|
||
}
|
||
: plan
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="overflow-hidden">
|
||
<div
|
||
className="flex gap-4 transition-transform duration-300 ease-out"
|
||
style={{
|
||
transform:
|
||
planPage === 1 ? "translateX(calc(-100% - 1rem))" : "none",
|
||
}}
|
||
>
|
||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-2">
|
||
{PLAN_CARDS.map((plan) => (
|
||
<PlanCard
|
||
action={getPlanCardAction(plan)}
|
||
key={plan.id}
|
||
plan={plan}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="grid w-full shrink-0 gap-4 md:grid-cols-3">
|
||
{ADVANCED_PLAN_CARDS.map((plan) => (
|
||
<PlanCard
|
||
action={getPlanCardAction(plan)}
|
||
key={plan.id}
|
||
plan={plan}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isPlanCarouselActive ? null : (
|
||
<div className="flex justify-end px-2 pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setIsPlanCarouselActive(true)
|
||
setPlanPage(1)
|
||
}}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex items-center justify-center gap-2 text-[13px] font-semibold text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
<span className="relative after:absolute after:right-0 after:-bottom-0.5 after:left-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:after:scale-x-100">
|
||
Other plans
|
||
</span>
|
||
<span
|
||
className="translate-x-1 text-[15px]"
|
||
aria-hidden="true"
|
||
>
|
||
→
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
<Dialog open={isCreditsDialogOpen} onOpenChange={setIsCreditsDialogOpen}>
|
||
<DialogContent
|
||
showCloseButton={false}
|
||
style={{ boxShadow: SURFACE_SHADOW }}
|
||
className="w-[min(560px,calc(100vw-32px))] rounded-[22px] border border-white/[0.12] bg-[#1B1F24] p-6"
|
||
>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[22px] font-semibold tracking-[-0.22px] text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
Buy Credits
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-2 text-[15px] text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
Add USD to your balance for metered usage.
|
||
</p>
|
||
</div>
|
||
<DialogClose asChild>
|
||
<button
|
||
type="button"
|
||
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
|
||
>
|
||
<X className="size-5" />
|
||
</button>
|
||
</DialogClose>
|
||
</div>
|
||
|
||
<div className="mt-8 flex flex-col gap-5">
|
||
<div className="flex flex-col gap-3">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[16px] font-semibold text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
Choose an amount
|
||
</p>
|
||
<FieldSelect
|
||
value={topUpAmount}
|
||
values={TOP_UP_AMOUNTS}
|
||
prefix="$"
|
||
onChange={(value) => {
|
||
setTopUpAmount(value)
|
||
setCustomTopUpAmount("")
|
||
}}
|
||
disabled={topUpPendingAmount !== null}
|
||
/>
|
||
<div className="flex flex-col gap-2">
|
||
<label
|
||
htmlFor="custom-topup-amount"
|
||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||
>
|
||
Custom amount (USD)
|
||
</label>
|
||
<input
|
||
id="custom-topup-amount"
|
||
inputMode="decimal"
|
||
min={1}
|
||
onChange={(event) => setCustomTopUpAmount(event.target.value)}
|
||
placeholder="e.g. 75"
|
||
type="number"
|
||
value={customTopUpAmount}
|
||
className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-white/[0.06]" />
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||
Auto reload
|
||
</p>
|
||
<span className="text-[12px] text-[#737373]">
|
||
{autoTopUpEnabled ? "on" : "off"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-4">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[16px] text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
isSavingAutoTopUp ||
|
||
!isAdmin ||
|
||
(!hasPaymentMethod && !activeAutoTopUp?.enabled)
|
||
}
|
||
onClick={() => handleAutoReloadToggle(!autoTopUpEnabled)}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
|
||
)}
|
||
>
|
||
{autoTopUpEnabled ? "Disable" : "Enable"}
|
||
</button>
|
||
</div>
|
||
|
||
{!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
|
||
<p className="text-[13px] text-[#737373]">
|
||
Save a card in Manage Billing to enable automatic reloads.
|
||
</p>
|
||
) : null}
|
||
|
||
<div
|
||
className={cn(
|
||
"grid gap-3 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3 transition-[filter,opacity] sm:grid-cols-2",
|
||
!autoTopUpEnabled &&
|
||
"pointer-events-none select-none opacity-45 blur-[3px]",
|
||
)}
|
||
>
|
||
<div className="flex flex-col gap-2">
|
||
<label
|
||
htmlFor="auto-topup-threshold"
|
||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||
>
|
||
Threshold (USD)
|
||
</label>
|
||
<input
|
||
id="auto-topup-threshold"
|
||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||
inputMode="decimal"
|
||
min={0}
|
||
onChange={(event) => {
|
||
const value = Number.parseFloat(event.target.value)
|
||
setAutoTopUpThreshold(Number.isFinite(value) ? value : 0)
|
||
}}
|
||
type="number"
|
||
value={
|
||
Number.isFinite(autoTopUpThreshold)
|
||
? autoTopUpThreshold
|
||
: ""
|
||
}
|
||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<label
|
||
htmlFor="auto-topup-amount"
|
||
className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]"
|
||
>
|
||
Reload amount (USD)
|
||
</label>
|
||
<input
|
||
id="auto-topup-amount"
|
||
disabled={!autoTopUpEnabled || isSavingAutoTopUp}
|
||
inputMode="decimal"
|
||
min={0.01}
|
||
onChange={(event) => {
|
||
const value = Number.parseFloat(event.target.value)
|
||
setAutoTopUpAmount(Number.isFinite(value) ? value : 0)
|
||
}}
|
||
type="number"
|
||
value={
|
||
Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : ""
|
||
}
|
||
className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
|
||
/>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleSaveAutoTopUp()}
|
||
disabled={isSavingAutoTopUp || !isAdmin}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
|
||
)}
|
||
>
|
||
{isSavingAutoTopUp ? (
|
||
<LoaderIcon className="size-3.5 animate-spin" />
|
||
) : null}
|
||
Save threshold & reload amount
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleTopUp(selectedTopUpAmount)}
|
||
disabled={
|
||
topUpPendingAmount !== null ||
|
||
!isAdmin ||
|
||
selectedTopUpAmount <= 0
|
||
}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
|
||
)}
|
||
>
|
||
{topUpPendingAmount !== null ? (
|
||
<LoaderIcon className="size-4 animate-spin" />
|
||
) : null}
|
||
Buy {formatUsd(selectedTopUpAmount)} in credits
|
||
</button>
|
||
|
||
{!isAdmin ? (
|
||
<p className="text-center text-[11px] text-[#737373]">
|
||
Only owners/admins can purchase credits.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{hasPaidPlan || isOnTrial ? (
|
||
<section className="flex flex-col gap-4">
|
||
<SectionTitle>Credits</SectionTitle>
|
||
{isOnTrial ? (
|
||
<SettingsCard>
|
||
<div className="flex min-w-0 items-start gap-3">
|
||
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||
Trial credits
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-2 text-[14px] font-semibold text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{formatUsd(creditRemaining)} remaining
|
||
{usdIncluded > 0 ? (
|
||
<span className="font-medium text-[#737373]">
|
||
{" "}
|
||
of {formatUsd(usdIncluded)}
|
||
</span>
|
||
) : trialCredits != null ? (
|
||
<span className="font-medium text-[#737373]">
|
||
{" "}
|
||
of {formatUsd(trialCredits)}
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
<p className="mt-2 text-[12px] leading-snug text-[#737373]">
|
||
Company Brain trials include{" "}
|
||
<span className="font-semibold text-[#A3A3A3]">
|
||
${trialCredits ?? 200}
|
||
</span>{" "}
|
||
in usage credits. Paid Scale includes $600/mo. Top-ups are
|
||
available after you activate.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SettingsCard>
|
||
) : (
|
||
<SettingsCard className="border border-dashed border-white/10 bg-[#14161A]/70">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="flex min-w-0 items-start gap-3">
|
||
<Coins className="mt-1 size-4 shrink-0 text-[#4BA0FA]" />
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] font-bold uppercase tracking-[0.5px] text-[#737373]">
|
||
Top-up credits
|
||
</p>
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"mt-2 text-[14px] font-semibold text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{creditRemaining > 0
|
||
? `${formatUsd(creditRemaining)} available`
|
||
: "No top-up credits yet"}
|
||
</p>
|
||
<p className="mt-2 text-[12px] leading-snug text-[#737373]">
|
||
Optional add-on that{" "}
|
||
<span className="font-semibold text-[#A3A3A3]">
|
||
rolls over
|
||
</span>{" "}
|
||
month-to-month, separate from your monthly usage above.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCreditsDialogOpen(true)}
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24]",
|
||
)}
|
||
>
|
||
<Plus className="size-3.5" />
|
||
{creditRemaining > 0 ? "Add more" : "Buy credits"}
|
||
</button>
|
||
</div>
|
||
</SettingsCard>
|
||
)}
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="flex flex-col gap-4">
|
||
<SectionTitle>Invoice history</SectionTitle>
|
||
<SettingsCard className="p-0">
|
||
{invoicesQuery.isLoading ? (
|
||
<div className="flex h-28 items-center justify-center gap-3 text-[#737373]">
|
||
<LoaderIcon className="size-4 animate-spin" />
|
||
<span className={cn(dmSans125ClassName(), "text-[13px]")}>
|
||
Loading invoices
|
||
</span>
|
||
</div>
|
||
) : invoices.length === 0 ? (
|
||
<div className="flex h-28 flex-col items-center justify-center gap-2 px-5 text-center">
|
||
<ReceiptText className="size-5 text-[#737373]" />
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"text-[13px] text-[#A3A3A3]",
|
||
)}
|
||
>
|
||
No invoices yet
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-white/[0.06]">
|
||
{invoices.map((invoice) => {
|
||
const date = formatDate(normalizeTimestamp(invoice.createdAt))
|
||
const label = invoice.planIds?.length
|
||
? invoice.planIds
|
||
.map((id) => getInvoiceProductLabel(id))
|
||
.join(", ")
|
||
: "Billing invoice"
|
||
return (
|
||
<div
|
||
key={invoice.stripeId}
|
||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-5 py-3 sm:grid-cols-[minmax(0,1.2fr)_110px_90px_auto]"
|
||
>
|
||
<div className="min-w-0">
|
||
<p
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"truncate text-[13px] font-medium text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{label}
|
||
</p>
|
||
<p className="mt-0.5 truncate text-[12px] text-[#737373]">
|
||
{invoice.stripeId}
|
||
</p>
|
||
</div>
|
||
<p className="hidden self-center text-[13px] text-[#A3A3A3] sm:block">
|
||
{date}
|
||
</p>
|
||
<p className="self-center text-right text-[13px] font-semibold tabular-nums text-[#FAFAFA] sm:text-left">
|
||
{formatInvoiceAmount(invoice.total, invoice.currency)}
|
||
</p>
|
||
<div className="col-span-2 flex items-center justify-between gap-3 sm:col-span-1 sm:justify-end">
|
||
<Pill tone={getStatusTone(invoice.status)}>
|
||
{invoice.status}
|
||
</Pill>
|
||
{invoice.hostedInvoiceUrl ? (
|
||
<a
|
||
href={invoice.hostedInvoiceUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex size-7 items-center justify-center rounded-[8px] border border-white/10 bg-[#0D121A] text-[#737373] transition-colors hover:text-[#FAFAFA]"
|
||
aria-label={`Open invoice ${invoice.stripeId}`}
|
||
>
|
||
<ExternalLink className="size-3.5" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</SettingsCard>
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|