feat: enhance welcome page with organization selection and workspace switching functionality
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
getUserOrganizations,
|
getUserOrganizations,
|
||||||
switchContext,
|
switchContext,
|
||||||
type Organization,
|
type Organization,
|
||||||
|
type OrganizationMember,
|
||||||
} from '@/lib/api/organization'
|
} from '@/lib/api/organization'
|
||||||
import { createCheckoutSession } from '@/lib/api/billing'
|
import { createCheckoutSession } from '@/lib/api/billing'
|
||||||
import { createSite, type Site } from '@/lib/api/sites'
|
import { createSite, type Site } from '@/lib/api/sites'
|
||||||
@@ -22,6 +23,7 @@ import { useAuth } from '@/lib/auth/context'
|
|||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import {
|
import {
|
||||||
trackWelcomeStepView,
|
trackWelcomeStepView,
|
||||||
|
trackWelcomeWorkspaceSelected,
|
||||||
trackWelcomeWorkspaceCreated,
|
trackWelcomeWorkspaceCreated,
|
||||||
trackWelcomePlanContinue,
|
trackWelcomePlanContinue,
|
||||||
trackWelcomePlanSkip,
|
trackWelcomePlanSkip,
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
BarChartIcon,
|
BarChartIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
|
PlusIcon,
|
||||||
} from '@ciphera-net/ui'
|
} from '@ciphera-net/ui'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
@@ -97,6 +100,10 @@ function WelcomeContent() {
|
|||||||
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
const [hadPendingCheckout, setHadPendingCheckout] = useState<boolean | null>(null)
|
||||||
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
const [dismissedPendingCheckout, setDismissedPendingCheckout] = useState(false)
|
||||||
|
|
||||||
|
const [organizations, setOrganizations] = useState<OrganizationMember[] | null>(null)
|
||||||
|
const [orgsLoading, setOrgsLoading] = useState(false)
|
||||||
|
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||||
|
|
||||||
const setStep = useCallback(
|
const setStep = useCallback(
|
||||||
(next: number) => {
|
(next: number) => {
|
||||||
const s = Math.min(Math.max(1, next), TOTAL_STEPS)
|
const s = Math.min(Math.max(1, next), TOTAL_STEPS)
|
||||||
@@ -113,22 +120,45 @@ function WelcomeContent() {
|
|||||||
if (stepFromUrl !== step) setStepState(stepFromUrl)
|
if (stepFromUrl !== step) setStepState(stepFromUrl)
|
||||||
}, [stepParam, step])
|
}, [stepParam, step])
|
||||||
|
|
||||||
// * If user already has orgs and no pending checkout, send to dashboard (avoid re-doing wizard)
|
// * Fetch organizations when on step 1 so we can show "Choose workspace" when user has orgs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || step !== 1) return
|
if (!user || step !== 1) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
setOrgsLoading(true)
|
||||||
getUserOrganizations()
|
getUserOrganizations()
|
||||||
.then((orgs) => {
|
.then((orgs) => {
|
||||||
if (cancelled || orgs.length === 0) return
|
if (!cancelled) setOrganizations(orgs || [])
|
||||||
if (!localStorage.getItem('pulse_pending_checkout')) {
|
})
|
||||||
router.replace('/')
|
.catch(() => {
|
||||||
}
|
if (!cancelled) setOrganizations([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setOrgsLoading(false)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [user, step, router])
|
}, [user, step])
|
||||||
|
|
||||||
|
const handleSelectWorkspace = async (org: OrganizationMember) => {
|
||||||
|
setSwitchingOrgId(org.organization_id)
|
||||||
|
try {
|
||||||
|
const { access_token } = await switchContext(org.organization_id)
|
||||||
|
const result = await setSessionAction(access_token)
|
||||||
|
if (result.success && result.user) {
|
||||||
|
login(result.user)
|
||||||
|
router.refresh()
|
||||||
|
trackWelcomeWorkspaceSelected()
|
||||||
|
setStep(3)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getAuthErrorMessage(err) || 'Failed to switch workspace')
|
||||||
|
} finally {
|
||||||
|
setSwitchingOrgId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNewWorkspace = () => setStep(2)
|
||||||
|
|
||||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const val = e.target.value
|
const val = e.target.value
|
||||||
@@ -272,6 +302,10 @@ function WelcomeContent() {
|
|||||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Creating your workspace..." />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (switchingOrgId) {
|
||||||
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Switching workspace..." />
|
||||||
|
}
|
||||||
|
|
||||||
if (redirectingCheckout || (planLoading && step === 3)) {
|
if (redirectingCheckout || (planLoading && step === 3)) {
|
||||||
return (
|
return (
|
||||||
<LoadingOverlay
|
<LoadingOverlay
|
||||||
@@ -319,26 +353,74 @@ function WelcomeContent() {
|
|||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className={cardClass}
|
className={cardClass}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
{orgsLoading ? (
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-6">
|
<div className="text-center py-8">
|
||||||
<ZapIcon className="h-7 w-7" />
|
<p className="text-neutral-600 dark:text-neutral-400">Loading your workspaces...</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
) : organizations && organizations.length > 0 ? (
|
||||||
Welcome to Pulse
|
<>
|
||||||
</h1>
|
<div className="text-center mb-6">
|
||||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-4">
|
||||||
Privacy-first analytics in a few steps. No credit card required to start.
|
<BarChartIcon className="h-7 w-7" />
|
||||||
</p>
|
</div>
|
||||||
<Button
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-white">
|
||||||
type="button"
|
Choose your workspace
|
||||||
variant="primary"
|
</h2>
|
||||||
className="mt-8 w-full sm:w-auto min-w-[180px]"
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
onClick={() => setStep(2)}
|
Continue with an existing workspace or create a new one.
|
||||||
>
|
</p>
|
||||||
Get started
|
</div>
|
||||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
<div className="space-y-2 mb-6">
|
||||||
</Button>
|
{organizations.map((org) => (
|
||||||
</div>
|
<button
|
||||||
|
key={org.organization_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectWorkspace(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"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{org.organization_name || 'Workspace'}
|
||||||
|
</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>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCreateNewWorkspace}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
Create a new workspace
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-brand-orange/10 text-brand-orange mb-6">
|
||||||
|
<ZapIcon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
Welcome to Pulse
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
|
Privacy-first analytics in a few steps. No credit card required to start.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
className="mt-8 w-full sm:w-auto min-w-[180px]"
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
export type WelcomeEventName =
|
export type WelcomeEventName =
|
||||||
| 'welcome_step_view'
|
| 'welcome_step_view'
|
||||||
|
| 'welcome_workspace_selected'
|
||||||
| 'welcome_workspace_created'
|
| 'welcome_workspace_created'
|
||||||
| 'welcome_plan_continue'
|
| 'welcome_plan_continue'
|
||||||
| 'welcome_plan_skip'
|
| 'welcome_plan_skip'
|
||||||
@@ -47,6 +48,10 @@ export function trackWelcomeStepView(step: number) {
|
|||||||
emit('welcome_step_view', { step })
|
emit('welcome_step_view', { step })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function trackWelcomeWorkspaceSelected() {
|
||||||
|
emit('welcome_workspace_selected')
|
||||||
|
}
|
||||||
|
|
||||||
export function trackWelcomeWorkspaceCreated(hadPendingCheckout: boolean) {
|
export function trackWelcomeWorkspaceCreated(hadPendingCheckout: boolean) {
|
||||||
emit('welcome_workspace_created', { had_pending_checkout: hadPendingCheckout })
|
emit('welcome_workspace_created', { had_pending_checkout: hadPendingCheckout })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user