fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen
- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler - Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto - Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example Made-with: Cursor
This commit is contained in:
@@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity'
|
||||
|
||||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
|
||||
|
||||
const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/
|
||||
|
||||
function validateLabelImport(
|
||||
imported: unknown,
|
||||
existing: CompanyLabel[]
|
||||
): { labels: CompanyLabel[]; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
if (!Array.isArray(imported)) {
|
||||
return { labels: [], errors: ['File must contain a JSON array'] }
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const labels: CompanyLabel[] = []
|
||||
imported.forEach((row, i) => {
|
||||
const rowNum = i + 1
|
||||
if (!row || typeof row !== 'object') {
|
||||
errors.push(`Row ${rowNum}: invalid object`)
|
||||
return
|
||||
}
|
||||
const r = row as Record<string, unknown>
|
||||
const name = typeof r.name === 'string' ? r.name.trim() : ''
|
||||
if (!name) {
|
||||
errors.push(`Row ${rowNum}: name is required`)
|
||||
return
|
||||
}
|
||||
if (name.length > 50) {
|
||||
errors.push(`Row ${rowNum}: name must be at most 50 characters`)
|
||||
return
|
||||
}
|
||||
if (r.color != null && r.color !== '') {
|
||||
if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) {
|
||||
errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
errors.push(`Row ${rowNum}: duplicate name "${name}" in import`)
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
if (existing.some((e) => e.name.trim().toLowerCase() === key)) {
|
||||
errors.push(`Row ${rowNum}: name "${name}" already exists`)
|
||||
return
|
||||
}
|
||||
labels.push({
|
||||
id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`,
|
||||
name,
|
||||
condition: typeof r.condition === 'string' ? r.condition : '',
|
||||
enabled: r.enabled !== false,
|
||||
category: typeof r.category === 'string' ? r.category : 'promotions',
|
||||
})
|
||||
})
|
||||
if (existing.length + labels.length > 100) {
|
||||
return {
|
||||
labels: [],
|
||||
errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`],
|
||||
}
|
||||
}
|
||||
return { labels, errors }
|
||||
}
|
||||
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
provider: 'gmail' | 'outlook' | 'imap'
|
||||
provider: 'gmail' | 'outlook' | 'imap' | 'demo'
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
interface VIPSender {
|
||||
@@ -76,10 +138,35 @@ interface VIPSender {
|
||||
interface Subscription {
|
||||
status: string
|
||||
plan: string
|
||||
planDisplayName?: string
|
||||
isFreeTier?: boolean
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
}
|
||||
|
||||
function subscriptionTitle(sub: Subscription | null): string {
|
||||
if (!sub) return ''
|
||||
if (sub.planDisplayName) return sub.planDisplayName
|
||||
if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan'
|
||||
if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1)
|
||||
return 'Subscription'
|
||||
}
|
||||
|
||||
function subscriptionBadge(sub: Subscription | null): {
|
||||
label: string
|
||||
variant: 'success' | 'warning' | 'secondary'
|
||||
} {
|
||||
if (!sub) return { label: '', variant: 'secondary' }
|
||||
if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' }
|
||||
if (sub.status === 'active') return { label: 'Active', variant: 'success' }
|
||||
const s = (sub.status || '').toLowerCase()
|
||||
if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' }
|
||||
return {
|
||||
label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive',
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
@@ -130,6 +217,7 @@ export function Settings() {
|
||||
})
|
||||
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||||
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||||
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
||||
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
||||
@@ -174,11 +262,24 @@ export function Settings() {
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Refetch subscription when opening this tab (fixes JWT timing vs initial loadData)
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'subscription' || !user?.$id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const res = await api.getSubscriptionStatus()
|
||||
if (!cancelled && res.data) setSubscription(res.data)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeTab, user?.$id])
|
||||
|
||||
const loadReferralData = async () => {
|
||||
if (!user?.$id) return
|
||||
setLoadingReferral(true)
|
||||
try {
|
||||
const res = await api.getReferralCode(user.$id)
|
||||
const res = await api.getReferralCode()
|
||||
if (res.data) setReferralData(res.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load referral data:', err)
|
||||
@@ -194,24 +295,33 @@ export function Settings() {
|
||||
|
||||
try {
|
||||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getUserPreferences(user.$id),
|
||||
api.getAIControlSettings(user.$id),
|
||||
api.getCompanyLabels(user.$id),
|
||||
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
|
||||
api.getEmailAccounts(),
|
||||
api.getSubscriptionStatus(),
|
||||
api.getUserPreferences(),
|
||||
api.getAIControlSettings(),
|
||||
api.getCompanyLabels(),
|
||||
user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
|
||||
])
|
||||
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
if (subsRes.data) setSubscription(subsRes.data)
|
||||
if (meRes.data?.isAdmin) {
|
||||
setIsAdmin(true)
|
||||
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
|
||||
const nameLabelsRes = await api.getNameLabels()
|
||||
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
||||
const pdata = prefsRes.data as {
|
||||
profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } }
|
||||
} | undefined
|
||||
if (pdata?.profile) {
|
||||
const prof = pdata.profile
|
||||
if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName)
|
||||
if (prof.timezone) setTimezone(prof.timezone)
|
||||
if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language))
|
||||
}
|
||||
if (aiControlRes.data) {
|
||||
// Merge cleanup defaults if not present
|
||||
const raw = aiControlRes.data
|
||||
@@ -297,7 +407,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.saveAIControlSettings(user.$id, {
|
||||
await api.saveAIControlSettings({
|
||||
enabledCategories: aiControlSettings.enabledCategories,
|
||||
categoryActions: aiControlSettings.categoryActions,
|
||||
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||||
@@ -326,24 +436,25 @@ export function Settings() {
|
||||
|
||||
// Load cleanup status
|
||||
const loadCleanupStatus = async () => {
|
||||
if (!user?.$id) return
|
||||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||||
if (!aid) return
|
||||
try {
|
||||
const res = await api.getCleanupStatus(user.$id)
|
||||
const res = await api.getCleanupStatus(aid)
|
||||
if (res.data) setCleanupStatus(res.data)
|
||||
} catch {
|
||||
// Silently fail if endpoint doesn't exist yet
|
||||
console.debug('Cleanup status endpoint not available')
|
||||
}
|
||||
}
|
||||
|
||||
// Load cleanup preview
|
||||
const loadCleanupPreview = async () => {
|
||||
if (!user?.$id || !aiControlSettings.cleanup?.enabled) return
|
||||
if (!aiControlSettings.cleanup?.enabled) return
|
||||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||||
if (!aid) return
|
||||
try {
|
||||
const res = await api.getCleanupPreview(user.$id)
|
||||
if (res.data?.preview) setCleanupPreview(res.data.preview)
|
||||
const res = await api.getCleanupPreview(aid)
|
||||
if (res.data?.messages) setCleanupPreview(res.data.messages)
|
||||
} catch {
|
||||
// Silently fail if endpoint doesn't exist yet
|
||||
console.debug('Cleanup preview endpoint not available')
|
||||
}
|
||||
}
|
||||
@@ -356,14 +467,14 @@ export function Settings() {
|
||||
loadCleanupPreview()
|
||||
}
|
||||
}
|
||||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun])
|
||||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
|
||||
|
||||
// Run cleanup now
|
||||
const handleRunCleanup = async () => {
|
||||
if (!user?.$id) return
|
||||
setRunningCleanup(true)
|
||||
try {
|
||||
const res = await api.runCleanup(user.$id)
|
||||
const res = await api.runCleanup()
|
||||
if (res.data) {
|
||||
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
|
||||
await loadCleanupStatus()
|
||||
@@ -432,8 +543,15 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
// TODO: Save profile data to backend
|
||||
// await api.updateUserProfile(user.$id, { name, language, timezone })
|
||||
const res = await api.updateProfile({
|
||||
displayName: name,
|
||||
timezone,
|
||||
notificationPrefs: { language },
|
||||
})
|
||||
if (res.error) {
|
||||
showMessage('error', res.error.message || 'Failed to save profile')
|
||||
return
|
||||
}
|
||||
savedProfileRef.current = { name, language, timezone }
|
||||
setHasProfileChanges(false)
|
||||
showMessage('success', 'Profile saved successfully!')
|
||||
@@ -472,7 +590,7 @@ export function Settings() {
|
||||
setConnectingProvider(provider)
|
||||
|
||||
try {
|
||||
const res = await api.getOAuthUrl(provider, user.$id)
|
||||
const res = await api.getOAuthUrl(provider)
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -486,7 +604,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
await api.disconnectEmailAccount(accountId, user.$id)
|
||||
await api.disconnectEmailAccount(accountId)
|
||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||
showMessage('success', 'Account disconnected')
|
||||
} catch {
|
||||
@@ -498,7 +616,7 @@ export function Settings() {
|
||||
e.preventDefault()
|
||||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||||
setImapConnecting(true)
|
||||
const res = await api.connectImapAccount(user.$id, {
|
||||
const res = await api.connectImapAccount({
|
||||
email: imapForm.email.trim(),
|
||||
password: imapForm.password,
|
||||
imapHost: imapForm.imapHost || undefined,
|
||||
@@ -511,7 +629,7 @@ export function Settings() {
|
||||
setImapConnecting(false)
|
||||
return
|
||||
}
|
||||
const list = await api.getEmailAccounts(user.$id)
|
||||
const list = await api.getEmailAccounts()
|
||||
setAccounts(list.data ?? [])
|
||||
setShowImapForm(false)
|
||||
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||
@@ -541,7 +659,7 @@ export function Settings() {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await api.saveUserPreferences(user.$id, { vipSenders })
|
||||
await api.saveUserPreferences({ vipSenders })
|
||||
showMessage('success', 'VIP list saved!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to save')
|
||||
@@ -554,7 +672,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
const res = await api.createPortalSession(user.$id)
|
||||
const res = await api.createPortalSession()
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -567,7 +685,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email)
|
||||
const res = await api.createSubscriptionCheckout(plan, user.email)
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -1721,6 +1839,7 @@ export function Settings() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLabelImportErrors([])
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'application/json'
|
||||
@@ -1730,12 +1849,18 @@ export function Settings() {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const imported = JSON.parse(text)
|
||||
if (Array.isArray(imported)) {
|
||||
// TODO: Validate and import labels
|
||||
showMessage('success', `Imported ${imported.length} labels`)
|
||||
const { labels, errors } = validateLabelImport(imported, companyLabels)
|
||||
if (errors.length > 0) {
|
||||
setLabelImportErrors(errors)
|
||||
showMessage('error', 'Fix import errors before saving')
|
||||
return
|
||||
}
|
||||
setLabelImportErrors([])
|
||||
setCompanyLabels([...companyLabels, ...labels])
|
||||
showMessage('success', `Imported ${labels.length} labels`)
|
||||
} catch {
|
||||
showMessage('error', 'Invalid JSON file')
|
||||
setLabelImportErrors([])
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@@ -1755,6 +1880,16 @@ export function Settings() {
|
||||
Add Label
|
||||
</Button>
|
||||
</div>
|
||||
{labelImportErrors.length > 0 && (
|
||||
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
|
||||
<p className="font-medium mb-1">Import issues</p>
|
||||
<ul className="list-disc pl-5 space-y-0.5">
|
||||
{labelImportErrors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Detection Toggle */}
|
||||
@@ -1853,7 +1988,7 @@ export function Settings() {
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
try {
|
||||
await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled })
|
||||
await api.saveCompanyLabel({ ...label, enabled: !label.enabled })
|
||||
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} catch {
|
||||
@@ -1874,7 +2009,7 @@ export function Settings() {
|
||||
if (!user?.$id || !label.id) return
|
||||
if (!confirm('Are you sure you want to delete this label?')) return
|
||||
try {
|
||||
await api.deleteCompanyLabel(user.$id, label.id)
|
||||
await api.deleteCompanyLabel(label.id)
|
||||
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
||||
showMessage('success', 'Label deleted!')
|
||||
} catch {
|
||||
@@ -2163,7 +2298,7 @@ export function Settings() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await api.saveCompanyLabel(user.$id, editingLabel)
|
||||
const saved = await api.saveCompanyLabel(editingLabel)
|
||||
if (saved.data) {
|
||||
if (editingLabel.id) {
|
||||
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
|
||||
@@ -2235,7 +2370,7 @@ export function Settings() {
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
try {
|
||||
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled })
|
||||
await api.saveNameLabel({ ...label, enabled: !label.enabled })
|
||||
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} catch {
|
||||
@@ -2256,7 +2391,7 @@ export function Settings() {
|
||||
if (!user?.$id || !label.id) return
|
||||
if (!confirm('Delete this name label?')) return
|
||||
try {
|
||||
await api.deleteNameLabel(user.$id, user.email, label.id)
|
||||
await api.deleteNameLabel(label.id)
|
||||
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
||||
showMessage('success', 'Label deleted!')
|
||||
} catch {
|
||||
@@ -2383,7 +2518,7 @@ export function Settings() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
|
||||
const saved = await api.saveNameLabel(editingNameLabel)
|
||||
if (saved.data) {
|
||||
if (editingNameLabel.id) {
|
||||
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
||||
@@ -2466,7 +2601,7 @@ export function Settings() {
|
||||
onDisconnect={async (accountId) => {
|
||||
if (!user?.$id) return
|
||||
try {
|
||||
const result = await api.disconnectEmailAccount(accountId, user.$id)
|
||||
const result = await api.disconnectEmailAccount(accountId)
|
||||
if (result.data) {
|
||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||
showMessage('success', 'Account disconnected')
|
||||
@@ -2479,7 +2614,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
|
||||
try {
|
||||
const result = await api.deleteAccount(user.$id)
|
||||
const result = await api.deleteAccount()
|
||||
if (result.data) {
|
||||
showMessage('success', 'Account deleted. Redirecting...')
|
||||
setTimeout(() => {
|
||||
@@ -2503,30 +2638,63 @@ export function Settings() {
|
||||
<CardDescription>Manage your MailFlow subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center">
|
||||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{subscription?.plan || 'Trial'}</h3>
|
||||
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
|
||||
{subscription?.status === 'active' ? 'Active' : 'Trial'}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 py-6">
|
||||
Loading subscription…
|
||||
</p>
|
||||
) : !subscription ? (
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Subscription status could not be loaded. Make sure you are signed in and the API is running.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const r = await api.getSubscriptionStatus()
|
||||
if (r.data) {
|
||||
setSubscription(r.data)
|
||||
showMessage('success', 'Subscription loaded')
|
||||
} else {
|
||||
showMessage('error', r.error?.message || 'Failed to load subscription')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleManageSubscription}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
|
||||
{subscriptionTitle(subscription)}
|
||||
</h3>
|
||||
{(() => {
|
||||
const b = subscriptionBadge(subscription)
|
||||
return b.label ? (
|
||||
<Badge variant={b.variant}>{b.label}</Badge>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{subscription.currentPeriodEnd && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Next billing:{' '}
|
||||
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="shrink-0" onClick={handleManageSubscription}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2556,8 +2724,8 @@ export function Settings() {
|
||||
<span className="text-slate-500 dark:text-slate-400">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{plan.features.map((feature, fi) => (
|
||||
<li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
{feature}
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user