feat: implement loading state and error handling in PricingSection; add Suspense for async components in Pricing and Settings pages

This commit is contained in:
Usman Baig
2026-01-31 17:29:54 +01:00
parent 8550909047
commit f085c11ba3
5 changed files with 69 additions and 11 deletions

View File

@@ -1,9 +1,12 @@
import { Suspense } from 'react'
import PricingSection from '@/components/PricingSection' import PricingSection from '@/components/PricingSection'
export default function PricingPage() { export default function PricingPage() {
return ( return (
<div className="min-h-screen pt-20"> <div className="min-h-screen pt-20">
<PricingSection /> <Suspense fallback={<div className="min-h-screen pt-20 flex items-center justify-center">Loading...</div>}>
<PricingSection />
</Suspense>
</div> </div>
) )
} }

View File

@@ -1,4 +1,6 @@
import { Suspense } from 'react'
import ProfileSettings from '@/components/settings/ProfileSettings' import ProfileSettings from '@/components/settings/ProfileSettings'
import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast'
export const metadata = { export const metadata = {
title: 'Settings - Pulse', title: 'Settings - Pulse',
@@ -8,6 +10,9 @@ export const metadata = {
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<div className="min-h-screen pt-12 pb-12 px-4 sm:px-6"> <div className="min-h-screen pt-12 pb-12 px-4 sm:px-6">
<Suspense fallback={null}>
<CheckoutSuccessToast />
</Suspense>
<ProfileSettings /> <ProfileSettings />
</div> </div>
) )

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { Button, CheckCircleIcon } from '@ciphera-net/ui' import { Button, CheckCircleIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { initiateOAuthFlow } from '@/lib/api/oauth' import { initiateOAuthFlow } from '@/lib/api/oauth'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getClient } from '@/lib/api/client' import { createCheckoutSession } from '@/lib/api/billing'
// 1. Define Plans with IDs and Site Limits // 1. Define Plans with IDs and Site Limits
const PLANS = [ const PLANS = [
@@ -100,11 +101,22 @@ const TRAFFIC_TIERS = [
] ]
export default function PricingSection() { export default function PricingSection() {
const searchParams = useSearchParams()
const [isYearly, setIsYearly] = useState(false) const [isYearly, setIsYearly] = useState(false)
const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2) const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2)
const [loadingPlan, setLoadingPlan] = useState<string | null>(null) const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const { user } = useAuth() const { user } = useAuth()
// * Show toast when redirected from Stripe Checkout with canceled=true
useEffect(() => {
if (searchParams.get('canceled') === 'true') {
toast.info('Checkout was canceled. You can try again whenever youre ready.')
const url = new URL(window.location.href)
url.searchParams.delete('canceled')
window.history.replaceState({}, '', url.pathname + url.search)
}
}, [searchParams])
// * Check for pending checkout on mount/auth // * Check for pending checkout on mount/auth
useEffect(() => { useEffect(() => {
if (!user) return if (!user) return
@@ -174,19 +186,18 @@ export default function PricingSection() {
} }
// 2. Call backend to create checkout session // 2. Call backend to create checkout session
const client = getClient()
const interval = options?.interval || (isYearly ? 'year' : 'month') const interval = options?.interval || (isYearly ? 'year' : 'month')
const limit = options?.limit || currentTraffic.value const limit = options?.limit || currentTraffic.value
const res = await client.post('/api/billing/checkout', { const { url } = await createCheckoutSession({
plan_id: planId, plan_id: planId,
interval: interval, interval,
limit: limit limit,
}) })
// 3. Redirect to Stripe Checkout // 3. Redirect to Stripe Checkout
if (res.data.url) { if (url) {
window.location.href = res.data.url window.location.href = url
} else { } else {
throw new Error('No checkout URL returned') throw new Error('No checkout URL returned')
} }
@@ -317,14 +328,14 @@ export default function PricingSection() {
<Button <Button
onClick={() => handleSubscribe(plan.id)} onClick={() => handleSubscribe(plan.id)}
disabled={loadingPlan === plan.id || !!loadingPlan} disabled={loadingPlan === plan.id || !!loadingPlan || !priceDetails}
className={`w-full mb-8 ${ className={`w-full mb-8 ${
isTeam isTeam
? 'bg-brand-orange hover:bg-brand-orange/90 text-white shadow-lg shadow-brand-orange/20' ? 'bg-brand-orange hover:bg-brand-orange/90 text-white shadow-lg shadow-brand-orange/20'
: 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100' : 'bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100'
}`} }`}
> >
{loadingPlan === plan.id ? 'Loading...' : 'Start free trial'} {loadingPlan === plan.id ? 'Loading...' : !priceDetails ? 'Contact us' : 'Start free trial'}
</Button> </Button>
<ul className="space-y-4 flex-grow"> <ul className="space-y-4 flex-grow">

View File

@@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
/**
* Shows a success toast when redirected from Stripe Checkout with success=true,
* then clears the query params from the URL.
*/
export default function CheckoutSuccessToast() {
const searchParams = useSearchParams()
useEffect(() => {
const success = searchParams.get('success')
if (success === 'true') {
toast.success('Thank you for subscribing! Your subscription is now active.')
const url = new URL(window.location.href)
url.searchParams.delete('success')
url.searchParams.delete('session_id')
window.history.replaceState({}, '', url.pathname + url.search)
}
}, [searchParams])
return null
}

View File

@@ -45,3 +45,16 @@ export async function createPortalSession(): Promise<{ url: string }> {
method: 'POST', method: 'POST',
}) })
} }
export interface CreateCheckoutParams {
plan_id: string
interval: string
limit: number
}
export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> {
return await billingFetch<{ url: string }>('/api/billing/checkout', {
method: 'POST',
body: JSON.stringify(params),
})
}