620 lines
24 KiB
TypeScript
620 lines
24 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
ArrowLeft,
|
|
Building2,
|
|
CalendarClock,
|
|
Check,
|
|
Copy,
|
|
Crown,
|
|
Loader2,
|
|
Mail,
|
|
Pencil,
|
|
UserCheck,
|
|
UserPlus,
|
|
UserX,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Input } from '@/components/ui/Input'
|
|
import { Modal } from '@/components/common/Modal'
|
|
import { EmptyState, StatusBadge } from '@/components/admin'
|
|
import { adminApi } from '@/api/admin'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
import type { AdminAccountDetailResponse, AdminAccountMember } from '@/types/admin'
|
|
|
|
function formatDate(value: string | null) {
|
|
if (!value) return 'Never'
|
|
return new Date(value).toLocaleDateString()
|
|
}
|
|
|
|
export function AccountDetailPage() {
|
|
const { accountId } = useParams<{ accountId: string }>()
|
|
const navigate = useNavigate()
|
|
|
|
const [account, setAccount] = useState<AdminAccountDetailResponse | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [isEditingName, setIsEditingName] = useState(false)
|
|
const [editedName, setEditedName] = useState('')
|
|
const [savingName, setSavingName] = useState(false)
|
|
|
|
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
|
|
const [createForm, setCreateForm] = useState({
|
|
email: '',
|
|
name: '',
|
|
account_role: 'engineer' as 'engineer' | 'viewer',
|
|
send_email: true,
|
|
})
|
|
const [createLoading, setCreateLoading] = useState(false)
|
|
const [tempPassword, setTempPassword] = useState<string | null>(null)
|
|
const [copiedPassword, setCopiedPassword] = useState(false)
|
|
|
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
|
const [inviteForm, setInviteForm] = useState({
|
|
email: '',
|
|
role: 'engineer' as 'engineer' | 'viewer',
|
|
})
|
|
const [inviteLoading, setInviteLoading] = useState(false)
|
|
|
|
const [planModalOpen, setPlanModalOpen] = useState(false)
|
|
const [selectedPlan, setSelectedPlan] = useState('free')
|
|
const [planSaving, setPlanSaving] = useState(false)
|
|
|
|
const [trialModalOpen, setTrialModalOpen] = useState(false)
|
|
const [trialDays, setTrialDays] = useState('14')
|
|
const [trialSaving, setTrialSaving] = useState(false)
|
|
|
|
const loadAccount = useCallback(async () => {
|
|
if (!accountId) return
|
|
setLoading(true)
|
|
try {
|
|
const data = await adminApi.getAccountDetail(accountId)
|
|
setAccount(data)
|
|
setEditedName(data.name)
|
|
setSelectedPlan(data.subscription?.plan ?? 'free')
|
|
} catch {
|
|
toast.error('Failed to load account')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [accountId])
|
|
|
|
useEffect(() => {
|
|
loadAccount()
|
|
}, [loadAccount])
|
|
|
|
const handleSaveName = async () => {
|
|
if (!account || !editedName.trim() || editedName.trim() === account.name) {
|
|
setIsEditingName(false)
|
|
return
|
|
}
|
|
setSavingName(true)
|
|
try {
|
|
const updated = await adminApi.updateAccount(account.id, { name: editedName.trim() })
|
|
setAccount(updated)
|
|
setEditedName(updated.name)
|
|
setIsEditingName(false)
|
|
toast.success('Account updated')
|
|
} catch {
|
|
toast.error('Failed to update account')
|
|
} finally {
|
|
setSavingName(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateUser = async () => {
|
|
if (!account || !createForm.email || !createForm.name) return
|
|
setCreateLoading(true)
|
|
try {
|
|
const result = await adminApi.createUser({
|
|
email: createForm.email,
|
|
name: createForm.name,
|
|
account_mode: 'existing',
|
|
account_display_code: account.display_code,
|
|
account_role: createForm.account_role,
|
|
send_email: createForm.send_email,
|
|
})
|
|
setShowCreateUserModal(false)
|
|
setCreateForm({ email: '', name: '', account_role: 'engineer', send_email: true })
|
|
setTempPassword(result.temporary_password)
|
|
setCopiedPassword(false)
|
|
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
|
|
loadAccount()
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
|
|
} else {
|
|
toast.error('Failed to create user')
|
|
}
|
|
} finally {
|
|
setCreateLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleInviteUser = async () => {
|
|
if (!account || !inviteForm.email) return
|
|
setInviteLoading(true)
|
|
try {
|
|
await adminApi.createInvite({
|
|
email: inviteForm.email,
|
|
account_display_code: account.display_code,
|
|
role: inviteForm.role,
|
|
})
|
|
toast.success('Invite sent')
|
|
setInviteForm({ email: '', role: 'engineer' })
|
|
setShowInviteModal(false)
|
|
loadAccount()
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
|
|
} else {
|
|
toast.error('Failed to send invite')
|
|
}
|
|
} finally {
|
|
setInviteLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleUpdateMemberRole = async (member: AdminAccountMember, nextRole: string) => {
|
|
try {
|
|
await adminApi.updateAccountRole(member.id, nextRole)
|
|
toast.success(`Updated ${member.name}`)
|
|
loadAccount()
|
|
} catch {
|
|
toast.error('Failed to update account role')
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (member: AdminAccountMember) => {
|
|
try {
|
|
if (member.is_active) {
|
|
await adminApi.deactivateUser(member.id)
|
|
toast.success('User deactivated')
|
|
} else {
|
|
await adminApi.activateUser(member.id)
|
|
toast.success('User activated')
|
|
}
|
|
loadAccount()
|
|
} catch {
|
|
toast.error('Failed to update user status')
|
|
}
|
|
}
|
|
|
|
const handleUpdatePlan = async () => {
|
|
if (!account) return
|
|
setPlanSaving(true)
|
|
try {
|
|
await adminApi.updateAccountSubscriptionPlan(account.id, selectedPlan)
|
|
toast.success(`Plan updated to ${selectedPlan}`)
|
|
setPlanModalOpen(false)
|
|
loadAccount()
|
|
} catch {
|
|
toast.error('Failed to update plan')
|
|
} finally {
|
|
setPlanSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleExtendTrial = async () => {
|
|
if (!account || !trialDays) return
|
|
setTrialSaving(true)
|
|
try {
|
|
await adminApi.extendAccountTrial(account.id, parseInt(trialDays, 10))
|
|
toast.success(`Trial updated by ${trialDays} days`)
|
|
setTrialModalOpen(false)
|
|
loadAccount()
|
|
} catch {
|
|
toast.error('Failed to update trial')
|
|
} finally {
|
|
setTrialSaving(false)
|
|
}
|
|
}
|
|
|
|
const copyDisplayCode = async () => {
|
|
if (!account) return
|
|
await navigator.clipboard.writeText(account.display_code)
|
|
toast.success('Display code copied')
|
|
}
|
|
|
|
const copyTempPassword = async () => {
|
|
if (!tempPassword) return
|
|
await navigator.clipboard.writeText(tempPassword)
|
|
setCopiedPassword(true)
|
|
setTimeout(() => setCopiedPassword(false), 2000)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!account) {
|
|
return (
|
|
<EmptyState
|
|
title="Account not found"
|
|
description="This account may have been removed or is unavailable."
|
|
action={<Button variant="secondary" onClick={() => navigate('/admin/accounts')}>Back to Accounts</Button>}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate('/admin/accounts')}
|
|
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-3">
|
|
<Building2 className="h-6 w-6 text-muted-foreground" />
|
|
<h1 className="truncate text-2xl font-bold text-foreground">{account.name}</h1>
|
|
<StatusBadge variant="default">{account.display_code}</StatusBadge>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Manage account settings, subscription, invites, and users from one place.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="secondary" onClick={() => setShowInviteModal(true)}>
|
|
<Mail className="h-4 w-4" />
|
|
Invite User
|
|
</Button>
|
|
<Button onClick={() => setShowCreateUserModal(true)}>
|
|
<UserPlus className="h-4 w-4" />
|
|
Create User
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
|
<section className="space-y-6">
|
|
<div className="rounded-2xl border border-border bg-card p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-lg font-semibold text-foreground">Account Settings</h2>
|
|
<Button variant="secondary" size="sm" onClick={copyDisplayCode}>
|
|
<Copy className="h-4 w-4" />
|
|
Copy Code
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-5 space-y-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground">Account Name</label>
|
|
{isEditingName ? (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} />
|
|
<Button onClick={handleSaveName} loading={savingName} size="icon-sm">
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-sm"
|
|
onClick={() => {
|
|
setEditedName(account.name)
|
|
setIsEditingName(false)
|
|
}}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span className="text-sm text-foreground">{account.name}</span>
|
|
<button
|
|
onClick={() => setIsEditingName(true)}
|
|
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Owner</p>
|
|
<p className="mt-2 text-sm text-foreground">{account.owner?.name ?? 'Unassigned'}</p>
|
|
<p className="text-xs text-muted-foreground">{account.owner?.email ?? 'No owner user yet'}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Created</p>
|
|
<p className="mt-2 text-sm text-foreground">{formatDate(account.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-border bg-card p-5">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-lg font-semibold text-foreground">Users</h2>
|
|
<StatusBadge variant="default">{account.member_count} members</StatusBadge>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{account.members.length > 0 ? (
|
|
account.members.map((member) => (
|
|
<div key={member.id} className="rounded-xl border border-border bg-card/50 p-4">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<p className="font-medium text-foreground">{member.name}</p>
|
|
<p className="text-sm text-muted-foreground">{member.email}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<StatusBadge variant="default">{member.role}</StatusBadge>
|
|
{member.account_role && <StatusBadge variant="default">{member.account_role}</StatusBadge>}
|
|
<StatusBadge variant={member.is_active ? 'success' : 'destructive'}>
|
|
{member.is_active ? 'Active' : 'Inactive'}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<select
|
|
value={member.account_role ?? 'engineer'}
|
|
onChange={(e) => handleUpdateMemberRole(member, e.target.value)}
|
|
className={cn(
|
|
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
<Button variant="secondary" size="sm" onClick={() => handleToggleActive(member)}>
|
|
{member.is_active ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />}
|
|
{member.is_active ? 'Deactivate' : 'Activate'}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => navigate(`/admin/users/${member.id}`)}>
|
|
View User
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
|
No users yet. Create or invite someone into this account.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<aside className="space-y-6">
|
|
<div className="rounded-2xl border border-border bg-card p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-lg font-semibold text-foreground">Subscription</h2>
|
|
{account.subscription ? (
|
|
<div className="flex gap-2">
|
|
<StatusBadge variant="default">{account.subscription.plan}</StatusBadge>
|
|
<StatusBadge variant={account.subscription.status === 'active' ? 'success' : account.subscription.status === 'canceled' ? 'destructive' : 'warning'}>
|
|
{account.subscription.status}
|
|
</StatusBadge>
|
|
</div>
|
|
) : (
|
|
<StatusBadge variant="warning">No subscription</StatusBadge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Renewal</p>
|
|
<p className="mt-2 text-sm text-foreground">{formatDate(account.subscription?.current_period_end ?? null)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border bg-card/50 p-4">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Usage</p>
|
|
<p className="mt-2 text-sm text-foreground">{account.usage.tree_count} flows · {account.usage.session_count_this_month} sessions</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedPlan(account.subscription?.plan ?? 'free')
|
|
setPlanModalOpen(true)
|
|
}}
|
|
>
|
|
<Crown className="h-4 w-4" />
|
|
Change Plan
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => setTrialModalOpen(true)}>
|
|
<CalendarClock className="h-4 w-4" />
|
|
Start or Extend Trial
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-border bg-card p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-lg font-semibold text-foreground">Pending Invites</h2>
|
|
<StatusBadge variant="default">{account.pending_invite_count}</StatusBadge>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
{account.invites.length > 0 ? (
|
|
account.invites.map((invite) => (
|
|
<div key={invite.id} className="rounded-xl border border-border bg-card/50 p-4">
|
|
<p className="font-medium text-foreground">{invite.email}</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<StatusBadge variant="default">{invite.role}</StatusBadge>
|
|
<StatusBadge variant="default">Expires {formatDate(invite.expires_at)}</StatusBadge>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
|
No pending invites for this account.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<Modal
|
|
isOpen={showCreateUserModal}
|
|
onClose={() => setShowCreateUserModal(false)}
|
|
title="Create User in Account"
|
|
size="sm"
|
|
footer={(
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => setShowCreateUserModal(false)}>Cancel</Button>
|
|
<Button onClick={handleCreateUser} disabled={!createForm.email || !createForm.name} loading={createLoading}>
|
|
{createLoading ? 'Creating...' : 'Create User'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Name</label>
|
|
<Input value={createForm.name} onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
|
<Input type="email" value={createForm.email} onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Account Role</label>
|
|
<select
|
|
value={createForm.account_role}
|
|
onChange={(e) => setCreateForm((f) => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
|
|
className={cn(
|
|
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={createForm.send_email}
|
|
onChange={(e) => setCreateForm((f) => ({ ...f, send_email: e.target.checked }))}
|
|
className="rounded border-border bg-card"
|
|
/>
|
|
<label className="text-sm text-muted-foreground">Send welcome email with temporary password</label>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showInviteModal}
|
|
onClose={() => setShowInviteModal(false)}
|
|
title="Invite User to Account"
|
|
size="sm"
|
|
footer={(
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => setShowInviteModal(false)}>Cancel</Button>
|
|
<Button onClick={handleInviteUser} disabled={!inviteForm.email} loading={inviteLoading}>
|
|
{inviteLoading ? 'Sending...' : 'Send Invite'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Email</label>
|
|
<Input type="email" value={inviteForm.email} onChange={(e) => setInviteForm((f) => ({ ...f, email: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Role</label>
|
|
<select
|
|
value={inviteForm.role}
|
|
onChange={(e) => setInviteForm((f) => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
|
|
className={cn(
|
|
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="engineer">Engineer</option>
|
|
<option value="viewer">Viewer</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={!!tempPassword}
|
|
onClose={() => setTempPassword(null)}
|
|
title="User Created"
|
|
size="sm"
|
|
footer={<div className="flex justify-end"><Button onClick={() => setTempPassword(null)}>Done</Button></div>}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
|
|
This password will not be shown again. Copy it now.
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-foreground">
|
|
{tempPassword}
|
|
</code>
|
|
<button
|
|
onClick={copyTempPassword}
|
|
className="rounded-md border border-border p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
{copiedPassword ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={planModalOpen}
|
|
onClose={() => setPlanModalOpen(false)}
|
|
title="Change Account Plan"
|
|
size="sm"
|
|
footer={(
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
|
<Button onClick={handleUpdatePlan} loading={planSaving}>Save</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<select
|
|
value={selectedPlan}
|
|
onChange={(e) => setSelectedPlan(e.target.value)}
|
|
className={cn(
|
|
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
|
|
'focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20'
|
|
)}
|
|
>
|
|
<option value="free">Free</option>
|
|
<option value="pro">Pro</option>
|
|
<option value="team">Team</option>
|
|
</select>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={trialModalOpen}
|
|
onClose={() => setTrialModalOpen(false)}
|
|
title="Start or Extend Trial"
|
|
size="sm"
|
|
footer={(
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="secondary" onClick={() => setTrialModalOpen(false)}>Cancel</Button>
|
|
<Button onClick={handleExtendTrial} loading={trialSaving}>Save</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-foreground">Days</label>
|
|
<Input type="number" min={1} max={90} value={trialDays} onChange={(e) => setTrialDays(e.target.value)} />
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AccountDetailPage
|