diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index e500209..70d613d 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,8 @@ import { OrganizationInvitation, 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 { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' @@ -68,6 +69,12 @@ export default function OrganizationSettings() { const [subscription, setSubscription] = useState(null) const [isLoadingSubscription, setIsLoadingSubscription] = 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([]) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) @@ -123,7 +130,11 @@ export default function OrganizationSettings() { const currentOrgId = getOrgIdFromToken() const loadMembers = useCallback(async () => { - if (!currentOrgId) return + if (!currentOrgId) { + setIsLoadingMembers(false) + return + } + setIsLoadingMembers(true) try { const [membersData, invitesData, orgData] = await Promise.all([ getOrganizationMembers(currentOrgId), @@ -257,6 +268,54 @@ export default function OrganizationSettings() { } } + 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 () => { if (deleteConfirm !== 'DELETE') return @@ -653,10 +712,10 @@ export default function OrganizationSettings() { )} {activeTab === 'billing' && ( -
+

Billing & Subscription

-

Manage your subscription plan and payment methods.

+

Manage your plan, usage, and payment details.

{isLoadingSubscription ? ( @@ -666,51 +725,82 @@ export default function OrganizationSettings() { ) : !subscription ? (

Could not load subscription details.

- +
) : ( -
- {/* Current Plan */} -
-
-
-

Current Plan

-
- - {subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan +
+ + {/* Trial notice */} + {subscription.subscription_status === 'trialing' && ( +
+
+

+ Your free trial ends on{' '} + + {(() => { + 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' }) : '—' + })()} - - {subscription.subscription_status === 'trialing' ? 'Trial Active' : (subscription.subscription_status || 'Free')} - -

+

+

+ After the trial you'll be charged automatically unless you cancel before then. +

- {subscription.has_payment_method && ( - - )} +
+ )} + + {/* Cancel-at-period-end notice */} + {subscription.cancel_at_period_end && ( +
+

+ Your subscription will end on{' '} + + {(() => { + 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' }) : '—' + })()} + +

+

+ You keep full access until then. Your data is retained for 30 days after. Use "Change plan" to resubscribe. +

+
+ )} + + {/* Plan & Usage card */} +
+ {/* Plan header */} +
+
+ + {subscription.plan_id?.startsWith('price_') ? 'Pro' : (subscription.plan_id === 'free' || !subscription.plan_id ? 'Free' : subscription.plan_id)} Plan + + + {subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')} + + {subscription.billing_interval && ( + + Billed {subscription.billing_interval}ly + + )} +
+
-
+ {/* Usage stats */} +
-
Sites
-
+
Sites
+
{typeof subscription.sites_count === 'number' ? subscription.plan_id === 'solo' ? `${subscription.sites_count} / 1` @@ -719,57 +809,61 @@ export default function OrganizationSettings() {
-
Pageviews this period
-
+
Pageviews
+
{subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` : '—'}
-
Billing Interval
-
- {subscription.billing_interval ? `${subscription.billing_interval}ly` : '—'} +
+ {subscription.subscription_status === 'trialing' ? 'Trial ends' : (subscription.cancel_at_period_end ? 'Access until' : 'Renews')}
-
-
-
Pageview Limit
-
- {subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / month` : 'Unlimited'} -
-
-
-
- {subscription.subscription_status === 'trialing' ? 'Trial Ends On' : 'Renews On'} -
-
+
{(() => { - const raw = subscription.current_period_end - const d = raw ? new Date(raw as string) : null - const ts = d ? d.getTime() : NaN - return raw && !Number.isNaN(ts) && ts !== 0 ? (d as Date).toLocaleDateString() : '—' + const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null + return d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' })()}
+
+
Limit
+
+ {subscription.pageview_limit > 0 ? `${subscription.pageview_limit.toLocaleString()} / mo` : 'Unlimited'} +
+
- {!subscription.has_payment_method && ( -
-

Upgrade to Pro

-

- Get higher limits, more data retention, and priority support. -

- -
- )} + {/* Quick actions */} +
+ {subscription.has_payment_method && ( + + )} + {subscription.has_payment_method && (subscription.subscription_status === 'active' || subscription.subscription_status === 'trialing') && !subscription.cancel_at_period_end && ( + + )} +
{/* Invoice History */}
-

Invoice History

-
+

Recent invoices

+
{isLoadingInvoices ? (
@@ -777,24 +871,21 @@ export default function OrganizationSettings() { ) : invoices.length === 0 ? (
No invoices found.
) : ( -
+ <> {invoices.map((invoice) => ( -
-
-
- -
+
+
-
+ {(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })} -
-
- {new Date(invoice.created * 1000).toLocaleDateString()} -
+ + + {new Date(invoice.created * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} +
-
- + {invoice.invoice_pdf && ( - - + + )} {invoice.hosted_invoice_url && ( - - + + )}
))} -
- )} + + ) + } +
-
)}
)} @@ -1053,6 +1135,142 @@ export default function OrganizationSettings() { )} + + {/* Cancel subscription confirmation modal */} + + {showCancelPrompt && ( + + +
+

Cancel subscription?

+ +
+

+ 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. +

+
+ + + +
+
+
+ )} +
+ + {/* Change plan modal */} + + {showChangePlanModal && ( + + +
+

Change plan

+ +
+

+ Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'You’ll start a new subscription.'} +

+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+
+ )} +
) } diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 29b6d48..447fd23 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -7,6 +7,8 @@ export interface SubscriptionDetails { billing_interval: string pageview_limit: number 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. */ sites_count?: number /** 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 { plan_id: string interval: string diff --git a/lib/plans.ts b/lib/plans.ts new file mode 100644 index 0000000..c81a4d5 --- /dev/null +++ b/lib/plans.ts @@ -0,0 +1,29 @@ +/** + * Shared plan and traffic tier definitions for pricing and billing (Change plan). + * Backend supports plan_id "solo" and limit 10k–10M; 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 +}