mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
improvement(ui): updated subscription and team settings modals to emcn (#2477)
This commit is contained in:
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
import {
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from '@/components/emcn'
|
||||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
@@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
|
|
||||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||||
referenceId = activeOrgId
|
referenceId = activeOrgId
|
||||||
// Get subscription ID for team/enterprise
|
|
||||||
subscriptionId = subData?.data?.id
|
subscriptionId = subData?.data?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
referenceId = activeOrgId
|
referenceId = activeOrgId
|
||||||
subscriptionId = subData?.data?.id
|
subscriptionId = subData?.data?.id
|
||||||
} else {
|
} else {
|
||||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
|
||||||
referenceId = session.user.id
|
referenceId = session.user.id
|
||||||
subscriptionId = undefined
|
subscriptionId = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
||||||
|
|
||||||
// Build restore params - only include subscriptionId if we have one (team/enterprise)
|
|
||||||
const restoreParams: any = { referenceId }
|
const restoreParams: any = { referenceId }
|
||||||
if (subscriptionId) {
|
if (subscriptionId) {
|
||||||
restoreParams.subscriptionId = subscriptionId
|
restoreParams.subscriptionId = subscriptionId
|
||||||
@@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
logger.info('Subscription restored successfully', result)
|
logger.info('Subscription restored successfully', result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate queries to refresh data
|
|
||||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||||
if (activeOrgId) {
|
if (activeOrgId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
|
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
|
||||||
@@ -175,10 +179,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
if (!date) return 'end of current billing period'
|
if (!date) return 'end of current billing period'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure we have a valid Date object
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date)
|
const dateObj = date instanceof Date ? date : new Date(date)
|
||||||
|
|
||||||
// Check if the date is valid
|
|
||||||
if (Number.isNaN(dateObj.getTime())) {
|
if (Number.isNaN(dateObj.getTime())) {
|
||||||
return 'end of current billing period'
|
return 'end of current billing period'
|
||||||
}
|
}
|
||||||
@@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
|
|
||||||
const periodEndDate = getPeriodEndDate()
|
const periodEndDate = getPeriodEndDate()
|
||||||
|
|
||||||
// Check if subscription is set to cancel at period end
|
|
||||||
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px]'>
|
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
|
||||||
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
|
|
||||||
</span>
|
|
||||||
{isCancelAtPeriodEnd && (
|
{isCancelAtPeriodEnd && (
|
||||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||||
You'll keep access until {formatDate(periodEndDate)}
|
You'll keep access until {formatDate(periodEndDate)}
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -217,7 +216,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
onClick={() => setIsDialogOpen(true)}
|
onClick={() => setIsDialogOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 rounded-[8px] font-medium text-xs',
|
'h-8 rounded-[8px] text-[13px]',
|
||||||
error && 'border-[var(--text-error)] text-[var(--text-error)]'
|
error && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -231,7 +230,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
|
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
{isCancelAtPeriodEnd
|
{isCancelAtPeriodEnd
|
||||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||||
@@ -244,8 +243,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
|
|
||||||
{!isCancelAtPeriodEnd && (
|
{!isCancelAtPeriodEnd && (
|
||||||
<div className='mt-3'>
|
<div className='mt-3'>
|
||||||
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
|
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
|
||||||
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
|
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
|
||||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||||
<li>• No more charges</li>
|
<li>• No more charges</li>
|
||||||
<li>• Data preserved</li>
|
<li>• Data preserved</li>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalBody,
|
||||||
ModalClose,
|
ModalClose,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
@@ -90,7 +92,6 @@ export function CreditBalance({
|
|||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
setIsOpen(open)
|
setIsOpen(open)
|
||||||
if (open) {
|
if (open) {
|
||||||
// Generate new requestId when modal opens - same ID used for entire session
|
|
||||||
setRequestId(crypto.randomUUID())
|
setRequestId(crypto.randomUUID())
|
||||||
} else {
|
} else {
|
||||||
setAmount('')
|
setAmount('')
|
||||||
@@ -102,72 +103,66 @@ export function CreditBalance({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<span className='text-muted-foreground text-sm'>Credit Balance</span>
|
<Label>Credit Balance</Label>
|
||||||
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
{isLoading ? '...' : `$${balance.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canPurchase && (
|
{canPurchase && (
|
||||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<ModalTrigger asChild>
|
<ModalTrigger asChild>
|
||||||
<Button variant='outline'>Add Credits</Button>
|
<Button variant='outline' className='h-8 rounded-[8px] text-[13px]'>
|
||||||
|
Add Credits
|
||||||
|
</Button>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
<ModalContent>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Add Credits</ModalHeader>
|
<ModalHeader>Add Credits</ModalHeader>
|
||||||
<div className='px-4'>
|
<ModalBody>
|
||||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
{success ? (
|
||||||
Credits are used before overage charges. Min $10, max $1,000.
|
<p className='text-center text-[13px] text-[var(--text-primary)]'>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{success ? (
|
|
||||||
<div className='py-4 text-center'>
|
|
||||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
|
||||||
Credits added successfully!
|
Credits added successfully!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<div className='flex flex-col gap-3 py-2'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
<div className='flex flex-col gap-1'>
|
Credits are used before overage charges. Min $10, max $1,000.
|
||||||
<label
|
|
||||||
htmlFor='credit-amount'
|
|
||||||
className='text-[12px] text-[var(--text-secondary)]'
|
|
||||||
>
|
|
||||||
Amount (USD)
|
|
||||||
</label>
|
|
||||||
<div className='relative'>
|
|
||||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
|
|
||||||
$
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
id='credit-amount'
|
|
||||||
type='text'
|
|
||||||
inputMode='numeric'
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => handleAmountChange(e.target.value)}
|
|
||||||
placeholder='50'
|
|
||||||
className='pl-7'
|
|
||||||
disabled={isPurchasing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <span className='text-[11px] text-red-500'>{error}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
|
|
||||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
|
||||||
Credits are non-refundable and don't expire. They'll be applied automatically to
|
|
||||||
your {entityType === 'organization' ? 'team' : ''} usage.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||||
|
<Label htmlFor='credit-amount'>Amount (USD)</Label>
|
||||||
|
<div className='relative'>
|
||||||
|
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
id='credit-amount'
|
||||||
|
type='text'
|
||||||
|
inputMode='numeric'
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => handleAmountChange(e.target.value)}
|
||||||
|
placeholder='50'
|
||||||
|
className='pl-7'
|
||||||
|
disabled={isPurchasing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <span className='text-[12px] text-[var(--text-error)]'>{error}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||||
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Credits are non-refundable and don't expire. They'll be applied automatically
|
||||||
|
to your {entityType === 'organization' ? 'team' : ''} usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
{!success && (
|
{!success && (
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<ModalClose asChild>
|
<ModalClose asChild>
|
||||||
<Button variant='ghost' disabled={isPurchasing}>
|
<Button disabled={isPurchasing}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</ModalClose>
|
</ModalClose>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export function PlanCard({
|
|||||||
if (typeof price === 'string') {
|
if (typeof price === 'string') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className='font-semibold text-xl'>{price}</span>
|
<span className='font-semibold text-[20px]'>{price}</span>
|
||||||
{priceSubtext && (
|
{priceSubtext && (
|
||||||
<span className='ml-1 text-[var(--text-muted)] text-xs'>{priceSubtext}</span>
|
<span className='ml-1 text-[12px] text-[var(--text-muted)]'>{priceSubtext}</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -58,13 +58,13 @@ export function PlanCard({
|
|||||||
const renderFeatures = () => {
|
const renderFeatures = () => {
|
||||||
if (isHorizontal) {
|
if (isHorizontal) {
|
||||||
return (
|
return (
|
||||||
<div className='mt-3 flex flex-wrap items-center gap-4'>
|
<div className='mt-3 flex flex-wrap items-center gap-3'>
|
||||||
{features.map((feature, index) => (
|
{features.map((feature, index) => (
|
||||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
|
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-[12px]'>
|
||||||
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-muted)]' />
|
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]' />
|
||||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||||
{index < features.length - 1 && (
|
{index < features.length - 1 && (
|
||||||
<div className='ml-4 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
<div className='ml-3 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -75,12 +75,12 @@ export function PlanCard({
|
|||||||
return (
|
return (
|
||||||
<ul className='mb-4 flex-1 space-y-2'>
|
<ul className='mb-4 flex-1 space-y-2'>
|
||||||
{features.map((feature, index) => (
|
{features.map((feature, index) => (
|
||||||
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
|
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-[12px]'>
|
||||||
<feature.icon
|
<feature.icon
|
||||||
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-muted)]'
|
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]'
|
||||||
aria-hidden='true'
|
aria-hidden='true'
|
||||||
/>
|
/>
|
||||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -91,24 +91,24 @@ export function PlanCard({
|
|||||||
<article
|
<article
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
|
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
|
||||||
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
|
isHorizontal ? 'flex-row items-center justify-between gap-6' : 'flex-col',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className={isHorizontal ? undefined : 'mb-4'}>
|
<header className={isHorizontal ? 'flex-1' : 'mb-4'}>
|
||||||
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
|
<h3 className='mb-2 font-semibold text-[14px]'>{name}</h3>
|
||||||
<div className='flex items-baseline'>{renderPrice()}</div>
|
<div className='flex items-baseline'>{renderPrice()}</div>
|
||||||
{isHorizontal && renderFeatures()}
|
{isHorizontal && renderFeatures()}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!isHorizontal && renderFeatures()}
|
{!isHorizontal && renderFeatures()}
|
||||||
|
|
||||||
<div className={isHorizontal ? 'ml-auto' : undefined}>
|
<div className={isHorizontal ? 'flex-shrink-0' : undefined}>
|
||||||
<Button
|
<Button
|
||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 rounded-[8px] text-xs',
|
'h-9 rounded-[8px] text-[13px]',
|
||||||
isHorizontal ? 'px-4' : 'w-full',
|
isHorizontal ? 'min-w-[100px] px-6' : 'w-full',
|
||||||
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||||
)}
|
)}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Switch } from '@/components/emcn'
|
|
||||||
import { Skeleton } from '@/components/ui'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Label,
|
||||||
SelectContent,
|
Popover,
|
||||||
SelectGroup,
|
PopoverContent,
|
||||||
SelectItem,
|
PopoverItem,
|
||||||
SelectLabel,
|
PopoverSection,
|
||||||
SelectTrigger,
|
PopoverTrigger,
|
||||||
SelectValue,
|
Switch,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/emcn'
|
||||||
|
import { Skeleton } from '@/components/ui'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
@@ -270,7 +270,6 @@ export function Subscription() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// UI state computed values
|
|
||||||
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
||||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
||||||
|
|
||||||
@@ -333,7 +332,7 @@ export function Subscription() {
|
|||||||
<PlanCard
|
<PlanCard
|
||||||
key='enterprise'
|
key='enterprise'
|
||||||
name='Enterprise'
|
name='Enterprise'
|
||||||
price={<span className='font-semibold text-xl'>Custom</span>}
|
price={<span className='font-semibold text-[20px]'>Custom</span>}
|
||||||
priceSubtext={
|
priceSubtext={
|
||||||
layout === 'horizontal'
|
layout === 'horizontal'
|
||||||
? 'Custom solutions tailored to your enterprise needs'
|
? 'Custom solutions tailored to your enterprise needs'
|
||||||
@@ -458,7 +457,7 @@ export function Subscription() {
|
|||||||
{/* Enterprise Usage Limit Notice */}
|
{/* Enterprise Usage Limit Notice */}
|
||||||
{subscription.isEnterprise && (
|
{subscription.isEnterprise && (
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<p className='text-[var(--text-muted)] text-xs'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
Contact enterprise for support usage limit changes
|
Contact enterprise for support usage limit changes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +466,7 @@ export function Subscription() {
|
|||||||
{/* Team Member Notice */}
|
{/* Team Member Notice */}
|
||||||
{permissions.showTeamMemberView && (
|
{permissions.showTeamMemberView && (
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<p className='text-[var(--text-muted)] text-xs'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
Contact your team admin to increase limits
|
Contact your team admin to increase limits
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -534,72 +533,78 @@ export function Subscription() {
|
|||||||
{/* Next Billing Date */}
|
{/* Next Billing Date */}
|
||||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px]'>Next Billing Date</span>
|
<Label>Next Billing Date</Label>
|
||||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Billing usage notifications toggle */}
|
{/* Usage notifications */}
|
||||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||||
|
|
||||||
{/* Cancel Subscription */}
|
{/* Cancel Subscription */}
|
||||||
{permissions.canCancelSubscription && (
|
{permissions.canCancelSubscription && (
|
||||||
<div className='mt-[8px]'>
|
<CancelSubscription
|
||||||
<CancelSubscription
|
subscription={{
|
||||||
subscription={{
|
plan: subscription.plan,
|
||||||
plan: subscription.plan,
|
status: subscription.status,
|
||||||
status: subscription.status,
|
isPaid: subscription.isPaid,
|
||||||
isPaid: subscription.isPaid,
|
}}
|
||||||
}}
|
subscriptionData={{
|
||||||
subscriptionData={{
|
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Workspace API Billing Settings */}
|
{/* Billed Account for Workspace */}
|
||||||
{canManageWorkspaceKeys && (
|
{canManageWorkspaceKeys && (
|
||||||
<div className='mt-[24px] flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px]'>Billed Account for Workspace</span>
|
<Label>Billed Account for Workspace</Label>
|
||||||
{isWorkspaceLoading ? (
|
{isWorkspaceLoading ? (
|
||||||
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
||||||
) : workspaceAdmins.length === 0 ? (
|
) : workspaceAdmins.length === 0 ? (
|
||||||
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[var(--text-muted)] text-xs'>
|
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[12px] text-[var(--text-muted)]'>
|
||||||
No admin members available
|
No admin members available
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Popover>
|
||||||
value={billedAccountUserId ?? ''}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={async (value) => {
|
<button
|
||||||
if (value === billedAccountUserId) return
|
className='flex h-8 w-[200px] items-center justify-between gap-2 rounded-[6px] border border-[var(--border)] bg-transparent px-3 text-left text-[13px] transition-colors hover:bg-[var(--surface-3)] disabled:pointer-events-none disabled:opacity-50'
|
||||||
try {
|
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
>
|
||||||
} catch (error) {
|
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||||
// Error is already logged in updateWorkspaceSettings
|
{billedAccountUserId
|
||||||
}
|
? workspaceAdmins.find((admin: any) => admin.userId === billedAccountUserId)
|
||||||
}}
|
?.email || 'Select admin'
|
||||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
: 'Select admin'}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
<ChevronDown className='h-3 w-3 shrink-0 text-[var(--text-secondary)]' />
|
||||||
<SelectValue placeholder='Select admin' />
|
</button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent align='start' className='z-[10000050]'>
|
<PopoverContent align='end' minWidth={200} border>
|
||||||
<SelectGroup>
|
<PopoverSection>Workspace admins</PopoverSection>
|
||||||
<SelectLabel className='px-3 py-1 text-[11px] text-[var(--text-muted)] uppercase'>
|
{workspaceAdmins.map((admin: any) => (
|
||||||
Workspace admins
|
<PopoverItem
|
||||||
</SelectLabel>
|
key={admin.userId}
|
||||||
{workspaceAdmins.map((admin: any) => (
|
active={billedAccountUserId === admin.userId}
|
||||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
showCheck
|
||||||
{admin.email}
|
onClick={async () => {
|
||||||
</SelectItem>
|
if (admin.userId === billedAccountUserId) return
|
||||||
))}
|
try {
|
||||||
</SelectGroup>
|
await updateWorkspaceSettings({ billedAccountUserId: admin.userId })
|
||||||
</SelectContent>
|
} catch (error) {
|
||||||
</Select>
|
// Error is already logged in updateWorkspaceSettings
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className='flex-1 truncate'>{admin.email}</span>
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px]'>Usage notifications</span>
|
<Label htmlFor='usage-notifications'>Usage notifications</Label>
|
||||||
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
|
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Email me when I reach 80% usage
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
id='usage-notifications'
|
||||||
checked={!!enabled}
|
checked={!!enabled}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={(v: boolean) => {
|
onCheckedChange={(v: boolean) => {
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function TeamMembers({
|
|||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
{teamItems.map((item) => (
|
{teamItems.map((item) => (
|
||||||
<div key={item.id} className='flex items-center justify-between'>
|
<div key={item.id} className='flex items-center justify-between'>
|
||||||
{/* Member info */}
|
{/* Left section: Avatar + Name/Role + Action buttons */}
|
||||||
<div className='flex flex-1 items-center gap-3'>
|
<div className='flex flex-1 items-center gap-3'>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -165,7 +165,7 @@ export function TeamMembers({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name and email */}
|
{/* Name and email */}
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span className='truncate font-medium text-sm'>{item.name}</span>
|
<span className='truncate font-medium text-sm'>{item.name}</span>
|
||||||
{item.type === 'member' && (
|
{item.type === 'member' && (
|
||||||
@@ -188,51 +188,50 @@ export function TeamMembers({
|
|||||||
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage stats - matching subscription layout */}
|
{/* Action buttons */}
|
||||||
{isAdminOrOwner && (
|
{isAdminOrOwner && (
|
||||||
<div className='hidden items-center text-xs tabular-nums sm:flex'>
|
<>
|
||||||
<div className='text-center'>
|
{/* Admin/Owner can remove other members */}
|
||||||
<div className='text-[var(--text-muted)]'>Usage</div>
|
{item.type === 'member' &&
|
||||||
<div className='font-medium'>
|
item.role !== 'owner' &&
|
||||||
{isLoadingUsage && item.type === 'member' ? (
|
item.email !== currentUserEmail && (
|
||||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
<Button
|
||||||
) : (
|
variant='ghost'
|
||||||
item.usage
|
onClick={() => onRemoveMember(item.member)}
|
||||||
)}
|
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
</div>
|
>
|
||||||
</div>
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin can cancel invitations */}
|
||||||
|
{item.type === 'invitation' && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||||
|
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||||
|
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
|
>
|
||||||
|
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right section: Usage column (right-aligned) */}
|
||||||
|
{isAdminOrOwner && (
|
||||||
|
<div className='ml-4 flex flex-col items-end'>
|
||||||
|
<div className='text-[var(--text-muted)] text-xs'>Usage</div>
|
||||||
|
<div className='font-medium text-xs tabular-nums'>
|
||||||
|
{isLoadingUsage && item.type === 'member' ? (
|
||||||
|
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||||
|
) : (
|
||||||
|
item.usage
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className='ml-4 flex gap-1'>
|
|
||||||
{/* Admin/Owner can remove other members */}
|
|
||||||
{isAdminOrOwner &&
|
|
||||||
item.type === 'member' &&
|
|
||||||
item.role !== 'owner' &&
|
|
||||||
item.email !== currentUserEmail && (
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={() => onRemoveMember(item.member)}
|
|
||||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Admin can cancel invitations */}
|
|
||||||
{isAdminOrOwner && item.type === 'invitation' && (
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
|
||||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
|
||||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import {
|
|||||||
type ComboboxOption,
|
type ComboboxOption,
|
||||||
Label,
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalBody,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalDescription,
|
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalTitle,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||||
@@ -55,50 +54,53 @@ export function TeamSeats({
|
|||||||
const totalMonthlyCost = selectedSeats * costPerSeat
|
const totalMonthlyCost = selectedSeats * costPerSeat
|
||||||
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
await onConfirm(selectedSeats)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
||||||
value: num.toString(),
|
value: num.toString(),
|
||||||
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
|
label: `${num} ${num === 1 ? 'seat' : 'seats'}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange}>
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
<ModalContent>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>
|
<ModalHeader>{title}</ModalHeader>
|
||||||
<ModalTitle>{title}</ModalTitle>
|
<ModalBody>
|
||||||
<ModalDescription>{description}</ModalDescription>
|
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<div className='py-4'>
|
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||||
<Label htmlFor='seats'>Number of seats</Label>
|
<Label htmlFor='seats'>Number of seats</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={seatOptions}
|
options={seatOptions}
|
||||||
value={selectedSeats.toString()}
|
value={selectedSeats > 0 ? selectedSeats.toString() : ''}
|
||||||
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
onChange={(value) => {
|
||||||
placeholder='Select number of seats'
|
const num = Number.parseInt(value, 10)
|
||||||
/>
|
if (!Number.isNaN(num) && num > 0) {
|
||||||
|
setSelectedSeats(num)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder='Select or enter number of seats'
|
||||||
|
editable
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className='mt-2 text-[var(--text-muted)] text-sm'>
|
<p className='mt-3 text-[12px] text-[var(--text-muted)]'>
|
||||||
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
||||||
total of ${totalMonthlyCost} inference credits per month.
|
total of ${totalMonthlyCost} inference credits per month.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showCostBreakdown && currentSeats !== undefined && (
|
{showCostBreakdown && currentSeats !== undefined && (
|
||||||
<div className='mt-3 rounded-[8px] bg-[var(--surface-3)] p-3'>
|
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||||
<div className='flex justify-between text-sm'>
|
<div className='flex justify-between text-[12px]'>
|
||||||
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
||||||
<span>{currentSeats}</span>
|
<span className='text-[var(--text-primary)]'>{currentSeats}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between text-sm'>
|
<div className='mt-2 flex justify-between text-[12px]'>
|
||||||
<span className='text-[var(--text-muted)]'>New seats:</span>
|
<span className='text-[var(--text-muted)]'>New seats:</span>
|
||||||
<span>{selectedSeats}</span>
|
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'>
|
<div className='mt-3 flex justify-between border-[var(--border)] border-t pt-3 text-[12px]'>
|
||||||
<span className='text-[var(--text-muted)]'>Monthly cost change:</span>
|
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
|
||||||
<span>
|
<span className='font-medium text-[var(--text-primary)]'>
|
||||||
{costChange > 0 ? '+' : ''}${costChange}
|
{costChange > 0 ? '+' : ''}${costChange}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,19 +108,14 @@ export function TeamSeats({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className='mt-3 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
<p className='mt-3 text-[12px] text-[var(--text-error)]'>
|
||||||
{error instanceof Error && error.message ? error.message : String(error)}
|
{error instanceof Error && error.message ? error.message : String(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
variant='outline'
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className='h-[32px] px-[12px]'
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -127,22 +124,15 @@ export function TeamSeats({
|
|||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
onClick={handleConfirm}
|
onClick={() => onConfirm(selectedSeats)}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
|
selectedSeats < 1 ||
|
||||||
(showCostBreakdown && selectedSeats === currentSeats) ||
|
(showCostBreakdown && selectedSeats === currentSeats) ||
|
||||||
isCancelledAtPeriodEnd
|
isCancelledAtPeriodEnd
|
||||||
}
|
}
|
||||||
className='h-[32px] px-[12px]'
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? 'Updating...' : confirmButtonText}
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
|
|
||||||
<span>Loading...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span>{confirmButtonText}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|||||||
Reference in New Issue
Block a user