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