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