feat: implement loading state and error handling in PricingSection; add Suspense for async components in Pricing and Settings pages
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 you’re 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">
|
||||||
|
|||||||
26
components/checkout/CheckoutSuccessToast.tsx
Normal file
26
components/checkout/CheckoutSuccessToast.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user