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",