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