[PULSE-50] Organizations rename, remove Tools page, post-login profile fetch, welcome page UX #18
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, Suspense, useRef, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { AUTH_URL } from '@/lib/api/client'
|
||||
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
|
||||
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
|
||||
import { authMessageFromErrorType, type AuthErrorType } from '@/lib/utils/authErrors'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
@@ -23,13 +23,22 @@ function AuthCallbackContent() {
|
||||
if (!code || !codeVerifier) return
|
||||
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||
if (result.success && result.user) {
|
||||
login(result.user)
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
localStorage.removeItem('oauth_state')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
if (localStorage.getItem('pulse_pending_checkout')) {
|
||||
router.push('/welcome')
|
||||
} else {
|
||||
router.push('/')
|
||||
const raw = searchParams.get('returnTo') || '/'
|
||||
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
|
||||
router.push(safe)
|
||||
}
|
||||
} else {
|
||||
setError(authMessageFromErrorType(result.error as AuthErrorType))
|
||||
@@ -51,12 +60,20 @@ function AuthCallbackContent() {
|
||||
const handleDirectTokens = async () => {
|
||||
const result = await setSessionAction(token, refreshToken)
|
||||
if (result.success && result.user) {
|
||||
login(result.user)
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
|
||||
router.push('/welcome')
|
||||
} else {
|
||||
const returnTo = searchParams.get('returnTo') || '/'
|
||||
router.push(returnTo)
|
||||
const raw = searchParams.get('returnTo') || '/'
|
||||
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
|
||||
router.push(safe)
|
||||
}
|
||||
} else {
|
||||
setError(authMessageFromErrorType('invalid'))
|
||||
@@ -91,6 +108,7 @@ function AuthCallbackContent() {
|
||||
|
||||
const handleRetry = () => {
|
||||
setError(null)
|
||||
processedRef.current = false
|
||||
setIsRetrying(true)
|
||||
}
|
||||
|
||||
|
||||
129
app/integrations/nextjs/page.tsx
Normal file
129
app/integrations/nextjs/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function NextJsIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 dark:invert">
|
||||
<path d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64 64-28.7 64-64S99.3 0 64 0zm27.6 93.9c-.8.9-2.2 1-3.1.2L42.8 52.8V88c0 1.3-1.1 2.3-2.3 2.3h-7.4c-1.3 0-2.3-1.1-2.3-2.3V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1 0 1.9.6 2.2 1.5l48.6 44.8V40c0-1.3 1.1-2.3 2.3-2.3h7.4c1.3 0 2.3 1.1 2.3 2.3v48c0 1.3-1.1 2.3-2.3 2.3h-6.8c-.9 0-1.7-.5-2.1-1.3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
Next.js Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
The best way to add Pulse to your Next.js application is using the built-in <code>next/script</code> component.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Using App Router (Recommended)</h3>
|
||||
<p>
|
||||
Add the script to your root layout file (usually <code>app/layout.tsx</code> or <code>app/layout.js</code>).
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">app/layout.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import Script from 'next/script'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
data-domain="your-site.com"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Using Pages Router</h3>
|
||||
<p>
|
||||
If you are using the older Pages Router, add the script to your custom <code>_app.tsx</code> or <code>_document.tsx</code>.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">pages/_app.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import Script from 'next/script'
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
defer
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
data-domain="your-site.com"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Configuration Options</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>data-domain</strong>: The domain name you added to your Pulse dashboard (e.g., <code>example.com</code>).
|
||||
</li>
|
||||
<li>
|
||||
<strong>src</strong>: The URL of our tracking script: <code>https://pulse.ciphera.net/script.js</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>strategy</strong>: We recommend <code>afterInteractive</code> to ensure it loads quickly without blocking hydration.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
app/integrations/react/page.tsx
Normal file
119
app/integrations/react/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function ReactIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#61DAFB] fill-current">
|
||||
<path d="M64 10.6c18.4 0 34.6 5.8 44.6 14.8 6.4 5.8 10.2 12.8 10.2 20.6 0 21.6-28.6 41.2-64 41.2-1.6 0-3.2-.1-4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11 11.2-5.6 20.2-13 26.2-21.4-6.4-5.8-15.4-10-25.6-12.2-10.2-2.2-21.4-3.4-33-3.4-1.6 0-3.2.1-4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-33.4 62c-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8 13.6-1.6 26.2-5.4 37.4-11zm-15.2-16.6c-6.4-5.8-10.2-12.8-10.2-20.6 0-21.6 28.6-41.2 64-41.2 1.6 0 3.2.1 4.8.2 1.2-10.8 6.2-20.2 13.8-27.6 8.8-8.6 20.6-13.4 33.2-13.4 2.2 0 4.4.2 6.4.4-10.2 12.8-15.6 29.2-15.6 46.2 0 2.6.2 5.2.4 7.8-13.6 1.6-26.2 5.4-37.4 11-11.2 5.6-20.2 13-26.2 21.4 6.4 5.8 15.4 10 25.6 12.2 10.2 2.2 21.4 3.4 33 3.4 1.6 0 3.2-.1 4.8-.2-1.2 10.8-6.2 20.2-13.8 27.6-8.8 8.6-20.6 13.4-33.2 13.4-2.2 0-4.4-.2-6.4-.4 10.2-12.8 15.6-29.2 15.6-46.2 0-2.6-.2-5.2-.4-7.8z" />
|
||||
<circle cx="64" cy="64" r="10.6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
React Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
For standard React SPAs (Create React App, Vite, etc.), you can simply add the script tag to your <code>index.html</code>.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: index.html (Recommended)</h3>
|
||||
<p>
|
||||
The simplest way is to add the script tag directly to the <code><head></code> of your <code>index.html</code> file.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">public/index.html</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Pulse Analytics -->
|
||||
<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>
|
||||
|
||||
<title>My React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Programmatic Injection</h3>
|
||||
<p>
|
||||
If you need to load the script dynamically (e.g., only in production), you can use a <code>useEffect</code> hook in your main App component.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">src/App.tsx</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
// Only load in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const script = document.createElement('script')
|
||||
script.defer = true
|
||||
script.setAttribute('data-domain', 'your-site.com')
|
||||
script.src = 'https://pulse.ciphera.net/script.js'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>Hello World</h1>
|
||||
</div>
|
||||
)
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
app/integrations/vue/page.tsx
Normal file
113
app/integrations/vue/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function VueIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#4FC08D] fill-current">
|
||||
<path d="M82.8 24.6h27.8L64 103.4 17.4 24.6h27.8L64 59.4l18.8-34.8z" />
|
||||
<path d="M64 24.6H39L64 67.4l25-42.8H64z" fill="#35495E" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
Vue.js Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
Integrating Pulse with Vue.js is straightforward. You can add the script to your <code>index.html</code> file.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: index.html (Recommended)</h3>
|
||||
<p>
|
||||
Add the script tag to the <code><head></code> section of your <code>index.html</code> file. This works for both Vue 2 and Vue 3 projects created with Vue CLI or Vite.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">index.html</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Pulse Analytics -->
|
||||
<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>
|
||||
|
||||
<title>My Vue App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Nuxt.js</h3>
|
||||
<p>
|
||||
For Nuxt.js applications, you should add the script to your <code>nuxt.config.js</code> or <code>nuxt.config.ts</code> file.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">nuxt.config.ts</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`export default defineNuxtConfig({
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
{
|
||||
src: 'https://pulse.ciphera.net/script.js',
|
||||
defer: true,
|
||||
'data-domain': 'your-site.com'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
app/integrations/wordpress/page.tsx
Normal file
81
app/integrations/wordpress/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeftIcon } from '@ciphera-net/ui'
|
||||
|
||||
export default function WordPressIntegrationPage() {
|
||||
return (
|
||||
<div className="relative min-h-screen flex flex-col overflow-hidden selection:bg-brand-orange/20">
|
||||
{/* * --- ATMOSPHERE (Background) --- */}
|
||||
<div className="absolute inset-0 -z-10 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-brand-orange/10 rounded-full blur-[128px] opacity-60" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-neutral-500/10 dark:bg-neutral-400/10 rounded-full blur-[128px] opacity-40" />
|
||||
<div
|
||||
className="absolute inset-0 bg-grid-pattern opacity-[0.02] dark:opacity-[0.05]"
|
||||
style={{ maskImage: 'radial-gradient(ellipse at center, black 0%, transparent 70%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow w-full max-w-4xl mx-auto px-4 pt-12 pb-10 z-10">
|
||||
<Link
|
||||
href="/integrations"
|
||||
className="inline-flex items-center text-sm text-neutral-500 hover:text-brand-orange mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
Back to Integrations
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
||||
<svg viewBox="0 0 128 128" className="w-10 h-10 text-[#21759B] fill-current">
|
||||
<path d="M116.6 64c0-19.2-10.4-36-26-45.2l28.6 78.4c-1 3.2-2.2 6.2-3.6 9.2-11.4 12.4-27.8 20.2-46 20.2-6.2 0-12.2-.8-17.8-2.4l26.2-76.4c1.2.2 2.4.4 3.6.4 5.4 0 13.8-.8 13.8-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-6 .4l19 56.6 5.4-18c2.4-7.4 4.2-12.8 4.2-17.4 0-6-2.2-10.2-7.6-12.6-2.8-1.2-2.2-5.4 1.4-5.4h4.4zM64 121.2c-15.8 0-30.2-6.4-40.8-16.8L46.6 36.8c-2.8-.2-5.8-.4-5.8-.4-2.8-.2-2.4-4.4.4-4.2 0 0 8.4.8 13.6.8 5.4 0 13.6-.8 13.6-.8 2.8-.2 3.2 4 .4 4.2 0 0-2.8.2-5.8.4l18.2 54.4 10.6-31.8L64 121.2zM11.4 64c0 17 8.2 32.2 20.8 41.8L18.8 66.8c-.8-3.4-1.2-6.6-1.2-9.2 0-6.8 2.6-13 6.2-17.8C15.6 47.4 11.4 55.2 11.4 64zM64 6.8c16.2 0 30.8 6.8 41.4 17.6-1.4-.2-2.8-.2-4.2-.2-7.8 0-14.2 1.4-14.2 1.4-2.8.6-2.2 4.8.6 4.2 0 0 5-1 10.6-1 2.2 0 4.6.2 6.6.4L88.2 53 71.4 6.8h-7.4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-neutral-900 dark:text-white">
|
||||
WordPress Integration
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
<p className="lead text-xl text-neutral-600 dark:text-neutral-400">
|
||||
You can add Pulse to your WordPress site without installing any heavy plugins, or by using a simple code snippet plugin.
|
||||
</p>
|
||||
|
||||
<hr className="my-8 border-neutral-200 dark:border-neutral-800" />
|
||||
|
||||
<h3>Method 1: Using a Plugin (Easiest)</h3>
|
||||
<ol>
|
||||
<li>Install a plugin like "Insert Headers and Footers" (WPCode).</li>
|
||||
<li>Go to the plugin settings and find the "Scripts in Header" section.</li>
|
||||
<li>Paste the following code snippet:</li>
|
||||
</ol>
|
||||
|
||||
<div className="bg-[#1e1e1e] rounded-xl overflow-hidden border border-neutral-800 my-6">
|
||||
<div className="flex items-center px-4 py-2 bg-[#252526] border-b border-neutral-800">
|
||||
<span className="text-xs text-neutral-400 font-mono">Header Script</span>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<pre className="text-sm font-mono text-neutral-300">
|
||||
{`<script
|
||||
defer
|
||||
data-domain="your-site.com"
|
||||
src="https://pulse.ciphera.net/script.js"
|
||||
></script>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Method 2: Edit Theme Files (Advanced)</h3>
|
||||
<p>
|
||||
If you are comfortable editing your theme files, you can add the script directly to your <code>header.php</code> file.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Go to Appearance > Theme File Editor.</li>
|
||||
<li>Select <code>header.php</code> from the right sidebar.</li>
|
||||
<li>Paste the script tag just before the closing <code></head></code> tag.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -73,14 +73,6 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
Features
|
||||
</Link>
|
||||
)}
|
||||
{auth.user && (
|
||||
<Link
|
||||
href="/tools"
|
||||
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
|
||||
>
|
||||
Tools
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import UtmBuilder from '@/components/tools/UtmBuilder'
|
||||
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-10 px-4">
|
||||
<h1 className="text-2xl font-bold mb-6 text-neutral-900 dark:text-white">Tools</h1>
|
||||
<div className="bg-white dark:bg-neutral-900 p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800">
|
||||
<h2 className="text-2xl font-bold mb-4 text-neutral-900 dark:text-white">UTM Campaign Builder</h2>
|
||||
<UtmBuilder />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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'
|
||||
import apiRequest from '@/lib/api/client'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
import {
|
||||
trackWelcomeStepView,
|
||||
@@ -149,7 +150,13 @@ function WelcomeContent() {
|
||||
const { access_token } = await switchContext(org.organization_id)
|
||||
const result = await setSessionAction(access_token)
|
||||
if (result.success && result.user) {
|
||||
login(result.user)
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
router.refresh()
|
||||
trackWelcomeWorkspaceSelected()
|
||||
setStep(3)
|
||||
@@ -180,7 +187,13 @@ function WelcomeContent() {
|
||||
const { access_token } = await switchContext(org.id)
|
||||
const result = await setSessionAction(access_token)
|
||||
if (result.success && result.user) {
|
||||
login(result.user)
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
router.refresh()
|
||||
}
|
||||
trackWelcomeWorkspaceCreated(!!(typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')))
|
||||
@@ -302,11 +315,11 @@ function WelcomeContent() {
|
||||
}, [step, siteName, siteDomain])
|
||||
|
||||
if (orgLoading && step === 2) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your organization..." />
|
||||
}
|
||||
|
||||
if (switchingOrgId) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching workspace..." />
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching organization..." />
|
||||
}
|
||||
|
||||
if (redirectingCheckout || (planLoading && step === 3)) {
|
||||
@@ -357,50 +370,73 @@ function WelcomeContent() {
|
||||
className={cardClass}
|
||||
>
|
||||
{orgsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Loading your workspaces...</p>
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto h-8 w-8 border-2 border-brand-orange border-t-transparent rounded-full animate-spin mb-4" />
|
||||
<p className="text-neutral-600 dark:text-neutral-400">Loading your organizations...</p>
|
||||
</div>
|
||||
) : organizations && organizations.length > 0 ? (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||
<BarChartIcon className="h-7 w-7" />
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-orange/20 to-brand-orange/5 text-brand-orange mb-5 shadow-sm">
|
||||
<BarChartIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white">
|
||||
Choose your organization
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Continue with an existing organization or create a new one.
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 max-w-sm mx-auto">
|
||||
Continue with an existing one or create a new organization.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 mb-6">
|
||||
{organizations.map((org) => (
|
||||
<button
|
||||
key={org.organization_id}
|
||||
type="button"
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
disabled={!!switchingOrgId}
|
||||
className="w-full flex items-center justify-between gap-3 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:border-brand-orange/50 px-4 py-3 text-left transition-colors disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{org.organization_name || 'Organization'}
|
||||
</span>
|
||||
{user?.org_id === org.organization_id && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Current</span>
|
||||
)}
|
||||
<ArrowRightIcon className="h-4 w-4 text-neutral-400 flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-2.5 mb-6">
|
||||
{organizations.map((org, index) => {
|
||||
const isCurrent = user?.org_id === org.organization_id
|
||||
const initial = (org.organization_name || 'O').charAt(0).toUpperCase()
|
||||
return (
|
||||
<motion.button
|
||||
key={org.organization_id}
|
||||
type="button"
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
disabled={!!switchingOrgId}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.04, duration: 0.2 }}
|
||||
className={`w-full flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-all duration-200 disabled:opacity-60 ${
|
||||
isCurrent
|
||||
? 'border-brand-orange/60 bg-brand-orange/5 dark:bg-brand-orange/10 shadow-sm'
|
||||
: 'border-neutral-200 dark:border-neutral-700 bg-neutral-50/80 dark:bg-neutral-800/50 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:border-neutral-300 dark:hover:border-neutral-600 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sm font-semibold ${
|
||||
isCurrent
|
||||
? 'bg-brand-orange/20 text-brand-orange dark:bg-brand-orange/30'
|
||||
: 'bg-neutral-200/80 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-neutral-900 dark:text-white truncate">
|
||||
{org.organization_name || 'Organization'}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs font-medium text-brand-orange shrink-0">Current</span>
|
||||
)}
|
||||
<ArrowRightIcon className={`h-4 w-4 shrink-0 ${isCurrent ? 'text-brand-orange' : 'text-neutral-400'}`} />
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full border border-dashed border-neutral-300 dark:border-neutral-600 hover:border-brand-orange/50 hover:bg-brand-orange/5 dark:hover:bg-brand-orange/10"
|
||||
onClick={handleCreateNewOrganization}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create a new organization
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={handleCreateNewOrganization}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create a new organization
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
@@ -484,7 +520,7 @@ function WelcomeContent() {
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Used in your organization URL.
|
||||
</p>
|
||||
</div>
|
||||
@@ -651,9 +687,15 @@ function WelcomeContent() {
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={siteLoading || !siteName.trim() || !siteDomain.trim()}
|
||||
isLoading={siteLoading}
|
||||
>
|
||||
Add site
|
||||
{siteLoading ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin mr-2" aria-hidden />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
'Add site'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -53,6 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
router.refresh()
|
||||
// * Fetch full profile (including display_name) so header shows correct name without page refresh
|
||||
apiRequest<User>('/auth/user/me')
|
||||
.then((fullProfile) => {
|
||||
setUser((prev) => {
|
||||
const merged = {
|
||||
...fullProfile,
|
||||
org_id: prev?.org_id ?? fullProfile.org_id,
|
||||
role: prev?.role ?? fullProfile.role,
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(merged))
|
||||
return merged
|
||||
})
|
||||
})
|
||||
.catch((e) => console.error('Failed to fetch full profile after login', e))
|
||||
}
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@@ -153,12 +167,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
// * Update session cookie
|
||||
const result = await setSessionAction(access_token)
|
||||
if (result.success && result.user) {
|
||||
setUser(result.user)
|
||||
localStorage.setItem('user', JSON.stringify(result.user))
|
||||
|
||||
// * Force hard reload to ensure browser sends new cookie to backend
|
||||
// * router.refresh() is not enough for Client Components fetching data immediately
|
||||
// window.location.reload()
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
setUser(merged)
|
||||
localStorage.setItem('user', JSON.stringify(merged))
|
||||
} catch {
|
||||
setUser(result.user)
|
||||
localStorage.setItem('user', JSON.stringify(result.user))
|
||||
}
|
||||
router.refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user