From 826dbdbe63a0f3aa19e01402be7b87da0b11ef90 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 02:46:23 +0100 Subject: [PATCH 01/16] feat: implement site limits based on subscription plans across dashboard and new site creation; enhance UI feedback for plan limits --- app/page.tsx | 16 ++++++++++++---- app/sites/new/page.tsx | 6 ++++-- components/settings/OrganizationSettings.tsx | 9 +++++---- lib/plans.ts | 15 ++++++++++++++- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 90eed1d..de34a12 100644 --- a/app/page.tsx +++ b/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 { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' +import { getSitesLimitForPlan } from '@/lib/plans' function DashboardPreview() { return ( @@ -337,10 +338,13 @@ export default function HomePage() {

Your Sites

Manage your analytics sites and view insights.

- {subscription?.plan_id === 'solo' && sites.length >= 1 ? ( + {(() => { + const siteLimit = getSitesLimitForPlan(subscription?.plan_id) + const atLimit = siteLimit != null && sites.length >= siteLimit + return atLimit ? (
- Limit reached (1/1) + Limit reached ({sites.length}/{siteLimit})
- ) : ( + ) : null + })() ?? ( + {subscription.business_name && ( +
+ Billing for: {subscription.business_name} +
+ )} {/* Usage stats */}
diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 047712d..43207e3 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -13,7 +13,7 @@ export interface SubscriptionDetails { sites_count?: number /** Pageviews in current billing period (when pageview_limit > 0). Present when backend supports usage API. */ pageview_usage?: number - /** Business name from Stripe Checkout (optional). */ + /** Business name from Stripe Tax ID collection / business purchase flow (optional). */ business_name?: string } From 0bbbb8a1afa7f6d2ffa06a06f502bd8e63594e69 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 03:41:35 +0100 Subject: [PATCH 04/16] feat: integrate Stripe for embedded checkout; update billing API to return client_secret and adjust checkout flow in components --- CONTRIBUTING.md | 1 + app/checkout/page.tsx | 137 +++++++++++++++++++ app/checkout/return/page.tsx | 93 +++++++++++++ app/welcome/page.tsx | 13 +- components/PricingSection.tsx | 18 +-- components/settings/OrganizationSettings.tsx | 11 +- lib/api/billing.ts | 15 +- package-lock.json | 30 +++- package.json | 2 + 9 files changed, 291 insertions(+), 29 deletions(-) create mode 100644 app/checkout/page.tsx create mode 100644 app/checkout/return/page.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a6c821..20f3f7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ Thank you for your interest in contributing to Pulse! We welcome contributions f NEXT_PUBLIC_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_AUTH_API_URL=http://localhost:8081 NEXT_PUBLIC_APP_URL=http://localhost:3003 + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # For embedded checkout (optional if billing not used) ``` 5. **Run the development server**: ```bash diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx new file mode 100644 index 0000000..36b975c --- /dev/null +++ b/app/checkout/page.tsx @@ -0,0 +1,137 @@ +'use client' + +/** + * Embedded Stripe Checkout page. + * Requires plan_id, interval, limit in URL (e.g. /checkout?plan_id=solo&interval=year&limit=100000). + * Falls back to pulse_pending_checkout from localStorage (after OAuth). + */ + +import { useCallback, useEffect, useMemo, Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { loadStripe } from '@stripe/stripe-js' +import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js' +import { useAuth } from '@/lib/auth/context' +import { createCheckoutSession } from '@/lib/api/billing' +import { LoadingOverlay } from '@ciphera-net/ui' +import Link from 'next/link' + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '') + +function CheckoutContent() { + const router = useRouter() + const searchParams = useSearchParams() + const { user } = useAuth() + + const planId = searchParams.get('plan_id') + const interval = searchParams.get('interval') + const limitParam = searchParams.get('limit') + const limit = limitParam ? parseInt(limitParam, 10) : null + + const paramsValid = planId && interval && limit != null && !Number.isNaN(limit) && limit > 0 + + const fetchClientSecret = useCallback(async () => { + let pid = planId + let int = interval + let lim = limit + + if (!paramsValid) { + const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null + if (pending) { + try { + const intent = JSON.parse(pending) + pid = intent.planId || pid + int = intent.interval || int + lim = intent.limit ?? lim + } catch { + // ignore + } + } + } + + if (!pid || !int || lim == null || lim <= 0) { + throw new Error('Missing checkout params. Go to Pricing to subscribe.') + } + + const { client_secret } = await createCheckoutSession({ + plan_id: pid, + interval: int, + limit: lim, + }) + return client_secret + }, [planId, interval, limit, paramsValid]) + + const options = useMemo(() => ({ fetchClientSecret }), [fetchClientSecret]) + + useEffect(() => { + if (!user) { + const intent = paramsValid + ? { planId, interval, limit, fromCheckout: true } + : typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null + if (intent && typeof intent === 'string') { + try { + const parsed = JSON.parse(intent) + localStorage.setItem('pulse_pending_checkout', JSON.stringify({ ...parsed, fromCheckout: true })) + } catch { + // ignore + } + } else if (paramsValid && typeof window !== 'undefined') { + localStorage.setItem('pulse_pending_checkout', JSON.stringify({ planId, interval, limit, fromCheckout: true })) + } + router.replace('/login') + return + } + + if (!paramsValid) { + const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null + if (!pending) { + router.replace('/pricing') + return + } + } + }, [user, paramsValid, planId, interval, limit, router]) + + if (!user) { + return + } + + if (!paramsValid) { + const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null + if (!pending) { + return ( +
+

Missing checkout parameters.

+ + Go to Pricing + +
+ ) + } + } + + if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + return ( +
+

Checkout is not configured.

+ + Back to Pricing + +
+ ) + } + + return ( +
+ + + +
+ ) +} + +export default function CheckoutPage() { + return ( + }> + + + ) +} diff --git a/app/checkout/return/page.tsx b/app/checkout/return/page.tsx new file mode 100644 index 0000000..e2aceac --- /dev/null +++ b/app/checkout/return/page.tsx @@ -0,0 +1,93 @@ +'use client' + +/** + * Return page after Embedded Checkout. + * Stripe redirects here with ?session_id={CHECKOUT_SESSION_ID}. + * Fetches session status and redirects to dashboard on success. + */ + +import { useEffect, useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { getCheckoutSessionStatus } from '@/lib/api/billing' +import { LoadingOverlay } from '@ciphera-net/ui' +import Link from 'next/link' + +export default function CheckoutReturnPage() { + const router = useRouter() + const searchParams = useSearchParams() + const sessionId = searchParams.get('session_id') + const [status, setStatus] = useState<'loading' | 'complete' | 'open' | 'error'>('loading') + const [errorMessage, setErrorMessage] = useState('') + + useEffect(() => { + if (!sessionId) { + setStatus('error') + setErrorMessage('Missing session ID') + return + } + + let cancelled = false + + async function check() { + try { + const data = await getCheckoutSessionStatus(sessionId!) + if (cancelled) return + + if (data.status === 'complete') { + setStatus('complete') + router.replace('/') + return + } + + if (data.status === 'open') { + setStatus('open') + setErrorMessage('Payment was not completed. You can try again.') + return + } + + setStatus('error') + setErrorMessage('Unexpected session status. Please contact support if you were charged.') + } catch (err) { + if (cancelled) return + setStatus('error') + setErrorMessage((err as Error)?.message || 'Failed to verify payment') + } + } + + check() + return () => { cancelled = true } + }, [sessionId, router]) + + if (status === 'loading') { + return + } + + if (status === 'complete') { + return + } + + return ( +
+

+ {status === 'open' ? 'Payment not completed' : 'Something went wrong'} +

+

+ {errorMessage} +

+
+ + Back to pricing + + + Go to dashboard + +
+
+ ) +} diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index bab5fbe..2fd1ac5 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -16,7 +16,6 @@ import { type Organization, type OrganizationMember, } from '@/lib/api/organization' -import { createCheckoutSession } from '@/lib/api/billing' import { createSite, type Site } from '@/lib/api/sites' import { setSessionAction } from '@/app/actions/auth' import { useAuth } from '@/lib/auth/context' @@ -218,18 +217,14 @@ function WelcomeContent() { try { trackWelcomePlanContinue() const intent = JSON.parse(raw) - const { url } = await createCheckoutSession({ + const params = new URLSearchParams({ plan_id: intent.planId, interval: intent.interval || 'month', - limit: intent.limit ?? 100000, + limit: String(intent.limit ?? 100000), }) localStorage.removeItem('pulse_pending_checkout') - if (url) { - setRedirectingCheckout(true) - window.location.href = url - return - } - throw new Error('No checkout URL returned') + setRedirectingCheckout(true) + router.push(`/checkout?${params.toString()}`) } catch (err: unknown) { setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout') localStorage.removeItem('pulse_pending_checkout') diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 04d8b1f..faeb9db 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -1,13 +1,12 @@ 'use client' import { useState, useEffect } from 'react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' import { Button, CheckCircleIcon } from '@ciphera-net/ui' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow } from '@/lib/api/oauth' import { toast } from '@ciphera-net/ui' -import { createCheckoutSession } from '@/lib/api/billing' // 1. Define Plans with IDs and Site Limits const PLANS = [ @@ -102,6 +101,7 @@ const TRAFFIC_TIERS = [ ] export default function PricingSection() { + const router = useRouter() const searchParams = useSearchParams() const [isYearly, setIsYearly] = useState(false) const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2) @@ -186,22 +186,16 @@ export default function PricingSection() { return } - // 2. Call backend to create checkout session + // 2. Navigate to embedded checkout page const interval = options?.interval || (isYearly ? 'year' : 'month') const limit = options?.limit || currentTraffic.value - const { url } = await createCheckoutSession({ + const params = new URLSearchParams({ plan_id: planId, interval, - limit, + limit: String(limit), }) - - // 3. Redirect to Stripe Checkout - if (url) { - window.location.href = url - } else { - throw new Error('No checkout URL returned') - } + router.push(`/checkout?${params.toString()}`) } catch (error: any) { console.error('Checkout error:', error) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 72d2b84..df57ee5 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,7 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' -import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, SubscriptionDetails, Invoice } from '@/lib/api/billing' import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings' @@ -351,9 +351,12 @@ export default function OrganizationSettings() { 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') + const params = new URLSearchParams({ + plan_id: PLAN_ID_SOLO, + interval, + limit: String(limit), + }) + router.push(`/checkout?${params.toString()}`) } } catch (error: any) { toast.error(getAuthErrorMessage(error) || error.message || 'Something went wrong.') diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 43207e3..fda5489 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -85,13 +85,24 @@ export interface CreateCheckoutParams { limit: number } -export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> { - return await billingFetch<{ url: string }>('/api/billing/checkout', { +export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ client_secret: string }> { + return await billingFetch<{ client_secret: string }>('/api/billing/checkout', { method: 'POST', body: JSON.stringify(params), }) } +export interface CheckoutSessionStatus { + status: string + customer_email: string +} + +export async function getCheckoutSessionStatus(sessionId: string): Promise { + return await billingFetch(`/api/billing/checkout/session-status?session_id=${encodeURIComponent(sessionId)}`, { + method: 'GET', + }) +} + export interface Invoice { id: string amount_paid: number diff --git a/package-lock.json b/package-lock.json index c1f34dc..9c1256e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "pulse-frontend", - "version": "0.6.0-alpha", + "version": "0.7.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.6.0-alpha", + "version": "0.7.0-alpha", "dependencies": { "@ciphera-net/ui": "^0.0.57", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", + "@stripe/react-stripe-js": "^5.6.0", + "@stripe/stripe-js": "^8.7.0", "axios": "^1.13.2", "country-flag-icons": "^1.6.4", "d3-scale": "^4.0.2", @@ -2715,6 +2717,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", + "integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz", + "integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", diff --git a/package.json b/package.json index c8b81cf..e88e3ec 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@ciphera-net/ui": "^0.0.57", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", + "@stripe/react-stripe-js": "^5.6.0", + "@stripe/stripe-js": "^8.7.0", "axios": "^1.13.2", "country-flag-icons": "^1.6.4", "d3-scale": "^4.0.2", From 96b3919e52c59a4bd9ce49abd40c647eaea12b76 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 03:47:10 +0100 Subject: [PATCH 05/16] fix: refactor CheckoutReturnPage to use Suspense for loading state and separate content into CheckoutReturnContent component --- app/checkout/return/page.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/checkout/return/page.tsx b/app/checkout/return/page.tsx index e2aceac..bd448e4 100644 --- a/app/checkout/return/page.tsx +++ b/app/checkout/return/page.tsx @@ -6,13 +6,13 @@ * Fetches session status and redirects to dashboard on success. */ -import { useEffect, useState } from 'react' +import { Suspense, useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { getCheckoutSessionStatus } from '@/lib/api/billing' import { LoadingOverlay } from '@ciphera-net/ui' import Link from 'next/link' -export default function CheckoutReturnPage() { +function CheckoutReturnContent() { const router = useRouter() const searchParams = useSearchParams() const sessionId = searchParams.get('session_id') @@ -91,3 +91,11 @@ export default function CheckoutReturnPage() {
) } + +export default function CheckoutReturnPage() { + return ( + }> + + + ) +} From 17106517d922a10b6e96c4551f9f785f9ffc7674 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 03:51:20 +0100 Subject: [PATCH 06/16] refactor: remove embedded checkout components and update billing API integration for streamlined checkout flow --- CONTRIBUTING.md | 1 - app/checkout/page.tsx | 137 ------------------- app/checkout/return/page.tsx | 101 -------------- app/welcome/page.tsx | 13 +- components/PricingSection.tsx | 18 ++- components/settings/OrganizationSettings.tsx | 11 +- lib/api/billing.ts | 15 +- 7 files changed, 27 insertions(+), 269 deletions(-) delete mode 100644 app/checkout/page.tsx delete mode 100644 app/checkout/return/page.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20f3f7e..7a6c821 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,6 @@ Thank you for your interest in contributing to Pulse! We welcome contributions f NEXT_PUBLIC_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_AUTH_API_URL=http://localhost:8081 NEXT_PUBLIC_APP_URL=http://localhost:3003 - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # For embedded checkout (optional if billing not used) ``` 5. **Run the development server**: ```bash diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx deleted file mode 100644 index 36b975c..0000000 --- a/app/checkout/page.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -/** - * Embedded Stripe Checkout page. - * Requires plan_id, interval, limit in URL (e.g. /checkout?plan_id=solo&interval=year&limit=100000). - * Falls back to pulse_pending_checkout from localStorage (after OAuth). - */ - -import { useCallback, useEffect, useMemo, Suspense } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { loadStripe } from '@stripe/stripe-js' -import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js' -import { useAuth } from '@/lib/auth/context' -import { createCheckoutSession } from '@/lib/api/billing' -import { LoadingOverlay } from '@ciphera-net/ui' -import Link from 'next/link' - -const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '') - -function CheckoutContent() { - const router = useRouter() - const searchParams = useSearchParams() - const { user } = useAuth() - - const planId = searchParams.get('plan_id') - const interval = searchParams.get('interval') - const limitParam = searchParams.get('limit') - const limit = limitParam ? parseInt(limitParam, 10) : null - - const paramsValid = planId && interval && limit != null && !Number.isNaN(limit) && limit > 0 - - const fetchClientSecret = useCallback(async () => { - let pid = planId - let int = interval - let lim = limit - - if (!paramsValid) { - const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null - if (pending) { - try { - const intent = JSON.parse(pending) - pid = intent.planId || pid - int = intent.interval || int - lim = intent.limit ?? lim - } catch { - // ignore - } - } - } - - if (!pid || !int || lim == null || lim <= 0) { - throw new Error('Missing checkout params. Go to Pricing to subscribe.') - } - - const { client_secret } = await createCheckoutSession({ - plan_id: pid, - interval: int, - limit: lim, - }) - return client_secret - }, [planId, interval, limit, paramsValid]) - - const options = useMemo(() => ({ fetchClientSecret }), [fetchClientSecret]) - - useEffect(() => { - if (!user) { - const intent = paramsValid - ? { planId, interval, limit, fromCheckout: true } - : typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null - if (intent && typeof intent === 'string') { - try { - const parsed = JSON.parse(intent) - localStorage.setItem('pulse_pending_checkout', JSON.stringify({ ...parsed, fromCheckout: true })) - } catch { - // ignore - } - } else if (paramsValid && typeof window !== 'undefined') { - localStorage.setItem('pulse_pending_checkout', JSON.stringify({ planId, interval, limit, fromCheckout: true })) - } - router.replace('/login') - return - } - - if (!paramsValid) { - const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null - if (!pending) { - router.replace('/pricing') - return - } - } - }, [user, paramsValid, planId, interval, limit, router]) - - if (!user) { - return - } - - if (!paramsValid) { - const pending = typeof window !== 'undefined' ? localStorage.getItem('pulse_pending_checkout') : null - if (!pending) { - return ( -
-

Missing checkout parameters.

- - Go to Pricing - -
- ) - } - } - - if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { - return ( -
-

Checkout is not configured.

- - Back to Pricing - -
- ) - } - - return ( -
- - - -
- ) -} - -export default function CheckoutPage() { - return ( - }> - - - ) -} diff --git a/app/checkout/return/page.tsx b/app/checkout/return/page.tsx deleted file mode 100644 index bd448e4..0000000 --- a/app/checkout/return/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client' - -/** - * Return page after Embedded Checkout. - * Stripe redirects here with ?session_id={CHECKOUT_SESSION_ID}. - * Fetches session status and redirects to dashboard on success. - */ - -import { Suspense, useEffect, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { getCheckoutSessionStatus } from '@/lib/api/billing' -import { LoadingOverlay } from '@ciphera-net/ui' -import Link from 'next/link' - -function CheckoutReturnContent() { - const router = useRouter() - const searchParams = useSearchParams() - const sessionId = searchParams.get('session_id') - const [status, setStatus] = useState<'loading' | 'complete' | 'open' | 'error'>('loading') - const [errorMessage, setErrorMessage] = useState('') - - useEffect(() => { - if (!sessionId) { - setStatus('error') - setErrorMessage('Missing session ID') - return - } - - let cancelled = false - - async function check() { - try { - const data = await getCheckoutSessionStatus(sessionId!) - if (cancelled) return - - if (data.status === 'complete') { - setStatus('complete') - router.replace('/') - return - } - - if (data.status === 'open') { - setStatus('open') - setErrorMessage('Payment was not completed. You can try again.') - return - } - - setStatus('error') - setErrorMessage('Unexpected session status. Please contact support if you were charged.') - } catch (err) { - if (cancelled) return - setStatus('error') - setErrorMessage((err as Error)?.message || 'Failed to verify payment') - } - } - - check() - return () => { cancelled = true } - }, [sessionId, router]) - - if (status === 'loading') { - return - } - - if (status === 'complete') { - return - } - - return ( -
-

- {status === 'open' ? 'Payment not completed' : 'Something went wrong'} -

-

- {errorMessage} -

-
- - Back to pricing - - - Go to dashboard - -
-
- ) -} - -export default function CheckoutReturnPage() { - return ( - }> - - - ) -} diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx index 2fd1ac5..bab5fbe 100644 --- a/app/welcome/page.tsx +++ b/app/welcome/page.tsx @@ -16,6 +16,7 @@ import { type Organization, type OrganizationMember, } from '@/lib/api/organization' +import { createCheckoutSession } from '@/lib/api/billing' import { createSite, type Site } from '@/lib/api/sites' import { setSessionAction } from '@/app/actions/auth' import { useAuth } from '@/lib/auth/context' @@ -217,14 +218,18 @@ function WelcomeContent() { try { trackWelcomePlanContinue() const intent = JSON.parse(raw) - const params = new URLSearchParams({ + const { url } = await createCheckoutSession({ plan_id: intent.planId, interval: intent.interval || 'month', - limit: String(intent.limit ?? 100000), + limit: intent.limit ?? 100000, }) localStorage.removeItem('pulse_pending_checkout') - setRedirectingCheckout(true) - router.push(`/checkout?${params.toString()}`) + if (url) { + setRedirectingCheckout(true) + window.location.href = url + return + } + throw new Error('No checkout URL returned') } catch (err: unknown) { setPlanError(getAuthErrorMessage(err) || (err as Error)?.message || 'Failed to start checkout') localStorage.removeItem('pulse_pending_checkout') diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index faeb9db..04d8b1f 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -1,12 +1,13 @@ 'use client' import { useState, useEffect } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' import { Button, CheckCircleIcon } from '@ciphera-net/ui' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow } from '@/lib/api/oauth' import { toast } from '@ciphera-net/ui' +import { createCheckoutSession } from '@/lib/api/billing' // 1. Define Plans with IDs and Site Limits const PLANS = [ @@ -101,7 +102,6 @@ const TRAFFIC_TIERS = [ ] export default function PricingSection() { - const router = useRouter() const searchParams = useSearchParams() const [isYearly, setIsYearly] = useState(false) const [sliderIndex, setSliderIndex] = useState(2) // Default to 100k (index 2) @@ -186,16 +186,22 @@ export default function PricingSection() { return } - // 2. Navigate to embedded checkout page + // 2. Call backend to create checkout session const interval = options?.interval || (isYearly ? 'year' : 'month') const limit = options?.limit || currentTraffic.value - const params = new URLSearchParams({ + const { url } = await createCheckoutSession({ plan_id: planId, interval, - limit: String(limit), + limit, }) - router.push(`/checkout?${params.toString()}`) + + // 3. Redirect to Stripe Checkout + if (url) { + window.location.href = url + } else { + throw new Error('No checkout URL returned') + } } catch (error: any) { console.error('Checkout error:', error) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index df57ee5..72d2b84 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,7 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' -import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, 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, getSitesLimitForPlan } from '@/lib/plans' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings' @@ -351,12 +351,9 @@ export default function OrganizationSettings() { setShowChangePlanModal(false) loadSubscription() } else { - const params = new URLSearchParams({ - plan_id: PLAN_ID_SOLO, - interval, - limit: String(limit), - }) - router.push(`/checkout?${params.toString()}`) + 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.') diff --git a/lib/api/billing.ts b/lib/api/billing.ts index fda5489..43207e3 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -85,24 +85,13 @@ export interface CreateCheckoutParams { limit: number } -export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ client_secret: string }> { - return await billingFetch<{ client_secret: string }>('/api/billing/checkout', { +export async function createCheckoutSession(params: CreateCheckoutParams): Promise<{ url: string }> { + return await billingFetch<{ url: string }>('/api/billing/checkout', { method: 'POST', body: JSON.stringify(params), }) } -export interface CheckoutSessionStatus { - status: string - customer_email: string -} - -export async function getCheckoutSessionStatus(sessionId: string): Promise { - return await billingFetch(`/api/billing/checkout/session-status?session_id=${encodeURIComponent(sessionId)}`, { - method: 'GET', - }) -} - export interface Invoice { id: string amount_paid: number From 2d37d065c0bd86a78e7458fa6703d343c8925a6e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 04:02:11 +0100 Subject: [PATCH 07/16] fix: remove CheckoutSuccessToast component and its usage in SettingsPage for cleaner settings interface --- app/settings/page.tsx | 5 ---- components/checkout/CheckoutSuccessToast.tsx | 26 -------------------- 2 files changed, 31 deletions(-) delete mode 100644 components/checkout/CheckoutSuccessToast.tsx diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 030ef5c..012f348 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,6 +1,4 @@ -import { Suspense } from 'react' import ProfileSettings from '@/components/settings/ProfileSettings' -import CheckoutSuccessToast from '@/components/checkout/CheckoutSuccessToast' export const metadata = { title: 'Settings - Pulse', @@ -10,9 +8,6 @@ export const metadata = { export default function SettingsPage() { return (
- - -
) diff --git a/components/checkout/CheckoutSuccessToast.tsx b/components/checkout/CheckoutSuccessToast.tsx deleted file mode 100644 index 4bd39ac..0000000 --- a/components/checkout/CheckoutSuccessToast.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useSearchParams } from 'next/navigation' -import { toast } from '@ciphera-net/ui' - -/** - * 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 -} From a4f2bebd10595082cbc31ded051009836b9a102b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 15:36:50 +0100 Subject: [PATCH 08/16] feat: enhance OrganizationSettings to display Tax IDs alongside business name for improved billing clarity --- components/settings/OrganizationSettings.tsx | 17 ++++++++++++++--- lib/api/billing.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 72d2b84..8c07fec 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -856,9 +856,20 @@ export default function OrganizationSettings() { Change plan - {subscription.business_name && ( -
- Billing for: {subscription.business_name} + {(subscription.business_name || (subscription.tax_ids && subscription.tax_ids.length > 0)) && ( +
+ {subscription.business_name && ( +
Billing for: {subscription.business_name}
+ )} + {subscription.tax_ids && subscription.tax_ids.length > 0 && ( +
+ Tax ID{subscription.tax_ids.length > 1 ? 's' : ''}:{' '} + {subscription.tax_ids.map((t) => { + const label = t.type === 'eu_vat' ? 'VAT' : t.type === 'us_ein' ? 'EIN' : t.type.replace(/_/g, ' ').toUpperCase() + return `${label} ${t.value}${t.country ? ` (${t.country})` : ''}` + }).join(', ')} +
+ )}
)} diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 43207e3..1c6cda4 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -1,5 +1,11 @@ import { API_URL } from './client' +export interface TaxID { + type: string + value: string + country?: string +} + export interface SubscriptionDetails { plan_id: string subscription_status: string @@ -15,6 +21,8 @@ export interface SubscriptionDetails { pageview_usage?: number /** Business name from Stripe Tax ID collection / business purchase flow (optional). */ business_name?: string + /** Tax IDs collected on the Stripe customer (VAT, EIN, etc.) for invoice verification. */ + tax_ids?: TaxID[] } async function billingFetch(endpoint: string, options: RequestInit = {}): Promise { From 53ed7493c6e0daec62e826bc1c2cc6699400ecf0 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 16:04:05 +0100 Subject: [PATCH 09/16] style: update download and view invoice links in OrganizationSettings for improved UI consistency and accessibility --- components/settings/OrganizationSettings.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 8c07fec..f6e9b65 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -974,14 +974,16 @@ export default function OrganizationSettings() { {invoice.invoice_pdf && ( - + className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors" title="Download PDF"> + + Download PDF )} {invoice.hosted_invoice_url && ( - + className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors" title="View invoice"> + + View invoice )}
From 99e9235f1fa433ed65feada9fdc685bedeb6dd34 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 16:07:17 +0100 Subject: [PATCH 10/16] feat: add resume subscription functionality in OrganizationSettings for improved user control over billing --- components/settings/OrganizationSettings.tsx | 53 ++++++++++++++------ lib/api/billing.ts | 7 +++ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index f6e9b65..6ea4cfc 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,7 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' -import { getSubscription, createPortalSession, getInvoices, cancelSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings' @@ -83,6 +83,7 @@ export default function OrganizationSettings() { const [isRedirectingToPortal, setIsRedirectingToPortal] = useState(false) const [cancelLoadingAction, setCancelLoadingAction] = useState<'period_end' | 'immediate' | null>(null) const [showCancelPrompt, setShowCancelPrompt] = useState(false) + const [isResuming, setIsResuming] = useState(false) const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [changePlanTierIndex, setChangePlanTierIndex] = useState(2) const [changePlanYearly, setChangePlanYearly] = useState(false) @@ -328,6 +329,19 @@ export default function OrganizationSettings() { } } + const handleResumeSubscription = async () => { + setIsResuming(true) + try { + await resumeSubscription() + toast.success('Subscription will continue. Cancellation has been undone.') + loadSubscription() + } catch (error: any) { + toast.error(getAuthErrorMessage(error) || error.message || 'Failed to resume subscription') + } finally { + setIsResuming(false) + } + } + const openChangePlanModal = () => { if (subscription?.pageview_limit != null && subscription.pageview_limit > 0) { setChangePlanTierIndex(getTierIndexForLimit(subscription.pageview_limit)) @@ -813,19 +827,30 @@ export default function OrganizationSettings() { {/* 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. -

+
+
+

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

+
+
)} diff --git a/lib/api/billing.ts b/lib/api/billing.ts index 1c6cda4..a337a4b 100644 --- a/lib/api/billing.ts +++ b/lib/api/billing.ts @@ -74,6 +74,13 @@ export async function cancelSubscription(params?: CancelSubscriptionParams): Pro }) } +/** Clears cancel_at_period_end so the subscription continues past the current period. */ +export async function resumeSubscription(): Promise<{ ok: boolean }> { + return await billingFetch<{ ok: boolean }>('/api/billing/resume', { + method: 'POST', + }) +} + export interface ChangePlanParams { plan_id: string interval: string From cc89a2797278afe7b9c14422636ead8868fa8886 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 16:18:00 +0100 Subject: [PATCH 11/16] feat: add invoice preview functionality in OrganizationSettings to enhance user experience with upcoming billing details --- components/settings/OrganizationSettings.tsx | 54 ++++++++++++++++++-- lib/api/billing.ts | 23 +++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 6ea4cfc..cf1d82f 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -16,7 +16,7 @@ import { OrganizationInvitation, Organization } from '@/lib/api/organization' -import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, createCheckoutSession, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { getSubscription, createPortalSession, getInvoices, cancelSubscription, resumeSubscription, changePlan, previewInvoice, createCheckoutSession, SubscriptionDetails, Invoice, PreviewInvoiceResult } from '@/lib/api/billing' import { TRAFFIC_TIERS, PLAN_ID_SOLO, getTierIndexForLimit, getLimitForTierIndex, getSitesLimitForPlan } from '@/lib/plans' import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings' @@ -87,6 +87,8 @@ export default function OrganizationSettings() { const [showChangePlanModal, setShowChangePlanModal] = useState(false) const [changePlanTierIndex, setChangePlanTierIndex] = useState(2) const [changePlanYearly, setChangePlanYearly] = useState(false) + const [invoicePreview, setInvoicePreview] = useState(null) + const [isLoadingPreview, setIsLoadingPreview] = useState(false) const [isChangingPlan, setIsChangingPlan] = useState(false) const [invoices, setInvoices] = useState([]) const [isLoadingInvoices, setIsLoadingInvoices] = useState(false) @@ -349,11 +351,27 @@ export default function OrganizationSettings() { setChangePlanTierIndex(2) } setChangePlanYearly(subscription?.billing_interval === 'year') + setInvoicePreview(null) setShowChangePlanModal(true) } const hasActiveSubscription = subscription?.subscription_status === 'active' || subscription?.subscription_status === 'trialing' + useEffect(() => { + if (!showChangePlanModal || !hasActiveSubscription) { + setInvoicePreview(null) + return + } + let cancelled = false + setIsLoadingPreview(true) + const interval = changePlanYearly ? 'year' : 'month' + const limit = getLimitForTierIndex(changePlanTierIndex) + previewInvoice({ plan_id: PLAN_ID_SOLO, interval, limit }) + .then((res) => { if (!cancelled) setInvoicePreview(res ?? null) }) + .finally(() => { if (!cancelled) setIsLoadingPreview(false) }) + return () => { cancelled = true } + }, [showChangePlanModal, hasActiveSubscription, changePlanTierIndex, changePlanYearly]) + const handleChangePlanSubmit = async () => { const interval = changePlanYearly ? 'year' : 'month' const limit = getLimitForTierIndex(changePlanTierIndex) @@ -925,8 +943,18 @@ export default function OrganizationSettings() {
{(() => { - 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' }) : '—' + const ts = subscription.next_invoice_period_end ?? subscription.current_period_end + const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null + const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0 + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + : '—' + const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency + ? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', { + style: 'currency', + currency: subscription.next_invoice_currency.toUpperCase(), + }) + : null + return amount && dateStr !== '—' ? `${dateStr} for ${amount}` : dateStr })()}
@@ -1426,6 +1454,26 @@ export default function OrganizationSettings() { + {hasActiveSubscription && ( +
+ {isLoadingPreview ? ( +
+ + Calculating next invoice… +
+ ) : invoicePreview ? ( +

+ Next invoice:{' '} + {(invoicePreview.amount_due / 100).toLocaleString('en-US', { + style: 'currency', + currency: invoicePreview.currency.toUpperCase(), + })}{' '} + on {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '} + (prorated) +

+ ) : null} +
+ )}
)}
From 5b1d3d8f0e38dbef49b7d23e2d67d99cc5ff311b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 20 Feb 2026 16:50:43 +0100 Subject: [PATCH 13/16] refactor: update PricingSection styles for improved layout and accessibility; enhance OrganizationSettings to handle plan changes and display past due notices --- components/PricingSection.tsx | 26 +++-- components/settings/OrganizationSettings.tsx | 116 ++++++++++++++++--- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 04d8b1f..37f5c40 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -219,10 +219,10 @@ export default function PricingSection() { transition={{ duration: 0.5 }} className="text-center mb-12" > -

+

Transparent Pricing

-

+

Scale with your traffic. No hidden fees.

@@ -232,11 +232,11 @@ export default function PricingSection() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.1 }} - className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-3xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20" + className="max-w-6xl mx-auto border border-neutral-200 dark:border-neutral-800 rounded-2xl bg-white/50 dark:bg-neutral-900/50 backdrop-blur-xl shadow-sm overflow-hidden mb-20" > {/* Top Toolbar */} -
+
10k @@ -252,7 +252,9 @@ export default function PricingSection() { step="1" value={sliderIndex} onChange={(e) => setSliderIndex(parseInt(e.target.value))} - className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange" + aria-label="Monthly pageview limit" + aria-valuetext={`${currentTraffic.label} pageviews per month`} + className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-neutral-700 accent-brand-orange focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2" />
@@ -260,10 +262,12 @@ export default function PricingSection() { Get 1 month free with yearly -
+
+
+ )} + {/* Cancel-at-period-end notice */} {subscription.cancel_at_period_end && (
@@ -886,9 +916,11 @@ export default function OrganizationSettings() { ? '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' + : subscription.subscription_status === 'past_due' + ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300' }`}> - {subscription.subscription_status === 'trialing' ? 'Trial' : (subscription.subscription_status || 'Free')} + {subscription.subscription_status === 'trialing' ? 'Trial' : subscription.subscription_status === 'past_due' ? 'Past Due' : (subscription.subscription_status || 'Free')} {subscription.billing_interval && ( @@ -918,7 +950,7 @@ export default function OrganizationSettings() { )} {/* Usage stats */} -
+
Sites
@@ -937,6 +969,22 @@ export default function OrganizationSettings() { ? `${subscription.pageview_usage.toLocaleString()} / ${subscription.pageview_limit.toLocaleString()}` : '—'}
+ {subscription.pageview_limit > 0 && typeof subscription.pageview_usage === 'number' && ( +
+
= 1 + ? 'bg-red-500' + : subscription.pageview_usage / subscription.pageview_limit >= 0.9 + ? 'bg-red-400' + : subscription.pageview_usage / subscription.pageview_limit >= 0.8 + ? 'bg-amber-400' + : 'bg-green-500' + }`} + style={{ width: `${Math.min(100, (subscription.pageview_usage / subscription.pageview_limit) * 100)}%` }} + /> +
+ )}
@@ -975,7 +1023,7 @@ export default function OrganizationSettings() { type="button" onClick={handleManageSubscription} disabled={isRedirectingToPortal} - 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" + 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 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:rounded" > Payment method & invoices @@ -985,7 +1033,7 @@ export default function OrganizationSettings() { @@ -994,7 +1042,7 @@ export default function OrganizationSettings() { {/* Invoice History */}
-

Recent invoices

+

Recent invoices

{isLoadingInvoices ? (
@@ -1028,14 +1076,14 @@ export default function OrganizationSettings() { {invoice.invoice_pdf && ( + className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="Download PDF"> Download PDF )} {invoice.hosted_invoice_url && ( + className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-brand-orange" title="View invoice"> View invoice @@ -1411,8 +1459,9 @@ export default function OrganizationSettings() { @@ -1421,6 +1470,41 @@ export default function OrganizationSettings() { Choose your pageview limit and billing interval. {hasActiveSubscription ? 'Your next invoice will reflect prorations.' : 'You’ll start a new subscription.'}

+
+ +
+ {([ + { id: PLAN_ID_SOLO, name: 'Solo', sites: '1 site' }, + { id: PLAN_ID_TEAM, name: 'Team', sites: 'Up to 5 sites' }, + { id: PLAN_ID_BUSINESS, name: 'Business', sites: 'Up to 10 sites' }, + ] as const).map((plan) => { + const isCurrentPlan = subscription?.plan_id === plan.id + const isSelected = changePlanId === plan.id + return ( + + ) + })} +
+