[PULSE-35] Billing tab: cancel and change-plan UI and copy #19

Merged
uz1mani merged 8 commits from staging into main 2026-02-09 14:16:21 +00:00
3 changed files with 382 additions and 108 deletions

View File

@@ -16,7 +16,8 @@ import {
OrganizationInvitation, OrganizationInvitation,
Organization Organization
} from '@/lib/api/organization' } from '@/lib/api/organization'
import { getSubscription, createPortalSession, getInvoices, 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 { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
import { toast } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@/lib/utils/authErrors'
@@ -68,6 +69,12 @@ export default function OrganizationSettings() {
const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null) const [subscription, setSubscription] = useState<SubscriptionDetails | null>(null)
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false) const [isLoadingSubscription, setIsLoadingSubscription] = useState(false)
const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false)
const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null)
const [showCancelPrompt, setShowCancelPrompt] = useState(false)
const [showChangePlanModal, setShowChangePlanModal] = useState(false)
const [changePlanTierIndex, setChangePlanTierIndex] = useState(2)
const [changePlanYearly, setChangePlanYearly] = useState(false)
const [isChangingPlan, setIsChangingPlan] = useState(false)
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false)
@@ -123,7 +130,11 @@ export default function OrganizationSettings() {
const currentOrgId = getOrgIdFromToken() const currentOrgId = getOrgIdFromToken()
const loadMembers = useCallback(async () => { const loadMembers = useCallback(async () => {
if (!currentOrgId) return if (!currentOrgId) {
setIsLoadingMembers(false)
return
}
setIsLoadingMembers(true)
try { try {
const [membersData, invitesData, orgData] = await Promise.all([ const [membersData, invitesData, orgData] = await Promise.all([
getOrganizationMembers(currentOrgId), getOrganizationMembers(currentOrgId),
@@ -257,6 +268,54 @@ export default function OrganizationSettings() {
} }
greptile-apps[bot] commented 2026-02-09 14:00:49 +00:00 (Migrated from github.com)
Review

Opening modal with stale tier selection if pageview_limit is 0. When subscription?.pageview_limit is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: subscription?.pageview_limit > 0.

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 281:288

Comment:
Opening modal with stale tier selection if `pageview_limit` is 0. When `subscription?.pageview_limit` is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: `subscription?.pageview_limit > 0`.

How can I resolve this? If you propose a fix, please make it concise.
Opening modal with stale tier selection if `pageview_limit` is 0. When `subscription?.pageview_limit` is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: `subscription?.pageview_limit > 0`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 281:288 Comment: Opening modal with stale tier selection if `pageview_limit` is 0. When `subscription?.pageview_limit` is 0 or falsy (not just undefined), falls back to tier index 2 (100k). Consider checking for non-zero explicitly: `subscription?.pageview_limit > 0`. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-09 14:06:33 +00:00 (Migrated from github.com)
Review

Issue: The condition subscription?.pageview_limit is falsy for 0, so when the limit was 0 we fell back to tier index 2 (100k) and opened the modal with the wrong tier.
Fix: Use an explicit check: subscription?.pageview_limit != null && subscription.pageview_limit > 0 before calling getTierIndexForLimit(subscription.pageview_limit); otherwise keep the default tier index 2.
Why: A real limit of 0 should not be treated as “unknown”; only when there’s no valid positive limit should we use the default.

Issue: The condition subscription?.pageview_limit is falsy for 0, so when the limit was 0 we fell back to tier index 2 (100k) and opened the modal with the wrong tier. Fix: Use an explicit check: subscription?.pageview_limit != null && subscription.pageview_limit > 0 before calling getTierIndexForLimit(subscription.pageview_limit); otherwise keep the default tier index 2. Why: A real limit of 0 should not be treated as “unknown”; only when there’s no valid positive limit should we use the default.
} }
const handleCancelSubscription = async (atPeriodEnd: boolean) => {
setCancelLoadingAction(atPeriodEnd ? 'period_end' : 'immediate')
try {
await cancelSubscription({ at_period_end: atPeriodEnd })
toast.success(atPeriodEnd ? 'Subscription will cancel at the end of the billing period.' : 'Subscription canceled.')
setShowCancelPrompt(false)
loadSubscription()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Failed to cancel subscription')
} finally {
setCancelLoadingAction(null)
}
}
const openChangePlanModal = () => {
if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) {
setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit))
} else {
setChangePlanTierIndex(2)
}
setChangePlanYearly(subscription?.billing_interval === 'year')
setShowChangePlanModal(true)
}
const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing'
const handleChangePlanSubmit = async () => {
const interval = changePlanYearly ? 'year' : 'month'
const limit = getLimitForTierIndex(changePlanTierIndex)
setIsChangingPlan(true)
try {
if (hasActiveSubscription) {
await changePlan({ plan_id: PLAN_ID_SOLO, interval, limit })
toast.success('Plan updated. Changes may take a moment to reflect.')
setShowChangePlanModal(false)
loadSubscription()
} else {
const { url } = await createCheckoutSession({ plan_id: PLAN_ID_SOLO, interval, limit })
if (url) window.location.href = url
else throw new Error('No checkout URL')
}
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.')
} finally {
setIsChangingPlan(false)
}
}
const handleDelete = async () => { const handleDelete = async () => {
if (deleteConfirm !== 'DELETE') return if (deleteConfirm !== 'DELETE') return
@@ -653,10 +712,10 @@ export default function OrganizationSettings() {
)} )}
{activeTab === 'billing' && ( {activeTab === 'billing' && (
<div className="space-y-12"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Billing & Subscription</h2> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white mb-1">Billing & Subscription</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage your subscription plan and payment methods.</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">Manage your plan, usage, and payment details.</p>
</div> </div>
{isLoadingSubscription ? ( {isLoadingSubscription ? (
@@ -666,51 +725,82 @@ export default function OrganizationSettings() {
) : !subscription ? ( ) : !subscription ? (
<div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800"> <div className="p-8 text-center bg-neutral-50 dark:bg-neutral-900/50 rounded-2xl border border-neutral-200 dark:border-neutral-800">
<p className="text-neutral-500">Could not load subscription details.</p> <p className="text-neutral-500">Could not load subscription details.</p>
<Button <Button variant="ghost" onClick={loadSubscription} className="mt-4">Retry</Button>
variant="ghost"
onClick={loadSubscription}
className="mt-4"
>
Retry
</Button>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-6">
{/* Current Plan */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-6"> {/* Trial notice */}
<div className="flex items-start justify-between mb-6"> {subscription.subscription_status === 'trialing' && (
<div> <div className="p-4 bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-800 rounded-2xl flex flex-col sm:flex-row sm:items-center gap-3">
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-1">Current Plan</h3> <div className="flex-1">
<div className="flex items-center gap-3"> <p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
<span className="text-2xl font-bold text-neutral-900 dark:text-white capitalize"> Your free trial ends on{' '}
{subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan <span className="font-semibold">
{(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : ''
})()}
</span> </span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${ </p>
subscription.subscription_status === 'active' <p className="text-xs text-yellow-700 dark:text-yellow-300 mt-0.5">
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' After the trial you'll be charged automatically unless you cancel before then.
: subscription.subscription_status === 'trialing' </p>
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{subscription.subscription_status === 'trialing' ? 'Trial Active' : (subscription.subscription_status || 'Free')}
</span>
</div>
</div> </div>
{subscription.has_payment_method && ( </div>
<Button )}
onClick={handleManageSubscription}
isLoading={isRedirectingToPortal} {/* Cancel-at-period-end notice */}
disabled={isRedirectingToPortal} {subscription.cancel_at_period_end && (
> <div className="p-4 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-2xl">
Manage Subscription <p className="text-sm font-medium text-amber-800 dark:text-amber-200">
</Button> Your subscription will end on{' '}
)} <span className="font-semibold">
{(() => {
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : '—'
})()}
</span>
</p>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe.
</p>
</div>
)}
{/* Plan & Usage card */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{/* Plan header */}
<div className="p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-xl font-bold text-neutral-900 dark:text-white capitalize">
{subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan
</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${
subscription.subscription_status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: subscription.subscription_status === 'trialing'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')}
</span>
{subscription.billing_interval && (
<span className="text-xs text-neutral-500 capitalize">
Billed {subscription.billing_interval}ly
</span>
)}
</div>
<Button onClick={openChangePlanModal} disabled={subscription.cancel_at_period_end}>
Change plan
</Button>
</div> </div>
<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"> {/* Usage stats */}
<div className="border-t border-neutral-200 dark:border-neutral-800 px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-6">
<div> <div>
<div className="text-sm text-neutral-500 mb-1">Sites</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Sites</div>
<div className="font-medium 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.plan_id === 'solo'
greptile-apps[bot] commented 2026-02-09 13:54:45 +00:00 (Migrated from github.com)
Review

Invalid JSX mapping

The invoices branch renders invoices.map(...) as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render.

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 867:870

Comment:
**Invalid JSX mapping**

The invoices branch renders `invoices.map(...)` as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render.


How can I resolve this? If you propose a fix, please make it concise.
**Invalid JSX mapping** The invoices branch renders `invoices.map(...)` as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 867:870 Comment: **Invalid JSX mapping** The invoices branch renders `invoices.map(...)` as a bare token inside JSX. This needs to be wrapped in curly braces (or wrapped in a fragment) to compile/render. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-09 13:57:41 +00:00 (Migrated from github.com)
Review

Issue: The third branch of the ternary was invoices.map(...) without being wrapped in a JS expression. In JSX, that can be treated as a bare token instead of an expression, so the map result may not render correctly.
Fix: Wrapped the map in curly braces so the third branch is { invoices.map((invoice) => ( ... )) }, and adjusted the closing to ))} so the block is valid JSX.
Why: In JSX, only expressions inside { } are evaluated; wrapping the map in braces makes it a proper expression and keeps behavior consistent across tools and React versions.

Issue: The third branch of the ternary was invoices.map(...) without being wrapped in a JS expression. In JSX, that can be treated as a bare token instead of an expression, so the map result may not render correctly. Fix: Wrapped the map in curly braces so the third branch is { invoices.map((invoice) => ( ... )) }, and adjusted the closing to ))} so the block is valid JSX. Why: In JSX, only expressions inside { } are evaluated; wrapping the map in braces makes it a proper expression and keeps behavior consistent across tools and React versions.
? `${subscription.sites_count} / 1` ? `${subscription.sites_count} / 1`
@@ -719,57 +809,61 @@ export default function OrganizationSettings() {
</div> </div>
greptile-apps[bot] commented 2026-02-09 14:00:51 +00:00 (Migrated from github.com)
Review

Hardcoded 'en-US' locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass undefined or the user's locale to respect their regional formatting preferences.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 875:875

Comment:
Hardcoded `'en-US'` locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass `undefined` or the user's locale to respect their regional formatting preferences.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.
Hardcoded `'en-US'` locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass `undefined` or the user's locale to respect their regional formatting preferences. <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 875:875 Comment: Hardcoded `'en-US'` locale for currency formatting. This forces US-style formatting (e.g., $1,234.56) regardless of user's locale. Pass `undefined` or the user's locale to respect their regional formatting preferences. <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-09 14:06:24 +00:00 (Migrated from github.com)
Review

No change: keeping the hardcoded 'en-US' locale for currency formatting.

No change: keeping the hardcoded 'en-US' locale for currency formatting.
</div> </div>
<div> <div>
<div className="text-sm text-neutral-500 mb-1">Pageviews this period</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Pageviews</div>
<div className="font-medium text-neutral-900 dark:text-white"> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' {subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number'
? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}`
: '—'} : '—'}
</div> </div>
</div> </div>
<div> <div>
<div className="text-sm text-neutral-500 mb-1">Billing Interval</div> <div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">
<div className="font-medium text-neutral-900 dark:text-white capitalize"> {subscription.subscription_status === 'trialing' ? 'Trial ends' : (subscription.cancel_at_period_end ? 'Access until' : 'Renews')}
{subscription.billing_interval ? `${subscription.billing_interval}ly` : ''}
</div> </div>
</div> <div className="text-lg font-semibold text-neutral-900 dark:text-white">
<div>
<div className="text-sm text-neutral-500 mb-1">Pageview Limit</div>
<div className="font-medium text-neutral-900 dark:text-white">
{subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / month` : 'Unlimited'}
</div>
</div>
<div>
<div className="text-sm text-neutral-500 mb-1">
{subscription.subscription_status === 'trialing' ? 'Trial Ends On' : 'Renews On'}
</div>
<div className="font-medium text-neutral-900 dark:text-white">
{(() => { {(() => {
const raw = subscription.current_period_end const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
const d = raw ? new Date(raw as string) : null return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
const ts = d ? d.getTime() : NaN
return raw && !Number.isNaN(ts) && ts !== 0 ? (d as Date).toLocaleDateString() : ''
})()} })()}
</div> </div>
</div> </div>
<div>
<div className="text-xs text-neutral-500 uppercase tracking-wider mb-1">Limit</div>
<div className="text-lg font-semibold text-neutral-900 dark:text-white">
{subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / mo` : 'Unlimited'}
</div>
</div>
</div> </div>
</div> </div>
{!subscription.has_payment_method && ( {/* Quick actions */}
<div className="p-6 bg-brand-orange/5 border border-brand-orange/20 rounded-2xl"> <div className="flex flex-wrap items-center gap-4">
<h3 className="font-medium text-brand-orange mb-2">Upgrade to Pro</h3> {subscription.has_payment_method && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4"> <button
Get higher limits, more data retention, and priority support. type="button"
</p> onClick={handleManageSubscription}
<Button onClick={() => router.push('/pricing')}> disabled={isRedirectingToPortal}
View Plans className="inline-flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
</Button> >
</div> <ExternalLinkIcon className="w-4 h-4" />
)} Payment method & invoices
</button>
)}
{subscription.has_payment_method && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && !subscription.cancel_at_period_end && (
<button
type="button"
onClick={() => setShowCancelPrompt(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 px-3.5 py-1.5 text-sm text-neutral-600 dark:text-neutral-400 hover:border-red-300 hover:text-red-600 dark:hover:border-red-800 dark:hover:text-red-400 transition-colors"
>
Cancel subscription
</button>
)}
</div>
{/* Invoice History */} {/* Invoice History */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-4">Invoice History</h3> <h3 className="text-sm font-medium text-neutral-500 uppercase tracking-wider mb-3">Recent invoices</h3>
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden"> <div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden divide-y divide-neutral-200 dark:divide-neutral-800">
{isLoadingInvoices ? ( {isLoadingInvoices ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
@@ -777,24 +871,21 @@ export default function OrganizationSettings() {
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
<div className="p-8 text-center text-neutral-500">No invoices found.</div> <div className="p-8 text-center text-neutral-500">No invoices found.</div>
) : ( ) : (
<div className="divide-y divide-neutral-200 dark:divide-neutral-800"> <>
{invoices.map((invoice) => ( {invoices.map((invoice) => (
<div key={invoice.id} className="p-4 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"> <div key={invoice.id} className="px-4 py-3 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center text-neutral-500">
<BookOpenIcon className="w-5 h-5" />
</div>
<div> <div>
<div className="font-medium text-neutral-900 dark:text-white"> <span className="font-medium text-sm text-neutral-900 dark:text-white">
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })} {(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
</div> </span>
<div className="text-xs text-neutral-500"> <span className="text-xs text-neutral-500 ml-2">
{new Date(invoice.created * 1000).toLocaleDateString()} {new Date(invoice.created * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</div> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium capitalize ${ <span className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
invoice.status === 'paid' invoice.status === 'paid'
greptile-apps[bot] commented 2026-02-09 13:48:48 +00:00 (Migrated from github.com)
Review

Invoices never render while loading
The invoices section is gated by invoices.length > 0 (components/settings/OrganizationSettings.tsx:860), so when invoices are still loading (initially []), the entire UI (including the spinner) is not rendered. This guarantees no loading state is shown and also hides the “No invoices found” state entirely. You likely want to render the section when isLoadingInvoices is true as well (or remove the outer length check).

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 859:906

Comment:
**Invoices never render while loading**
The invoices section is gated by `invoices.length > 0` (components/settings/OrganizationSettings.tsx:860), so when invoices are still loading (initially `[]`), the entire UI (including the spinner) is not rendered. This guarantees no loading state is shown and also hides the “No invoices found” state entirely. You likely want to render the section when `isLoadingInvoices` is true as well (or remove the outer length check).

How can I resolve this? If you propose a fix, please make it concise.
**Invoices never render while loading** The invoices section is gated by `invoices.length > 0` (components/settings/OrganizationSettings.tsx:860), so when invoices are still loading (initially `[]`), the entire UI (including the spinner) is not rendered. This guarantees no loading state is shown and also hides the “No invoices found” state entirely. You likely want to render the section when `isLoadingInvoices` is true as well (or remove the outer length check). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 859:906 Comment: **Invoices never render while loading** The invoices section is gated by `invoices.length > 0` (components/settings/OrganizationSettings.tsx:860), so when invoices are still loading (initially `[]`), the entire UI (including the spinner) is not rendered. This guarantees no loading state is shown and also hides the “No invoices found” state entirely. You likely want to render the section when `isLoadingInvoices` is true as well (or remove the outer length check). How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-09 13:52:22 +00:00 (Migrated from github.com)
Review

Issue: The invoices block was wrapped in invoices.length > 0, so while invoices was still [] (e.g. during load) the whole block (including spinner and empty state) was hidden.
Fix: The “Recent invoices” block always renders. The inner content is a three-way branch: show spinner when isLoadingInvoices, “No invoices found.” when loaded and invoices.length === 0, otherwise the list. The outer invoices.length > 0 guard was removed.
Why: Users need to see a loading state while invoices are fetched and an explicit empty state when there are none, not a missing section.

Issue: The invoices block was wrapped in invoices.length > 0, so while invoices was still [] (e.g. during load) the whole block (including spinner and empty state) was hidden. Fix: The “Recent invoices” block always renders. The inner content is a three-way branch: show spinner when isLoadingInvoices, “No invoices found.” when loaded and invoices.length === 0, otherwise the list. The outer invoices.length > 0 guard was removed. Why: Users need to see a loading state while invoices are fetched and an explicit empty state when there are none, not a missing section.
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: invoice.status === 'open' : invoice.status === 'open'
@@ -804,35 +895,26 @@ export default function OrganizationSettings() {
{invoice.status} {invoice.status}
</span> </span>
{invoice.invoice_pdf && ( {invoice.invoice_pdf && (
<a <a href={invoice.invoice_pdf} target="_blank" rel="noopener noreferrer"
href={invoice.invoice_pdf} className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="Download PDF">
target="_blank" <DownloadIcon className="w-4 h-4" />
rel="noopener noreferrer"
className="p-2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title="Download PDF"
>
<DownloadIcon className="w-5 h-5" />
</a> </a>
)} )}
{invoice.hosted_invoice_url && ( {invoice.hosted_invoice_url && (
<a <a href={invoice.hosted_invoice_url} target="_blank" rel="noopener noreferrer"
href={invoice.hosted_invoice_url} className="p-1.5 text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg transition-colors" title="View invoice">
target="_blank" <ExternalLinkIcon className="w-4 h-4" />
rel="noopener noreferrer"
className="p-2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title="View Invoice"
>
<ExternalLinkIcon className="w-5 h-5" />
</a> </a>
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </>
)} )
}
</div>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>
)} )}
@@ -1053,6 +1135,142 @@ export default function OrganizationSettings() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Cancel subscription confirmation modal */}
<AnimatePresence>
{showCancelPrompt && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Cancel subscription?</h3>
<button
onClick={() => setShowCancelPrompt(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
disabled={cancelLoadingAction != null}
>
<XIcon className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-300 mb-4">
You can cancel at the end of your billing period (you keep access until then) or cancel immediately. Your data is retained for 30 days after access ends.
</p>
<div className="flex flex-col gap-2">
<Button
onClick={() => handleCancelSubscription(true)}
disabled={cancelLoadingAction != null}
isLoading={cancelLoadingAction === 'period_end'}
>
Cancel at period end
</Button>
<Button
variant="ghost"
className="text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
onClick={() => handleCancelSubscription(false)}
disabled={cancelLoadingAction != null}
isLoading={cancelLoadingAction === 'immediate'}
>
Cancel immediately
</Button>
<Button variant="ghost" onClick={() => setShowCancelPrompt(false)} disabled={cancelLoadingAction != null}>
Keep subscription
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Change plan modal */}
<AnimatePresence>
{showChangePlanModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-neutral-900 rounded-2xl shadow-2xl max-w-md w-full p-6 border border-neutral-200 dark:border-neutral-800"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Change plan</h3>
<button
type="button"
onClick={() => setShowChangePlanModal(false)}
className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-400"
disabled={isChangingPlan}
>
<XIcon className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'Youll start a new subscription.'}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Pageviews per month</label>
<select
value={changePlanTierIndex}
onChange={(e) => setChangePlanTierIndex(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none"
>
{TRAFFIC_TIERS.map((tier, idx) => (
<option key={tier.value} value={idx}>
{tier.label} / month
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Billing</label>
<div className="flex rounded-lg border border-neutral-200 dark:border-neutral-800 p-1 gap-1">
<button
type="button"
onClick={() => setChangePlanYearly(false)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${!changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Monthly
</button>
<button
type="button"
onClick={() => setChangePlanYearly(true)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${changePlanYearly ? 'bg-brand-orange text-white' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`}
>
Yearly
</button>
</div>
</div>
</div>
<div className="flex gap-2 mt-6">
<Button
onClick={handleChangePlanSubmit}
isLoading={isChangingPlan}
disabled={isChangingPlan}
className="flex-1"
>
{hasActiveSubscription ? 'Update plan' : 'Start subscription'}
</Button>
<Button variant="ghost" onClick={() => setShowChangePlanModal(false)} disabled={isChangingPlan}>
Cancel
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
) )
} }

View File

@@ -7,6 +7,8 @@ export interface SubscriptionDetails {
billing_interval: string billing_interval: string
pageview_limit: number pageview_limit: number
has_payment_method: boolean has_payment_method: boolean
/** True when subscription is set to cancel at the end of the current period. */
cancel_at_period_end?: boolean
/** Number of sites for the org (billing usage). Present when backend supports usage API. */ /** Number of sites for the org (billing usage). Present when backend supports usage API. */
sites_count?: number sites_count?: number
/** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */ /** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */
@@ -50,6 +52,31 @@ export async function createPortalSession(): Promise<{ url: string }> {
}) })
} }
export interface CancelSubscriptionParams {
/** If true (default), cancel at end of billing period; if false, cancel immediately. */
at_period_end?: boolean
}
export async function cancelSubscription(params?: CancelSubscriptionParams): Promise<{ ok: boolean; at_period_end: boolean }> {
return await billingFetch<{ ok: boolean; at_period_end: boolean }>('/api/billing/cancel', {
method: 'POST',
body: JSON.stringify({ at_period_end: params?.at_period_end ?? true }),
})
}
export interface ChangePlanParams {
plan_id: string
interval: string
limit: number
}
export async function changePlan(params: ChangePlanParams): Promise<{ ok: boolean }> {
return await billingFetch<{ ok: boolean }>('/api/billing/change-plan', {
method: 'POST',
body: JSON.stringify(params),
})
}
export interface CreateCheckoutParams { export interface CreateCheckoutParams {
plan_id: string plan_id: string
interval: string interval: string

29
lib/plans.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Shared plan and traffic tier definitions for pricing and billing (Change plan).
* Backend supports plan_id "solo" and limit 10k10M; month/year interval.
*/
export const PLAN_ID_SOLO = 'solo'
/** Traffic tiers available for Solo plan (pageview limits). */
export const TRAFFIC_TIERS = [
{ label: '10k', value: 10000 },
{ label: '50k', value: 50000 },
{ label: '100k', value: 100000 },
{ label: '250k', value: 250000 },
{ label: '500k', value: 500000 },
{ label: '1M', value: 1000000 },
{ label: '2.5M', value: 2500000 },
{ label: '5M', value: 5000000 },
{ label: '10M', value: 10000000 },
] as const
export function getTierIndexForLimit(limit: number): number {
const idx = TRAFFIC_TIERS.findIndex((t) => t.value === limit)
return idx >= 0 ? idx : 2
}
export function getLimitForTierIndex(index: number): number {
if (index < 0 || index >= TRAFFIC_TIERS.length) return 100000
return TRAFFIC_TIERS[index].value
}