feat: implement site limits based on subscription plans across dashboard and new site creation; enhance UI feedback for plan limits
This commit is contained in:
16
app/page.tsx
16
app/page.tsx
@@ -13,6 +13,7 @@ import { Button } from '@ciphera-net/ui'
|
|||||||
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
|
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||||
|
|
||||||
function DashboardPreview() {
|
function DashboardPreview() {
|
||||||
return (
|
return (
|
||||||
@@ -337,10 +338,13 @@ export default function HomePage() {
|
|||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Your Sites</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Manage your analytics sites and view insights.</p>
|
||||||
</div>
|
</div>
|
||||||
{subscription?.plan_id === 'solo' && sites.length >= 1 ? (
|
{(() => {
|
||||||
|
const siteLimit = getSitesLimitForPlan(subscription?.plan_id)
|
||||||
|
const atLimit = siteLimit != null && sites.length >= siteLimit
|
||||||
|
return atLimit ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
<span className="text-sm font-medium text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
Limit reached (1/1)
|
Limit reached ({sites.length}/{siteLimit})
|
||||||
</span>
|
</span>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
<Button variant="primary" className="text-sm">
|
<Button variant="primary" className="text-sm">
|
||||||
@@ -348,7 +352,8 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null
|
||||||
|
})() ?? (
|
||||||
<Link href="/sites/new">
|
<Link href="/sites/new">
|
||||||
<Button variant="primary" className="text-sm">
|
<Button variant="primary" className="text-sm">
|
||||||
Add New Site
|
Add New Site
|
||||||
@@ -388,7 +393,10 @@ export default function HomePage() {
|
|||||||
{(typeof subscription.sites_count === 'number' || (subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number')) && (
|
{(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">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{typeof subscription.sites_count === 'number' && (
|
{typeof subscription.sites_count === 'number' && (
|
||||||
<span>Sites: {subscription.plan_id === 'solo' && subscription.sites_count > 0 ? `${subscription.sites_count}/1` : subscription.sites_count}</span>
|
<span>Sites: {(() => {
|
||||||
|
const limit = getSitesLimitForPlan(subscription.plan_id)
|
||||||
|
return limit != null && typeof subscription.sites_count === 'number' ? `${subscription.sites_count}/${limit}` : subscription.sites_count
|
||||||
|
})()}</span>
|
||||||
)}
|
)}
|
||||||
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '}
|
{typeof subscription.sites_count === 'number' && subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ' · '}
|
||||||
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
|
import { createSite, listSites, getSite, type Site } from '@/lib/api/sites'
|
||||||
import { getSubscription } from '@/lib/api/billing'
|
import { getSubscription } from '@/lib/api/billing'
|
||||||
|
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||||
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
|
import { trackSiteCreatedFromDashboard, trackSiteCreatedScriptCopied } from '@/lib/welcomeAnalytics'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
@@ -57,9 +58,10 @@ export default function NewSitePage() {
|
|||||||
getSubscription()
|
getSubscription()
|
||||||
])
|
])
|
||||||
|
|
||||||
if (subscription?.plan_id === 'solo' && sites.length >= 1) {
|
const siteLimit = subscription?.plan_id ? getSitesLimitForPlan(subscription.plan_id) : null
|
||||||
|
if (siteLimit != null && sites.length >= siteLimit) {
|
||||||
setAtLimit(true)
|
setAtLimit(true)
|
||||||
toast.error('Solo plan limit reached (1 site). Please upgrade to add more sites.')
|
toast.error(`${subscription.plan_id} plan limit reached (${siteLimit} site${siteLimit === 1 ? '' : 's'}). Please upgrade to add more sites.`)
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Organization
|
Organization
|
||||||
} from '@/lib/api/organization'
|
} from '@/lib/api/organization'
|
||||||
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing'
|
||||||
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex } from '@/lib/plans'
|
import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans'
|
||||||
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
@@ -863,9 +863,10 @@ export default function OrganizationSettings() {
|
|||||||
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
|
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
|
||||||
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||||
{typeof subscription.sites_count === 'number'
|
{typeof subscription.sites_count === 'number'
|
||||||
? subscription.plan_id === 'solo'
|
? (() => {
|
||||||
? `${subscription.sites_count} / 1`
|
const limit = getSitesLimitForPlan(subscription.plan_id)
|
||||||
: `${subscription.sites_count}`
|
return limit != null ? `${subscription.sites_count} / ${limit}` : `${subscription.sites_count}`
|
||||||
|
})()
|
||||||
: '—'}
|
: '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
lib/plans.ts
15
lib/plans.ts
@@ -1,9 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Shared plan and traffic tier definitions for pricing and billing (Change plan).
|
* Shared plan and traffic tier definitions for pricing and billing (Change plan).
|
||||||
* Backend supports plan_id "solo" and limit 10k–10M; month/year interval.
|
* Backend supports plan_id solo, team, business and limit 10k–10M; month/year interval.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PLAN_ID_SOLO = 'solo'
|
export const PLAN_ID_SOLO = 'solo'
|
||||||
|
export const PLAN_ID_TEAM = 'team'
|
||||||
|
export const PLAN_ID_BUSINESS = 'business'
|
||||||
|
|
||||||
|
/** Sites limit per plan. Returns null for free (no limit enforced in UI). */
|
||||||
|
export function getSitesLimitForPlan(planId: string | null | undefined): number | null {
|
||||||
|
if (!planId || planId === 'free') return null
|
||||||
|
switch (planId) {
|
||||||
|
case 'solo': return 1
|
||||||
|
case 'team': return 5
|
||||||
|
case 'business': return 10
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Traffic tiers available for Solo plan (pageview limits). */
|
/** Traffic tiers available for Solo plan (pageview limits). */
|
||||||
export const TRAFFIC_TIERS = [
|
export const TRAFFIC_TIERS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user