[PULSE-10] Billing usage in dashboard – frontend #9
@@ -1,3 +1,4 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
import OrganizationSettings from '@/components/settings/OrganizationSettings'
|
import OrganizationSettings from '@/components/settings/OrganizationSettings'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -9,7 +10,9 @@ export default function OrgSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
<div className="min-h-screen pt-24 pb-12 px-4 sm:px-6">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<OrganizationSettings />
|
<Suspense fallback={<div className="p-8 text-center text-neutral-500">Loading...</div>}>
|
||||||
|
<OrganizationSettings />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
41
app/page.tsx
41
app/page.tsx
@@ -285,12 +285,13 @@ export default function HomePage() {
|
|||||||
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
<div className="rounded-xl border border-neutral-200 bg-brand-orange/10 p-4 dark:border-neutral-800">
|
||||||
<p className="text-sm text-brand-orange">Plan Status</p>
|
<p className="text-sm text-brand-orange">Plan & usage</p>
|
||||||
<p className="text-lg font-bold text-brand-orange">
|
{subscriptionLoading ? (
|
||||||
{subscriptionLoading
|
<p className="text-lg font-bold text-brand-orange">...</p>
|
||||||
? '...'
|
) : subscription ? (
|
||||||
: (() => {
|
<>
|
||||||
if (!subscription) return 'Free Plan'
|
<p className="text-lg font-bold text-brand-orange">
|
||||||
|
{(() => {
|
||||||
const raw =
|
const raw =
|
||||||
subscription.plan_id?.startsWith('price_')
|
subscription.plan_id?.startsWith('price_')
|
||||||
? 'Pro'
|
? 'Pro'
|
||||||
@@ -300,7 +301,33 @@ export default function HomePage() {
|
|||||||
const label = raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1)
|
const label = raw === 'Free' || raw === 'Pro' ? raw : raw.charAt(0).toUpperCase() + raw.slice(1)
|
||||||
return `${label} Plan`
|
return `${label} Plan`
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
|
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number')) && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
|
{typeof subscription.sites_count === 'number' && (
|
||||||
|
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : subscription.sites_count}</span>
|
||||||
|
)}
|
||||||
|
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '}
|
||||||
|
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||||
|
<span>Pageviews: {subscription.pageview_usage.toLocaleString()}/{subscription.pageview_limit.toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
{subscription.has_payment_method ? (
|
||||||
|
<Link href="/org-settings?tab=billing" className="text-sm font-medium text-brand-orange hover:underline">
|
||||||
|
Manage billing
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/pricing" className="text-sm font-medium text-brand-orange hover:underline">
|
||||||
|
Upgrade
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-bold text-brand-orange">Free Plan</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import {
|
import {
|
||||||
deleteOrganization,
|
deleteOrganization,
|
||||||
@@ -38,7 +38,11 @@ import { Button, Input } from '@ciphera-net/ui'
|
|||||||
export default function OrganizationSettings() {
|
export default function OrganizationSettings() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing'>('general')
|
const searchParams = useSearchParams()
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing'>(() => {
|
||||||
|
const tab = searchParams.get('tab')
|
||||||
|
return tab === 'billing' || tab === 'members' ? tab : 'general'
|
||||||
|
})
|
||||||
|
|
||||||
const [showDeletePrompt, setShowDeletePrompt] = useState(false)
|
const [showDeletePrompt, setShowDeletePrompt] = useState(false)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||||
@@ -135,6 +139,13 @@ export default function OrganizationSettings() {
|
|||||||
}
|
}
|
||||||
|
|
|||||||
}, [currentOrgId, loadMembers])
|
}, [currentOrgId, loadMembers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = searchParams.get('tab')
|
||||||
|
if ((tab === 'billing' || tab === 'members') && tab !== activeTab) {
|
||||||
|
setActiveTab(tab)
|
||||||
|
}
|
||||||
|
}, [searchParams, activeTab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'billing' && currentOrgId) {
|
if (activeTab === 'billing' && currentOrgId) {
|
||||||
loadSubscription()
|
loadSubscription()
|
||||||
@@ -593,7 +604,25 @@ export default function OrganizationSettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 pt-6 border-t border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-neutral-500 mb-1">Sites</div>
|
||||||
|
<div className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{typeof subscription.sites_count === 'number'
|
||||||
|
? subscription.plan_id === 'solo'
|
||||||
|
? `${subscription.sites_count} / 1`
|
||||||
|
: `${subscription.sites_count}`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-neutral-500 mb-1">Pageviews this period</div>
|
||||||
|
<div className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number'
|
||||||
|
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-neutral-500 mb-1">Billing Interval</div>
|
<div className="text-sm text-neutral-500 mb-1">Billing Interval</div>
|
||||||
<div className="font-medium text-neutral-900 dark:text-white capitalize">
|
<div className="font-medium text-neutral-900 dark:text-white capitalize">
|
||||||
@@ -603,7 +632,7 @@ export default function OrganizationSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-neutral-500 mb-1">Pageview Limit</div>
|
<div className="text-sm text-neutral-500 mb-1">Pageview Limit</div>
|
||||||
<div className="font-medium text-neutral-900 dark:text-white">
|
<div className="font-medium text-neutral-900 dark:text-white">
|
||||||
{subscription.pageview_limit.toLocaleString()} / month
|
{subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / month` : 'Unlimited'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface SubscriptionDetails {
|
|||||||
billing_interval: string
|
billing_interval: string
|
||||||
pageview_limit: number
|
pageview_limit: number
|
||||||
has_payment_method: boolean
|
has_payment_method: boolean
|
||||||
|
/** Number of sites for the org (billing usage). Present when backend supports usage API. */
|
||||||
|
sites_count?: number
|
||||||
|
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
|
||||||
|
pageview_usage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function billingFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user
This
useEffectcan cause infinite re-renders if the URL updates programmatically. WhensetActiveTabis called, if other code updates the URL, it will trigger this effect again. Consider adding a guard or removing this effect since initial state is already set fromsearchParamsin theuseStateinitializer.Prompt To Fix With AI
Change: Only call setActiveTab(tab) when the tab from the URL is different from the current activeTab.
How: Added && tab !== activeTab so we don’t set state when the URL and state already match, and added activeTab to the effect dependency array so the guard is correct.
Result: The effect no longer triggers repeated updates when the URL is updated programmatically; it only syncs when the URL tab actually changes (e.g. client-side navigation to ?tab=billing).