feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
372
frontend/src/pages/AcceptInvitePage.tsx
Normal file
372
frontend/src/pages/AcceptInvitePage.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { inviteApi, type AccountInviteLookup } from '@/api/invite'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { buildOAuthAuthorizeUrl } from './RegisterPage'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { encodeOAuthState } from '@/lib/oauthState'
|
||||
|
||||
function randomCsrf(): string {
|
||||
const buf = new Uint8Array(16)
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(buf)
|
||||
} else {
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
type LookupState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ok'; data: AccountInviteLookup }
|
||||
| { status: 'invalid' }
|
||||
| { status: 'missing-code' }
|
||||
|
||||
export function AcceptInvitePage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { register, isLoading, error, clearError } = useAuthStore()
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const code = useMemo(() => {
|
||||
const search = new URLSearchParams(location.search)
|
||||
return (search.get('code') || '').trim()
|
||||
}, [location.search])
|
||||
|
||||
const [lookup, setLookup] = useState<LookupState>(
|
||||
code ? { status: 'loading' } : { status: 'missing-code' },
|
||||
)
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
|
||||
setLookup({ status: 'missing-code' })
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLookup({ status: 'loading' })
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await inviteApi.lookupAccountInvite(code)
|
||||
if (cancelled) return
|
||||
setLookup({ status: 'ok', data })
|
||||
} catch {
|
||||
if (cancelled) return
|
||||
// Any error — 404, 410, network — collapses to the same "ask the
|
||||
// inviter to resend" UX. Anti-enumeration is enforced server-side.
|
||||
setLookup({ status: 'invalid' })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code])
|
||||
|
||||
const googleAvailable = appConfig.oauth_providers.includes('google')
|
||||
const microsoftAvailable = appConfig.oauth_providers.includes('microsoft')
|
||||
|
||||
const handleOAuth = (provider: 'google' | 'microsoft') => {
|
||||
if (lookup.status !== 'ok') return
|
||||
const csrf = randomCsrf()
|
||||
try {
|
||||
sessionStorage.setItem('rf-oauth-state', csrf)
|
||||
} catch {
|
||||
// ignore — non-fatal
|
||||
}
|
||||
const stateValue = encodeOAuthState({
|
||||
csrf,
|
||||
accountInviteCode: code,
|
||||
invitedEmail: lookup.data.invited_email,
|
||||
})
|
||||
const url = buildOAuthAuthorizeUrl(provider, stateValue)
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLocalError('')
|
||||
clearError()
|
||||
|
||||
if (lookup.status !== 'ok') return
|
||||
|
||||
if (!name || !password) {
|
||||
setLocalError('Please fill in all fields')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (password.length < 10) {
|
||||
setLocalError('Password must be at least 10 characters')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await register({
|
||||
email: lookup.data.invited_email,
|
||||
password,
|
||||
name,
|
||||
account_invite_code: code,
|
||||
})
|
||||
// Invitees skip the welcome wizard — they're joining an existing shop.
|
||||
// The `?welcome=teammate` marker is decoded by the dashboard in Task 41
|
||||
// to surface the "Welcome to {account_name}" toast and pre-checked
|
||||
// checklist items.
|
||||
navigate('/?welcome=teammate', { replace: true })
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta
|
||||
title="Join your team on ResolutionFlow"
|
||||
description="Accept an invite to join an existing ResolutionFlow account"
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
|
||||
<div className="relative w-full max-w-md space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
||||
ResolutionFlow
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{lookup.status === 'loading' && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Loading invite…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(lookup.status === 'invalid' || lookup.status === 'missing-code') && (
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
This invite is no longer valid
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lookup.status === 'missing-code'
|
||||
? 'The invite link is missing its code.'
|
||||
: 'This invite has expired, been used, or been revoked.'}{' '}
|
||||
Ask the person who invited you to resend it.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:?subject=Please%20resend%20my%20ResolutionFlow%20invite&body=Hi%2C%20could%20you%20resend%20my%20ResolutionFlow%20invite%3F%20The%20link%20I%20got%20is%20no%20longer%20valid.%20Thanks!"
|
||||
className={cn(
|
||||
'inline-block rounded-xl px-4 py-2 text-sm font-semibold btn-press',
|
||||
'bg-primary text-white hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Email your inviter
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-foreground hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lookup.status === 'ok' && (
|
||||
<>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
Join <span className="font-semibold">{lookup.data.account_name}</span> on
|
||||
ResolutionFlow
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{lookup.data.inviter_name} invited you as {lookup.data.role}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="block text-sm font-medium text-foreground">
|
||||
Joining as
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground',
|
||||
)}
|
||||
data-testid="invited-email"
|
||||
>
|
||||
{lookup.data.invited_email}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The invite is locked to this email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(googleAvailable || microsoftAvailable) && (
|
||||
<div className="space-y-3">
|
||||
{googleAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('google')}
|
||||
data-testid="oauth-google"
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
)}
|
||||
{microsoftAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('microsoft')}
|
||||
data-testid="oauth-microsoft"
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
Continue with Microsoft
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-wider">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
or set a password
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Must be at least 10 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="accept-submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-white hover:brightness-110',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Joining…' : `Join ${lookup.data.account_name}`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-medium text-foreground hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AcceptInvitePage
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Crown,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
|
||||
title="Profile"
|
||||
description="Your name, email, and personal preferences"
|
||||
/>
|
||||
<SettingsRow
|
||||
to="/account/billing"
|
||||
icon={<CreditCard className="h-4 w-4" />}
|
||||
title="Billing"
|
||||
description="Subscription, payment method, and invoices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAccountOwner && (
|
||||
|
||||
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
396
frontend/src/pages/ContactSalesPage.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { salesApi, type SalesLeadSource } from '@/api/sales'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Source detection
|
||||
*
|
||||
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
|
||||
* by document.referrer: visitors landing on /contact-sales after viewing the
|
||||
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
|
||||
* `register_footer` is reserved for the (future) sign-up footer CTA.
|
||||
*
|
||||
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
|
||||
* ------------------------------------------------------------------------- */
|
||||
function detectSource(): SalesLeadSource {
|
||||
if (typeof document === 'undefined') return 'landing_page'
|
||||
const ref = document.referrer || ''
|
||||
if (ref.includes('/pricing')) return 'pricing_page'
|
||||
return 'landing_page'
|
||||
}
|
||||
|
||||
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: 'Select team size' },
|
||||
{ value: '1-2', label: '1–2' },
|
||||
{ value: '3-5', label: '3–5' },
|
||||
{ value: '6-10', label: '6–10' },
|
||||
{ value: '11-25', label: '11–25' },
|
||||
{ value: '26+', label: 'More than 26' },
|
||||
]
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
team_size: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const INITIAL: FormState = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
team_size: '',
|
||||
message: '',
|
||||
}
|
||||
|
||||
function ContactSalesNotFound() {
|
||||
return (
|
||||
<div
|
||||
data-testid="contact-sales-not-found"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContactSalesPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const calendlyUrl = useMemo(() => {
|
||||
const raw = import.meta.env.VITE_CALENDLY_URL
|
||||
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
|
||||
}, [])
|
||||
|
||||
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
|
||||
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Page not found" />
|
||||
<ContactSalesNotFound />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange =
|
||||
(field: keyof FormState) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
|
||||
const name = form.name.trim()
|
||||
const email = form.email.trim()
|
||||
const company = form.company.trim()
|
||||
|
||||
if (!name || !email || !company) {
|
||||
setError('Please fill in name, work email, and company.')
|
||||
return
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Enter a valid work email address.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await salesApi.createLead({
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
team_size: form.team_size || undefined,
|
||||
message: form.message.trim() || undefined,
|
||||
source: detectSource(),
|
||||
})
|
||||
setSubmitted(true)
|
||||
} catch {
|
||||
// The backend may rate-limit (429) or reject for validation; surface a
|
||||
// generic message and allow retry. Don't leak internal errors.
|
||||
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<PageMeta
|
||||
title="Talk to Sales"
|
||||
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
|
||||
/>
|
||||
|
||||
<main
|
||||
className="landing-main"
|
||||
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '640px',
|
||||
margin: '0 auto',
|
||||
padding: '3rem 1.5rem 1.5rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 0.75rem',
|
||||
}}
|
||||
>
|
||||
Talk to Sales
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.05rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Tell us about your MSP. We’ll reach out within 1 business day.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
padding: '1rem 1.5rem 3rem',
|
||||
}}
|
||||
>
|
||||
{submitted ? (
|
||||
<div
|
||||
data-testid="contact-sales-confirmation"
|
||||
style={{
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem 1.75rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 0.75rem',
|
||||
}}
|
||||
>
|
||||
Thanks — we’ll reach out within 1 business day.
|
||||
</h2>
|
||||
{calendlyUrl && (
|
||||
<div data-testid="calendly-block">
|
||||
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
|
||||
Want to skip ahead?
|
||||
</p>
|
||||
<a
|
||||
data-testid="calendly-link"
|
||||
href={calendlyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.65rem 1.25rem',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Book a time
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
data-testid="contact-sales-form"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.75rem',
|
||||
}}
|
||||
>
|
||||
<Field label="Name" htmlFor="cs-name" required>
|
||||
<input
|
||||
id="cs-name"
|
||||
data-testid="cs-name"
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={handleChange('name')}
|
||||
autoComplete="name"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Work email" htmlFor="cs-email" required>
|
||||
<input
|
||||
id="cs-email"
|
||||
data-testid="cs-email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={handleChange('email')}
|
||||
autoComplete="email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Company" htmlFor="cs-company" required>
|
||||
<input
|
||||
id="cs-company"
|
||||
data-testid="cs-company"
|
||||
type="text"
|
||||
required
|
||||
value={form.company}
|
||||
onChange={handleChange('company')}
|
||||
autoComplete="organization"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Team size" htmlFor="cs-team-size">
|
||||
<select
|
||||
id="cs-team-size"
|
||||
data-testid="cs-team-size"
|
||||
value={form.team_size}
|
||||
onChange={handleChange('team_size')}
|
||||
style={inputStyle}
|
||||
>
|
||||
{TEAM_SIZE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="What brought you here?" htmlFor="cs-message">
|
||||
<textarea
|
||||
id="cs-message"
|
||||
data-testid="cs-message"
|
||||
rows={4}
|
||||
value={form.message}
|
||||
onChange={handleChange('message')}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="cs-error"
|
||||
style={{ color: '#f87171', fontSize: '0.9rem' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="cs-submit"
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
opacity: submitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor: string
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '0.6rem 0.75rem',
|
||||
background: 'var(--lp-bg-alt, #1a1d26)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '0.95rem',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
export default ContactSalesPage
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
q: 'How is this different from just using ChatGPT?',
|
||||
@@ -29,11 +28,9 @@ const FAQ_ITEMS = [
|
||||
]
|
||||
|
||||
export default function LandingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [navScrolled, setNavScrolled] = useState(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [betaEmail, setBetaEmail] = useState('')
|
||||
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
const [betaError, setBetaError] = useState('')
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -71,32 +68,6 @@ export default function LandingPage() {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = betaEmail.trim()
|
||||
if (!trimmed || betaStatus === 'sending') return
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
||||
setBetaStatus('error')
|
||||
setBetaError('Enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setBetaStatus('sending')
|
||||
setBetaError('')
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: trimmed }),
|
||||
})
|
||||
if (!resp.ok) throw new Error('Signup failed')
|
||||
setBetaStatus('sent')
|
||||
setBetaEmail('')
|
||||
} catch {
|
||||
setBetaStatus('error')
|
||||
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
|
||||
}
|
||||
}, [betaEmail, betaStatus])
|
||||
|
||||
const toggleFaq = (index: number) => {
|
||||
setOpenFaq(prev => prev === index ? null : index)
|
||||
}
|
||||
@@ -174,6 +145,15 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
||||
{appConfig.self_serve_enabled && (
|
||||
<Link
|
||||
to="/pricing"
|
||||
className="landing-btn-hero-secondary"
|
||||
data-testid="landing-see-pricing"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
)}
|
||||
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
||||
</div>
|
||||
<p className="landing-hero-credibility">
|
||||
@@ -422,34 +402,10 @@ export default function LandingPage() {
|
||||
<section className="landing-cta-section landing-reveal">
|
||||
<div className="landing-cta-inner">
|
||||
<h2>Ready to stop writing ticket notes?</h2>
|
||||
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
|
||||
<div className="landing-cta-input-wrap">
|
||||
<input
|
||||
type="email"
|
||||
className="landing-cta-email-input"
|
||||
placeholder="you@yourmsp.com"
|
||||
value={betaEmail}
|
||||
onChange={e => {
|
||||
setBetaEmail(e.target.value)
|
||||
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
|
||||
}}
|
||||
required
|
||||
aria-describedby="beta-status"
|
||||
/>
|
||||
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
|
||||
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="beta-status" aria-live="polite" className="landing-cta-status">
|
||||
{betaStatus === 'sent' && (
|
||||
<p className="landing-cta-success">You're in. We'll send beta access details soon.</p>
|
||||
)}
|
||||
{betaStatus === 'error' && betaError && (
|
||||
<p className="landing-cta-error">{betaError}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
|
||||
<div className="landing-cta-actions">
|
||||
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
|
||||
</div>
|
||||
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
196
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
196
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { decodeOAuthState } from '@/lib/oauthState'
|
||||
|
||||
type Provider = 'google' | 'microsoft'
|
||||
|
||||
/**
|
||||
* Handles the OAuth redirect leg of the full-page Google / Microsoft sign-in
|
||||
* flow. Mounted at /auth/google/callback and /auth/microsoft/callback as
|
||||
* public routes (NOT inside ProtectedRoute).
|
||||
*
|
||||
* Reads `?code=...` from the URL, POSTs it to the backend, stores the
|
||||
* returned tokens, hydrates the auth store via fetchUser(), and redirects.
|
||||
*
|
||||
* Two state forms are supported:
|
||||
* - Legacy: `state` is a raw random hex string. CSRF check against
|
||||
* sessionStorage('rf-oauth-state').
|
||||
* - /accept-invite: `state` is base64url(JSON({csrf, accountInviteCode,
|
||||
* invitedEmail})). The CSRF value is compared against
|
||||
* sessionStorage('rf-oauth-state'); the invite fields are forwarded to
|
||||
* the backend so the new user joins the invited account instead of
|
||||
* getting a personal one.
|
||||
*/
|
||||
export function OAuthCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { setTokens, fetchUser } = useAuthStore()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Derive provider purely from URL pathname — routes are static
|
||||
// (/auth/google/callback and /auth/microsoft/callback), so there is
|
||||
// no `:provider` route param to read.
|
||||
const provider: Provider = location.pathname.includes('/microsoft/')
|
||||
? 'microsoft'
|
||||
: 'google'
|
||||
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(location.search)
|
||||
const code = search.get('code')
|
||||
const oauthError = search.get('error')
|
||||
const returnedState = search.get('state')
|
||||
|
||||
// CSRF: validate state round-trip against the value RegisterPage /
|
||||
// AcceptInvitePage stashed in sessionStorage before redirecting to the
|
||||
// provider. Always clear the stored value so a stale entry can't be
|
||||
// re-used by a later attempt.
|
||||
let storedState: string | null = null
|
||||
try {
|
||||
storedState = sessionStorage.getItem('rf-oauth-state')
|
||||
sessionStorage.removeItem('rf-oauth-state')
|
||||
} catch {
|
||||
// sessionStorage may be unavailable (private mode, etc.) — treat as missing.
|
||||
storedState = null
|
||||
}
|
||||
|
||||
if (oauthError) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
|
||||
setError(`OAuth error: ${oauthError}`)
|
||||
return
|
||||
}
|
||||
if (!storedState || !returnedState) {
|
||||
setError('Invalid OAuth state — possible CSRF. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
// The decoded form encodes the original CSRF value; compare that.
|
||||
const decoded = decodeOAuthState(returnedState)
|
||||
const matchesCsrf = decoded
|
||||
? decoded.csrf === storedState
|
||||
: returnedState === storedState
|
||||
if (!matchesCsrf) {
|
||||
setError('Invalid OAuth state — possible CSRF. Please try again.')
|
||||
return
|
||||
}
|
||||
if (!code) {
|
||||
setError('Missing authorization code')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const inviteOptions = decoded
|
||||
? {
|
||||
accountInviteCode: decoded.accountInviteCode,
|
||||
invitedEmail: decoded.invitedEmail,
|
||||
}
|
||||
: undefined
|
||||
const result =
|
||||
provider === 'microsoft'
|
||||
? await authApi.microsoftCallback(code, inviteOptions)
|
||||
: await authApi.googleCallback(code, inviteOptions)
|
||||
if (cancelled) return
|
||||
|
||||
// Persist tokens for apiClient interceptor + zustand store.
|
||||
localStorage.setItem('access_token', result.access_token)
|
||||
localStorage.setItem('refresh_token', result.refresh_token)
|
||||
setTokens({
|
||||
access_token: result.access_token,
|
||||
refresh_token: result.refresh_token,
|
||||
token_type: result.token_type || 'bearer',
|
||||
})
|
||||
// Hydrate user / account / subscription.
|
||||
await fetchUser()
|
||||
if (cancelled) return
|
||||
|
||||
// Invitee path lands on the dashboard with the teammate-welcome
|
||||
// marker; new self-serve owners go to the welcome wizard; returning
|
||||
// users to /.
|
||||
let dest = '/'
|
||||
if (decoded?.accountInviteCode) {
|
||||
dest = '/?welcome=teammate'
|
||||
} else if (result.is_new_user) {
|
||||
dest = '/welcome'
|
||||
}
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return
|
||||
const axiosErr = err as {
|
||||
response?: { data?: { detail?: unknown } }
|
||||
}
|
||||
const detail = axiosErr.response?.data?.detail
|
||||
// Backend returns { error: "invite_email_mismatch" } etc.
|
||||
let msg: string | null = null
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail
|
||||
} else if (
|
||||
detail &&
|
||||
typeof detail === 'object' &&
|
||||
'error' in (detail as Record<string, unknown>)
|
||||
) {
|
||||
const code = (detail as { error: string }).error
|
||||
if (code === 'invite_email_mismatch') {
|
||||
msg =
|
||||
'The email on your provider account does not match the invited email. ' +
|
||||
'Sign in with the matching account, or ask your inviter to resend.'
|
||||
} else if (code === 'invite_invalid_or_expired_or_revoked') {
|
||||
msg = 'This invite is no longer valid. Ask your inviter to resend.'
|
||||
} else {
|
||||
msg = code
|
||||
}
|
||||
}
|
||||
msg =
|
||||
msg ||
|
||||
(err instanceof Error ? err.message : 'Sign-in failed')
|
||||
setError(msg)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [location.search, provider, setTokens, fetchUser, navigate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Signing you in" description="Completing OAuth sign-in" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
<div className="relative w-full max-w-md space-y-6 text-center">
|
||||
<div className="flex justify-center">
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
{error ? (
|
||||
<>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Sign-in failed
|
||||
</h1>
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Signing you in…
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OAuthCallbackPage
|
||||
439
frontend/src/pages/PricingPage.tsx
Normal file
439
frontend/src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* v1 hardcoded comparison table
|
||||
*
|
||||
* The marketing /pricing page surfaces a small "what's in each plan" table.
|
||||
* Long-term, the source of truth for "plan X has feature Y" should be a
|
||||
* server-side feature-flag mapping (likely keyed off feature_flag.display_name
|
||||
* + plan_features). For v1 we hardcode the well-known features so we can ship
|
||||
* the page without a backend dependency. Replace this block when a server-side
|
||||
* feature mapping endpoint exists.
|
||||
* ------------------------------------------------------------------------- */
|
||||
type PlanColumn = 'starter' | 'pro' | 'enterprise'
|
||||
|
||||
const COMPARISON_ROWS: Array<{
|
||||
feature: string
|
||||
values: Record<PlanColumn, boolean>
|
||||
}> = [
|
||||
{ feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } },
|
||||
{ feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } },
|
||||
{ feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } },
|
||||
{ feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } },
|
||||
{ feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } },
|
||||
]
|
||||
|
||||
function formatPrice(cents: number | null | undefined): string {
|
||||
if (cents == null) return ''
|
||||
const dollars = cents / 100
|
||||
// Whole dollars (no decimals) for marketing display.
|
||||
return `$${Math.round(dollars).toLocaleString()}`
|
||||
}
|
||||
|
||||
function PricingNotFound() {
|
||||
return (
|
||||
<div
|
||||
data-testid="pricing-not-found"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PublicPlanResponse | null
|
||||
fallback: {
|
||||
plan: string
|
||||
display_name: string
|
||||
description: string
|
||||
}
|
||||
recommended?: boolean
|
||||
hidePrice?: boolean
|
||||
ctaLabel: string
|
||||
ctaHref: string
|
||||
ctaTestId: string
|
||||
}
|
||||
|
||||
function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) {
|
||||
const displayName = plan?.display_name ?? fallback.display_name
|
||||
const description = plan?.description ?? fallback.description
|
||||
const monthlyCents = plan?.monthly_price_cents ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`plan-card-${fallback.plan}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--lp-card)',
|
||||
border: recommended
|
||||
? '2px solid var(--lp-accent)'
|
||||
: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem 1.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{recommended && (
|
||||
<span
|
||||
data-testid="recommended-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '4px 12px',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</h3>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: '3rem' }}>
|
||||
{hidePrice ? (
|
||||
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||
Custom pricing
|
||||
</div>
|
||||
) : monthlyCents != null ? (
|
||||
<div>
|
||||
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
|
||||
{formatPrice(monthlyCents)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={ctaHref}
|
||||
data-testid={ctaTestId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: recommended ? 'var(--lp-accent)' : 'transparent',
|
||||
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
|
||||
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch plans on mount when self-serve is enabled.
|
||||
useEffect(() => {
|
||||
if (appConfig.isLoading) return
|
||||
if (!appConfig.self_serve_enabled) return
|
||||
|
||||
let cancelled = false
|
||||
plansApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setPlans(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
// Non-fatal: page still renders with fallback descriptions and no
|
||||
// server-driven prices. The CTA still works via /register?plan=...
|
||||
setError('Unable to load live pricing.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [appConfig.isLoading, appConfig.self_serve_enabled])
|
||||
|
||||
// Self-serve disabled: render a 404-style fallback. Done after hooks so
|
||||
// the React rules-of-hooks invariant holds.
|
||||
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Page not found" />
|
||||
<PricingNotFound />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const planByName = (name: string) =>
|
||||
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<PageMeta
|
||||
title="Pricing"
|
||||
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
|
||||
/>
|
||||
|
||||
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
|
||||
{/* ---- HERO ---- */}
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
padding: '4rem 1.5rem 2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 1rem',
|
||||
}}
|
||||
>
|
||||
Simple pricing for MSPs of every size
|
||||
</h1>
|
||||
<p
|
||||
data-testid="hero-trial-line"
|
||||
style={{
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.125rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Try Pro free for 14 days. No credit card required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---- PLAN CARDS ---- */}
|
||||
<section
|
||||
aria-label="Plans"
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
padding: '2rem 1.5rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<PlanCard
|
||||
plan={planByName('starter')}
|
||||
fallback={{
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs getting structured.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=starter"
|
||||
ctaTestId="cta-starter"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('pro')}
|
||||
recommended
|
||||
fallback={{
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=pro"
|
||||
ctaTestId="cta-pro"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('enterprise')}
|
||||
hidePrice
|
||||
fallback={{
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom branding, custom seats, and a dedicated success contact.',
|
||||
}}
|
||||
ctaLabel="Talk to sales"
|
||||
ctaHref="/contact-sales"
|
||||
ctaTestId="cta-enterprise"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
|
||||
>
|
||||
Loading pricing…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="status"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- COMPARISON TABLE ---- */}
|
||||
<section
|
||||
aria-label="Plan comparison"
|
||||
style={{
|
||||
maxWidth: '1000px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '0 1.5rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 1rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Compare plans
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
color: 'var(--lp-text-body)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--lp-bg-alt)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
|
||||
Feature
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON_ROWS.map((row) => (
|
||||
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.starter ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.pro ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.enterprise ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
|
||||
<section
|
||||
aria-label="Customer testimonial"
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '2rem 1.5rem',
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
data-testid="testimonial-slot"
|
||||
>
|
||||
<blockquote
|
||||
style={{
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.05rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
"Pilot testimonials coming soon."
|
||||
</blockquote>
|
||||
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
ResolutionFlow pilot, 2026
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TRUST STRIP ---- */}
|
||||
<section
|
||||
aria-label="Trust"
|
||||
data-testid="trust-strip"
|
||||
style={{
|
||||
maxWidth: '900px',
|
||||
margin: '2rem auto 0',
|
||||
padding: '1rem 1.5rem',
|
||||
color: 'var(--lp-text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Built on Stripe + AWS · Encrypted in transit and at rest
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PricingPage
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||
@@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
import { NextStepCard, pickNextStep } from '@/components/dashboard/NextStepCard'
|
||||
import { SetupChecklist } from '@/components/dashboard/SetupChecklist'
|
||||
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
|
||||
import { useTrialBanner } from '@/hooks/useTrialBanner'
|
||||
|
||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
@@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
|
||||
|
||||
export function QuickStartPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [showAllSetupSteps, setShowAllSetupSteps] = useState(false)
|
||||
const onboardingStatus = useOnboardingStatus()
|
||||
const { stage: trialStage } = useTrialBanner()
|
||||
|
||||
// Onboarding section is visible when there's still something to nudge on.
|
||||
// We check the same priority list NextStepCard uses so the toggle row
|
||||
// disappears cleanly once everything is done OR the user dismissed.
|
||||
const onboardingVisible =
|
||||
onboardingStatus !== null &&
|
||||
!onboardingStatus.dismissed &&
|
||||
pickNextStep(onboardingStatus, trialStage) !== null
|
||||
|
||||
const now = new Date()
|
||||
const greeting = now.getHours() < 12
|
||||
@@ -47,6 +63,29 @@ export function QuickStartPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Next-step card — surfaces a single onboarding nudge below the hero. */}
|
||||
{onboardingVisible && (
|
||||
<div className="mb-6">
|
||||
<NextStepCard />
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSetupSteps((v) => !v)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
|
||||
data-testid="toggle-setup-checklist"
|
||||
aria-expanded={showAllSetupSteps}
|
||||
>
|
||||
{showAllSetupSteps ? 'Hide setup steps' : 'Show all setup steps'}
|
||||
</button>
|
||||
</div>
|
||||
{showAllSetupSteps && (
|
||||
<div className="mt-3">
|
||||
<SetupChecklist />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat-style input */}
|
||||
<StartSessionInput />
|
||||
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { inviteApi } from '@/api/invite'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { PasswordInput } from '@/components/common/PasswordInput'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const MICROSOFT_AUTH_URL =
|
||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
|
||||
|
||||
function getRedirectBase(): string {
|
||||
const fromEnv = import.meta.env.VITE_OAUTH_REDIRECT_BASE
|
||||
if (fromEnv) return fromEnv as string
|
||||
// Falls back to current origin in dev so feature works without explicit env.
|
||||
if (typeof window !== 'undefined') return window.location.origin
|
||||
return ''
|
||||
}
|
||||
|
||||
function randomState(): string {
|
||||
// Lightweight random state — used only to harden against CSRF on the OAuth
|
||||
// round-trip. Not a security boundary; backend independently authenticates
|
||||
// via the authorization code exchange.
|
||||
const buf = new Uint8Array(16)
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(buf)
|
||||
} else {
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Build provider authorize URL. Exported for tests and invite OAuth handoff. */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit tests
|
||||
export function buildOAuthAuthorizeUrl(
|
||||
provider: 'google' | 'microsoft',
|
||||
state: string,
|
||||
): string {
|
||||
const redirectUri = `${getRedirectBase()}/auth/${provider}/callback`
|
||||
if (provider === 'google') {
|
||||
const params = new URLSearchParams({
|
||||
client_id: (import.meta.env.VITE_GOOGLE_CLIENT_ID as string) || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
})
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: (import.meta.env.VITE_MS_CLIENT_ID as string) || '',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile offline_access',
|
||||
response_mode: 'query',
|
||||
state,
|
||||
})
|
||||
return `${MICROSOFT_AUTH_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { register, isLoading, error, clearError } = useAuthStore()
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [inviteCodeStatus, setInviteCodeStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle')
|
||||
const [inviteCodeStatus, setInviteCodeStatus] = useState<
|
||||
'idle' | 'checking' | 'valid' | 'invalid'
|
||||
>('idle')
|
||||
const [inviteCodeMessage, setInviteCodeMessage] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -20,6 +80,32 @@ export function RegisterPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [localError, setLocalError] = useState('')
|
||||
|
||||
// Capture ?plan=pro into localStorage so the in-app flow / start_trial
|
||||
// can later read it. One-shot on mount.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const plan = params.get('plan')
|
||||
if (plan) localStorage.setItem('rf-intended-plan', plan)
|
||||
}, [location.search])
|
||||
|
||||
const showOAuthButtons = appConfig.self_serve_enabled
|
||||
const showInviteCode = !appConfig.self_serve_enabled
|
||||
const googleAvailable =
|
||||
showOAuthButtons && appConfig.oauth_providers.includes('google')
|
||||
const microsoftAvailable =
|
||||
showOAuthButtons && appConfig.oauth_providers.includes('microsoft')
|
||||
|
||||
const handleOAuth = (provider: 'google' | 'microsoft') => {
|
||||
const state = randomState()
|
||||
try {
|
||||
sessionStorage.setItem('rf-oauth-state', state)
|
||||
} catch {
|
||||
// ignore — non-fatal
|
||||
}
|
||||
const url = buildOAuthAuthorizeUrl(provider, state)
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
const validateInviteCode = async (code: string) => {
|
||||
if (!code.trim()) {
|
||||
setInviteCodeStatus('idle')
|
||||
@@ -43,8 +129,8 @@ export function RegisterPage() {
|
||||
setLocalError('')
|
||||
clearError()
|
||||
|
||||
// Only validate invite code if one was entered
|
||||
if (inviteCode.trim() && inviteCodeStatus === 'invalid') {
|
||||
// Only validate invite code when the field is shown (legacy invite flow).
|
||||
if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') {
|
||||
setLocalError('Please enter a valid invite code')
|
||||
return
|
||||
}
|
||||
@@ -65,12 +151,15 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Only include invite_code if provided
|
||||
const userData = inviteCode.trim()
|
||||
? { email, password, name, invite_code: inviteCode.trim() }
|
||||
: { email, password, name }
|
||||
const userData =
|
||||
showInviteCode && inviteCode.trim()
|
||||
? { email, password, name, invite_code: inviteCode.trim() }
|
||||
: { email, password, name }
|
||||
await register(userData)
|
||||
navigate('/', { replace: true })
|
||||
// New users land on the welcome wizard. The /welcome route is
|
||||
// materialized by Task 38; until that lands, this redirect falls
|
||||
// through to the catch-all 404 — acceptable per spec.
|
||||
navigate('/welcome', { replace: true })
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
@@ -78,28 +167,30 @@ export function RegisterPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
{/* Subtle radial overlay */}
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
<PageMeta
|
||||
title="Create Account"
|
||||
description="Create your ResolutionFlow account to start building guided troubleshooting flows"
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
{/* Subtle radial overlay */}
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
|
||||
<div className="relative w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<BrandLogo size="lg" />
|
||||
<div className="relative w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
||||
ResolutionFlow
|
||||
</h1>
|
||||
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
|
||||
AI-Powered Troubleshooting for MSPs
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
|
||||
Create your account
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
||||
ResolutionFlow
|
||||
</h1>
|
||||
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
|
||||
AI-Powered Troubleshooting for MSPs
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
|
||||
Create your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
@@ -107,140 +198,217 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="inviteCode" className="block text-sm font-medium text-foreground">
|
||||
Invite code
|
||||
</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
name="inviteCode"
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => {
|
||||
setInviteCode(e.target.value.toUpperCase())
|
||||
setInviteCodeStatus('idle')
|
||||
}}
|
||||
onBlur={(e) => validateInviteCode(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-hidden focus:ring-1',
|
||||
inviteCodeStatus === 'valid' && 'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30',
|
||||
inviteCodeStatus === 'invalid' && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/30',
|
||||
inviteCodeStatus === 'idle' && 'border-border focus:border-primary focus:ring-primary/20',
|
||||
inviteCodeStatus === 'checking' && 'border-border focus:border-primary focus:ring-primary/20'
|
||||
{showOAuthButtons && (googleAvailable || microsoftAvailable) && (
|
||||
<div className="space-y-3">
|
||||
{googleAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('google')}
|
||||
data-testid="oauth-google"
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
// TODO(brand): swap to white-on-black with Google "G" mark
|
||||
// when brand assets are imported. Neutral fallback for now.
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
)}
|
||||
placeholder="ABCD1234"
|
||||
/>
|
||||
{inviteCodeStatus === 'checking' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Validating...</p>
|
||||
{microsoftAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('microsoft')}
|
||||
data-testid="oauth-microsoft"
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'transition-all',
|
||||
)}
|
||||
>
|
||||
Continue with Microsoft
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-wider">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
or sign up with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{showInviteCode && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="inviteCode"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Invite code
|
||||
</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
name="inviteCode"
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => {
|
||||
setInviteCode(e.target.value.toUpperCase())
|
||||
setInviteCodeStatus('idle')
|
||||
}}
|
||||
onBlur={(e) => validateInviteCode(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-hidden focus:ring-1',
|
||||
inviteCodeStatus === 'valid' &&
|
||||
'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30',
|
||||
inviteCodeStatus === 'invalid' &&
|
||||
'border-red-400/50 focus:border-red-400 focus:ring-red-400/30',
|
||||
inviteCodeStatus === 'idle' &&
|
||||
'border-border focus:border-primary focus:ring-primary/20',
|
||||
inviteCodeStatus === 'checking' &&
|
||||
'border-border focus:border-primary focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="ABCD1234"
|
||||
/>
|
||||
{inviteCodeStatus === 'checking' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Validating...
|
||||
</p>
|
||||
)}
|
||||
{inviteCodeStatus === 'valid' && (
|
||||
<p className="mt-1 text-xs text-emerald-400">
|
||||
{inviteCodeMessage}
|
||||
</p>
|
||||
)}
|
||||
{inviteCodeStatus === 'invalid' && (
|
||||
<p className="mt-1 text-xs text-red-400">
|
||||
{inviteCodeMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{inviteCodeStatus === 'valid' && (
|
||||
<p className="mt-1 text-xs text-emerald-400">{inviteCodeMessage}</p>
|
||||
)}
|
||||
{inviteCodeStatus === 'invalid' && (
|
||||
<p className="mt-1 text-xs text-red-400">{inviteCodeMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="John Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Must be at least 10 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-white hover:brightness-110',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-all',
|
||||
)}
|
||||
placeholder="John Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Must be at least 10 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
|
||||
Confirm password
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-white hover:brightness-110',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-all'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
@@ -249,9 +417,8 @@ export function RegisterPage() {
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +1,221 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2, MailCheck } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Status = 'loading' | 'success' | 'error' | 'already-verified' | 'no-token'
|
||||
|
||||
const SUCCESS_REDIRECT_MS = 1200
|
||||
|
||||
/**
|
||||
* Standalone landing page for the email-verification link
|
||||
* (`/verify-email?token=...`).
|
||||
*
|
||||
* Behavior:
|
||||
* - If the user is already verified, short-circuit to a friendly
|
||||
* "Already verified" state. No API call.
|
||||
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
|
||||
* React 19 strict-mode double-invoke from double-firing the call). On
|
||||
* success, refresh the auth store and bounce to `/?verified=1` so the
|
||||
* dashboard surfaces a toast.
|
||||
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
|
||||
* `POST /auth/email/send-verification`.
|
||||
*/
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
|
||||
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided')
|
||||
|
||||
const alreadyVerified = useAuthStore(
|
||||
(s) => Boolean(s.user?.email_verified_at),
|
||||
)
|
||||
|
||||
const initialStatus: Status = alreadyVerified
|
||||
? 'already-verified'
|
||||
: token
|
||||
? 'loading'
|
||||
: 'no-token'
|
||||
|
||||
const [status, setStatus] = useState<Status>(initialStatus)
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
|
||||
// Single-fire guard: React 19 strict mode runs effects twice on mount.
|
||||
// Without this, the verify endpoint would burn the token on the first call
|
||||
// and then 400 on the second, flashing an error past the success state.
|
||||
const hasFiredRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'loading') return
|
||||
if (!token) return
|
||||
if (hasFiredRef.current) return
|
||||
hasFiredRef.current = true
|
||||
|
||||
authApi.verifyEmail(token)
|
||||
.then(() => setStatus('success'))
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
setErrorMessage(detail ?? 'Verification failed')
|
||||
let cancelled = false
|
||||
|
||||
authApi
|
||||
.verifyEmail(token)
|
||||
.then(async () => {
|
||||
// Refresh user so `email_verified_at` is populated everywhere.
|
||||
try {
|
||||
await useAuthStore.getState().fetchUser()
|
||||
} catch {
|
||||
// Non-fatal: server confirmed verification, the local user object
|
||||
// will refresh on next page load.
|
||||
}
|
||||
if (cancelled) return
|
||||
setStatus('success')
|
||||
toast.success('Email verified')
|
||||
// Brief success state, then redirect with a query flag so the
|
||||
// dashboard can re-surface confirmation if it wants to.
|
||||
window.setTimeout(() => {
|
||||
navigate('/?verified=1', { replace: true })
|
||||
}, SUCCESS_REDIRECT_MS)
|
||||
})
|
||||
}, [token])
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })
|
||||
.response?.data?.detail
|
||||
setErrorMessage(detail ?? 'Invalid or expired verification link')
|
||||
setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [status, token, navigate])
|
||||
|
||||
const handleResend = async () => {
|
||||
setIsResending(true)
|
||||
try {
|
||||
await authApi.sendVerificationEmail()
|
||||
toast.success('Verification email sent — check your inbox')
|
||||
} catch {
|
||||
toast.error('Failed to send verification email')
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Verify Email" description="Verify your ResolutionFlow email address" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="card-flat w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
|
||||
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-white',
|
||||
'hover:brightness-110'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
|
||||
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-input border border-border px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<PageMeta
|
||||
title="Verify Email"
|
||||
description="Verify your ResolutionFlow email address"
|
||||
/>
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="card-flat w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email…</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-success" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
||||
Email verified
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Redirecting you to the dashboard…
|
||||
</p>
|
||||
<Link
|
||||
to="/?verified=1"
|
||||
replace
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
||||
'hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'already-verified' && (
|
||||
<>
|
||||
<MailCheck className="mx-auto h-12 w-12 text-success" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
||||
You're already verified
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
This account's email is already confirmed. No further
|
||||
action needed.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
||||
'hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-danger" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
||||
Verification failed
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{errorMessage || 'Invalid or expired verification link'}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isResending}
|
||||
data-testid="resend-button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{isResending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Resend verification email
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border border-default bg-input px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'no-token' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-danger" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
|
||||
Missing verification token
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
The link you used doesn't include a verification token.
|
||||
Try the link in your verification email again.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
|
||||
'hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Go to dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal file
123
frontend/src/pages/__tests__/AcceptInvitePage.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { AcceptInvitePage } from '../AcceptInvitePage'
|
||||
import { inviteApi } from '@/api/invite'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/invite', () => ({
|
||||
inviteApi: {
|
||||
lookupAccountInvite: vi.fn(),
|
||||
validateCode: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
clearError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderPage(initialPath: string) {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<AcceptInvitePage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AcceptInvitePage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: ['google', 'microsoft'],
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows account name + locked email + accept buttons for a valid code', async () => {
|
||||
vi.mocked(inviteApi.lookupAccountInvite).mockResolvedValue({
|
||||
account_name: 'Acme MSP',
|
||||
inviter_name: 'Alice Owner',
|
||||
invited_email: 'bob@acme.example',
|
||||
role: 'engineer',
|
||||
})
|
||||
|
||||
renderPage('/accept-invite?code=VALIDINVITECODE0011223344556677')
|
||||
|
||||
// Inviter context (also confirms the lookup completed and rendered)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Alice Owner invited you as engineer/),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
// Account name surfaces in the heading line.
|
||||
expect(
|
||||
screen.getByText((_content, node) => {
|
||||
return (
|
||||
node?.tagName.toLowerCase() === 'span' &&
|
||||
/Acme MSP/.test(node.textContent || '')
|
||||
)
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Locked email — not an editable input
|
||||
const emailDisplay = screen.getByTestId('invited-email')
|
||||
expect(emailDisplay.tagName.toLowerCase()).not.toBe('input')
|
||||
expect(emailDisplay).toHaveTextContent('bob@acme.example')
|
||||
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
|
||||
|
||||
// OAuth buttons + password submit all rendered
|
||||
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('accept-submit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('accept-submit')).toHaveTextContent(/Join Acme MSP/)
|
||||
|
||||
expect(inviteApi.lookupAccountInvite).toHaveBeenCalledWith(
|
||||
'VALIDINVITECODE0011223344556677',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows resend message + mailto link for an invalid invite code', async () => {
|
||||
vi.mocked(inviteApi.lookupAccountInvite).mockRejectedValue(
|
||||
Object.assign(new Error('not found'), {
|
||||
response: {
|
||||
status: 404,
|
||||
data: { detail: { error: 'invite_invalid_or_expired_or_revoked' } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage('/accept-invite?code=BADCODE')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/This invite is no longer valid/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.getByText(/Ask the person who invited you to resend it/i),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const resendLink = screen.getByRole('link', { name: /Email your inviter/i })
|
||||
expect(resendLink).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringMatching(/^mailto:/),
|
||||
)
|
||||
|
||||
// No accept form rendered when invite is invalid.
|
||||
expect(screen.queryByTestId('accept-submit')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
146
frontend/src/pages/__tests__/ContactSalesPage.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { ContactSalesPage } from '../ContactSalesPage'
|
||||
import { salesApi } from '@/api/sales'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/sales', () => ({
|
||||
salesApi: {
|
||||
createLead: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/contact-sales']}>
|
||||
<ContactSalesPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function fillRequiredFields() {
|
||||
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
|
||||
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
|
||||
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
|
||||
}
|
||||
|
||||
describe('ContactSalesPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('submits form and shows confirmation', async () => {
|
||||
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
|
||||
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||
id: 'fake-uuid',
|
||||
status: 'received',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
fillRequiredFields()
|
||||
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
|
||||
fireEvent.change(screen.getByTestId('cs-message'), {
|
||||
target: { value: 'Looking at Enterprise pricing.' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
|
||||
expect(payload).toMatchObject({
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@acme.com',
|
||||
company: 'Acme MSP',
|
||||
team_size: '11-25',
|
||||
message: 'Looking at Enterprise pricing.',
|
||||
})
|
||||
// Default source is landing_page (no /pricing in referrer in jsdom).
|
||||
expect(payload.source).toBe('landing_page')
|
||||
|
||||
// Confirmation surface replaces the form.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
|
||||
vi.stubEnv('VITE_CALENDLY_URL', '')
|
||||
vi.mocked(salesApi.createLead).mockResolvedValue({
|
||||
id: 'fake-uuid',
|
||||
status: 'received',
|
||||
})
|
||||
|
||||
renderPage()
|
||||
fillRequiredFields()
|
||||
fireEvent.click(screen.getByTestId('cs-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables submit button while in flight to prevent duplicate submissions', async () => {
|
||||
let resolveSubmit: (() => void) | null = null
|
||||
vi.mocked(salesApi.createLead).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
fillRequiredFields()
|
||||
|
||||
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
|
||||
fireEvent.click(submit)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submit.disabled).toBe(true)
|
||||
})
|
||||
|
||||
// A second click while in flight should be a no-op.
|
||||
fireEvent.click(submit)
|
||||
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSubmit?.()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when self_serve_enabled is false', () => {
|
||||
__resetAppConfigCache()
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
69
frontend/src/pages/__tests__/LandingPage.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import LandingPage from '../LandingPage'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
// jsdom does not provide IntersectionObserver. LandingPage uses it for
|
||||
// scroll-reveal animations; stub a no-op so the page can mount.
|
||||
beforeAll(() => {
|
||||
// @ts-expect-error — test-only stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<LandingPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LandingPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows See pricing CTA when self_serve_enabled is true', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('landing-see-pricing')
|
||||
expect(cta).toHaveAttribute('href', '/pricing')
|
||||
expect(cta).toHaveTextContent(/See pricing/i)
|
||||
})
|
||||
|
||||
it('hides See pricing CTA when self_serve_enabled is false', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
|
||||
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
121
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal file
121
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { OAuthCallbackPage } from '../OAuthCallbackPage'
|
||||
import { authApi } from '@/api/auth'
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
googleCallback: vi.fn(),
|
||||
microsoftCallback: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSetTokens = vi.fn()
|
||||
const mockFetchUser = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
setTokens: mockSetTokens,
|
||||
fetchUser: mockFetchUser,
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderAt(path: string) {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/auth/google/callback"
|
||||
element={<OAuthCallbackPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/auth/microsoft/callback"
|
||||
element={<OAuthCallbackPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('OAuthCallbackPage CSRF state validation', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('shows error and does NOT call googleCallback when state in URL does not match sessionStorage', async () => {
|
||||
sessionStorage.setItem('rf-oauth-state', 'expected-state-value')
|
||||
|
||||
renderAt('/auth/google/callback?code=auth-code-123&state=attacker-state')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Invalid OAuth state/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(authApi.googleCallback).not.toHaveBeenCalled()
|
||||
expect(authApi.microsoftCallback).not.toHaveBeenCalled()
|
||||
// Stored value must be cleared regardless of outcome.
|
||||
expect(sessionStorage.getItem('rf-oauth-state')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows error and does NOT call googleCallback when stored state is missing', async () => {
|
||||
// No sessionStorage entry set.
|
||||
renderAt('/auth/google/callback?code=auth-code-123&state=any-state')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Invalid OAuth state/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(authApi.googleCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthCallbackPage successful callback', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists tokens via setTokens (which marks the store authenticated) and fetches the user', async () => {
|
||||
sessionStorage.setItem('rf-oauth-state', 'csrf-value')
|
||||
;(authApi.googleCallback as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
token_type: 'bearer',
|
||||
is_new_user: false,
|
||||
})
|
||||
|
||||
renderAt('/auth/google/callback?code=auth-code-123&state=csrf-value')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetTokens).toHaveBeenCalledWith({
|
||||
access_token: 'access-123',
|
||||
refresh_token: 'refresh-456',
|
||||
token_type: 'bearer',
|
||||
})
|
||||
})
|
||||
expect(mockFetchUser).toHaveBeenCalled()
|
||||
// Tokens are also persisted for the apiClient interceptor.
|
||||
expect(localStorage.getItem('access_token')).toBe('access-123')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('refresh-456')
|
||||
})
|
||||
})
|
||||
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { PricingPage } from '../PricingPage'
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/plans', () => ({
|
||||
plansApi: {
|
||||
getPublic: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const STARTER: PublicPlanResponse = {
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs.',
|
||||
monthly_price_cents: 1900,
|
||||
annual_price_cents: 19000,
|
||||
max_seats: 3,
|
||||
sort_order: 10,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
const PRO: PublicPlanResponse = {
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing MSP teams.',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
max_seats: 10,
|
||||
sort_order: 20,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
const ENTERPRISE: PublicPlanResponse = {
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom seats + branding.',
|
||||
monthly_price_cents: null,
|
||||
annual_price_cents: null,
|
||||
max_seats: null,
|
||||
sort_order: 30,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/pricing']}>
|
||||
<PricingPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PricingPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows three plan cards with prices from API', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(plansApi.getPublic).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Three plan cards present.
|
||||
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||
|
||||
// Prices from API rendered.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('$19')).toBeInTheDocument()
|
||||
expect(screen.getByText('$49')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enterprise card hides price (shows "Custom pricing" instead).
|
||||
expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument()
|
||||
|
||||
// Pro is recommended.
|
||||
expect(screen.getByTestId('recommended-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Start free trial button navigates to /register?plan=pro', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const proCta = await screen.findByTestId('cta-pro')
|
||||
expect(proCta).toHaveAttribute('href', '/register?plan=pro')
|
||||
expect(proCta).toHaveTextContent(/Start free trial/i)
|
||||
|
||||
const starterCta = screen.getByTestId('cta-starter')
|
||||
expect(starterCta).toHaveAttribute('href', '/register?plan=starter')
|
||||
})
|
||||
|
||||
it('Talk to sales button navigates to /contact-sales', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const enterpriseCta = await screen.findByTestId('cta-enterprise')
|
||||
expect(enterpriseCta).toHaveAttribute('href', '/contact-sales')
|
||||
expect(enterpriseCta).toHaveTextContent(/Talk to sales/i)
|
||||
})
|
||||
|
||||
it('returns 404 when self_serve_enabled is false', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Page not found/i)).toBeInTheDocument()
|
||||
|
||||
// No plan cards rendered, no API call made.
|
||||
expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument()
|
||||
expect(plansApi.getPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses softer trust language (no SOC2/DPA claim yet)', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const trust = await screen.findByTestId('trust-strip')
|
||||
expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i)
|
||||
expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i)
|
||||
expect(trust).not.toHaveTextContent(/SOC ?2/i)
|
||||
})
|
||||
})
|
||||
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal file
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import type { OnboardingStatus } from '@/api/onboarding'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
// Mock heavy dashboard children — they pull in axios + zustand stores we
|
||||
// don't care about for this toggle test.
|
||||
vi.mock('@/components/dashboard/StartSessionInput', () => ({
|
||||
StartSessionInput: () => <div data-testid="mock-start-session" />,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/PendingEscalations', () => ({
|
||||
PendingEscalations: () => null,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/ActiveFlowPilotSessions', () => ({
|
||||
ActiveFlowPilotSessions: () => null,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/TicketQueue', () => ({
|
||||
TicketQueue: () => null,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/PerformanceCards', () => ({
|
||||
PerformanceCards: () => null,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/KnowledgeBaseCards', () => ({
|
||||
KnowledgeBaseCards: () => null,
|
||||
}))
|
||||
vi.mock('@/components/dashboard/TeamSummary', () => ({
|
||||
TeamSummary: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/onboarding', () => {
|
||||
const mockGet = vi.fn()
|
||||
return {
|
||||
getOnboardingStatus: mockGet,
|
||||
dismissOnboarding: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { QuickStartPage } from '../QuickStartPage'
|
||||
import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding'
|
||||
|
||||
const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeStatus(overrides: Partial<OnboardingStatus> = {}): OnboardingStatus {
|
||||
return {
|
||||
created_flow: false,
|
||||
ran_session: false,
|
||||
exported_session: false,
|
||||
tried_ai_assistant: false,
|
||||
invited_teammate: false,
|
||||
connected_psa: false,
|
||||
is_team_user: false,
|
||||
dismissed: false,
|
||||
email_verified: true, // skip past verify so the next-step card is not the noisy thing here.
|
||||
shop_setup_done: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('QuickStartPage', () => {
|
||||
beforeEach(() => {
|
||||
getOnboardingStatus.mockReset()
|
||||
useAuthStore.setState({
|
||||
user: {
|
||||
id: 'u-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'engineer',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
token: 'tok',
|
||||
isAuthenticated: true,
|
||||
})
|
||||
useBillingStore.setState({
|
||||
subscription: {
|
||||
status: 'complimentary',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-05-01T00:00:00Z',
|
||||
current_period_end: null,
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: null,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('Show all setup steps toggle reveals unified checklist with no SOLO/TEAM headers', async () => {
|
||||
getOnboardingStatus.mockResolvedValue(makeStatus())
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<QuickStartPage />
|
||||
</BrowserRouter>,
|
||||
)
|
||||
|
||||
// Wait for initial fetch.
|
||||
await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled())
|
||||
|
||||
// Checklist is hidden by default.
|
||||
expect(screen.queryByTestId('setup-checklist')).toBeNull()
|
||||
|
||||
// Toggle visible.
|
||||
const toggle = screen.getByTestId('toggle-setup-checklist')
|
||||
expect(toggle).toHaveTextContent(/Show all setup steps/i)
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
// Checklist now rendered. (`SetupChecklist` runs its own fetch — same mock.)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('setup-checklist')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// No SOLO/TEAM section headers in the unified list.
|
||||
expect(screen.queryByText(/^SOLO$/)).toBeNull()
|
||||
expect(screen.queryByText(/^TEAM$/)).toBeNull()
|
||||
expect(screen.queryByText(/Solo users/i)).toBeNull()
|
||||
expect(screen.queryByText(/Team users/i)).toBeNull()
|
||||
|
||||
// Toggle label flips after clicking.
|
||||
expect(toggle).toHaveTextContent(/Hide setup steps/i)
|
||||
})
|
||||
})
|
||||
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal file
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { RegisterPage } from '../RegisterPage'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
function renderPage(initialPath = '/register') {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
// Provide mock env values so authorize URL build is deterministic.
|
||||
vi.stubEnv('VITE_GOOGLE_CLIENT_ID', 'test-google-client')
|
||||
vi.stubEnv('VITE_MS_CLIENT_ID', 'test-ms-client')
|
||||
vi.stubEnv('VITE_OAUTH_REDIRECT_BASE', 'http://localhost:5173')
|
||||
})
|
||||
|
||||
it('hides OAuth + shows invite-code field when self_serve_enabled is false', () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: ['google', 'microsoft'],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByLabelText(/invite code/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('oauth-microsoft')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(/or sign up with email/i),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides invite-code + shows OAuth buttons when self_serve_enabled is true', () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: ['google', 'microsoft'],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.queryByLabelText(/invite code/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
|
||||
expect(screen.getByText(/or sign up with email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking Continue with Google opens OAuth flow with correct URL', () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: ['google'],
|
||||
})
|
||||
|
||||
// Stub window.location.href assignment.
|
||||
const originalLocation = window.location
|
||||
const hrefSetter = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
origin: 'http://localhost:5173',
|
||||
set href(value: string) {
|
||||
hrefSetter(value)
|
||||
},
|
||||
get href() {
|
||||
return originalLocation.href
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
renderPage()
|
||||
const button = screen.getByTestId('oauth-google')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledTimes(1)
|
||||
const url = hrefSetter.mock.calls[0][0] as string
|
||||
expect(url).toMatch(
|
||||
/^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?/,
|
||||
)
|
||||
const search = new URL(url).searchParams
|
||||
expect(search.get('client_id')).toBe('test-google-client')
|
||||
expect(search.get('redirect_uri')).toBe(
|
||||
'http://localhost:5173/auth/google/callback',
|
||||
)
|
||||
expect(search.get('response_type')).toBe('code')
|
||||
expect(search.get('scope')).toContain('openid')
|
||||
expect(search.get('state')).toBeTruthy()
|
||||
} finally {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('captures ?plan=pro into localStorage on mount', () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
localStorage.removeItem('rf-intended-plan')
|
||||
|
||||
renderPage('/register?plan=pro')
|
||||
|
||||
expect(localStorage.getItem('rf-intended-plan')).toBe('pro')
|
||||
})
|
||||
})
|
||||
174
frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
Normal file
174
frontend/src/pages/__tests__/VerifyEmailPage.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { VerifyEmailPage } from '../VerifyEmailPage'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type { User } from '@/types'
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
authApi: {
|
||||
verifyEmail: vi.fn(),
|
||||
sendVerificationEmail: vi.fn(),
|
||||
me: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'engineer',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(initialPath: string) {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('VerifyEmailPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
vi.mocked(authApi.me).mockResolvedValue(
|
||||
makeUser({ email_verified_at: '2026-05-06T00:00:00Z' }),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows success and redirects on valid token', async () => {
|
||||
useAuthStore.setState({ user: makeUser() })
|
||||
// Override fetchUser to avoid hitting axios/XHR in jsdom — the page calls
|
||||
// it after a successful verify to refresh `email_verified_at`.
|
||||
useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
|
||||
vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
|
||||
|
||||
renderPage('/verify-email?token=valid-token')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApi.verifyEmail).toHaveBeenCalledWith('valid-token')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Email verified/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Advance past the redirect delay.
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows already-verified state when user is already verified', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ email_verified_at: '2026-05-05T00:00:00Z' }),
|
||||
})
|
||||
|
||||
renderPage('/verify-email?token=any-token')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/already verified/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The verify endpoint must NOT have been called when the user is already
|
||||
// verified — that would burn a perfectly good token for no reason.
|
||||
expect(authApi.verifyEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only calls verifyEmail once even if the effect re-runs (strict-mode guard)', async () => {
|
||||
useAuthStore.setState({ user: makeUser() })
|
||||
useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
|
||||
vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
|
||||
|
||||
const { rerender } = render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
|
||||
<Routes>
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
|
||||
// Force a re-render to simulate React 19 strict-mode double-invoke.
|
||||
rerender(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
|
||||
<Routes>
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApi.verifyEmail).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(authApi.verifyEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows an error state with a resend CTA on invalid token', async () => {
|
||||
useAuthStore.setState({ user: makeUser() })
|
||||
vi.mocked(authApi.verifyEmail).mockRejectedValue(
|
||||
Object.assign(new Error('boom'), {
|
||||
response: { data: { detail: 'Token expired' } },
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage('/verify-email?token=stale-token')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Verification failed/i)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Token expired/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('resend-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
267
frontend/src/pages/account/BillingPage.tsx
Normal file
267
frontend/src/pages/account/BillingPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { BillingPortalError } from '@/types/billing'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'trialing':
|
||||
return 'Trialing'
|
||||
case 'active':
|
||||
return 'Active'
|
||||
case 'past_due':
|
||||
return 'Past due'
|
||||
case 'canceled':
|
||||
return 'Canceled'
|
||||
case 'incomplete':
|
||||
return 'Incomplete'
|
||||
case 'complimentary':
|
||||
return 'Complimentary'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
function statusToneClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'complimentary':
|
||||
return 'text-success'
|
||||
case 'trialing':
|
||||
return 'text-info'
|
||||
case 'past_due':
|
||||
case 'incomplete':
|
||||
return 'text-warning'
|
||||
case 'canceled':
|
||||
return 'text-danger'
|
||||
default:
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
const subscription = useBillingStore((s) => s.subscription)
|
||||
const planBilling = useBillingStore((s) => s.planBilling)
|
||||
const isLoading = useBillingStore((s) => s.isLoading)
|
||||
|
||||
const [openingPortal, setOpeningPortal] = useState(false)
|
||||
|
||||
const status = subscription?.status ?? null
|
||||
const isComplimentary = status === 'complimentary'
|
||||
const isTrialing = status === 'trialing'
|
||||
const isPastDue = status === 'past_due'
|
||||
const isCanceled = status === 'canceled'
|
||||
|
||||
const handleOpenPortal = async () => {
|
||||
setOpeningPortal(true)
|
||||
try {
|
||||
const { url } = await billingApi.getPortalSession()
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
if (err instanceof BillingPortalError) {
|
||||
if (err.code === 'no_stripe_customer') {
|
||||
toast.error('Complete checkout first to access billing portal.')
|
||||
} else {
|
||||
toast.error('Billing portal is not available right now.')
|
||||
}
|
||||
} else {
|
||||
toast.error('Failed to open billing portal.')
|
||||
}
|
||||
setOpeningPortal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !subscription) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Billing" />
|
||||
<div>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||
Billing
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage your subscription, payment method, and billing history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Past-due banner ────────────────────────────────────────────── */}
|
||||
{isPastDue && (
|
||||
<div
|
||||
data-testid="past-due-banner"
|
||||
className={cn(
|
||||
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
|
||||
'bg-warning-dim p-4 text-foreground',
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Your last payment failed.</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Update your payment method to keep access to ResolutionFlow.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={openingPortal}
|
||||
onClick={handleOpenPortal}
|
||||
data-testid="past-due-update-payment"
|
||||
>
|
||||
Update payment method
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Subscription summary card ──────────────────────────────────── */}
|
||||
<div className="card-flat max-w-xl space-y-5 p-6">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{planBilling?.display_name ?? 'No active plan'}
|
||||
</span>
|
||||
</div>
|
||||
{subscription && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-xs',
|
||||
statusToneClass(subscription.status),
|
||||
)}
|
||||
>
|
||||
{statusLabel(subscription.status)}
|
||||
{subscription.cancel_at_period_end && ' · cancels at period end'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscription?.seat_limit != null && (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">Seats</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{subscription.seat_limit}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
|
||||
</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Plan started</div>
|
||||
<div className="text-sm tabular-nums text-foreground">
|
||||
{formatDate(subscription?.current_period_start)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State-specific messaging ------------------------------------ */}
|
||||
{isComplimentary && (
|
||||
<div
|
||||
data-testid="complimentary-message"
|
||||
className="rounded-md bg-success-dim p-3 text-xs text-success"
|
||||
>
|
||||
Complimentary Pro — no billing required.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTrialing && (
|
||||
<div
|
||||
data-testid="trial-message"
|
||||
className="rounded-md bg-info-dim p-3 text-xs text-info"
|
||||
>
|
||||
Trial ends {formatDate(subscription?.current_period_end)} — pick a plan
|
||||
to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCanceled && (
|
||||
<div
|
||||
data-testid="canceled-message"
|
||||
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
|
||||
>
|
||||
Subscription canceled. Reactivate by picking a plan.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Actions ────────────────────────────────────────────────────── */}
|
||||
{!isComplimentary && (
|
||||
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
|
||||
{(isTrialing || isCanceled) && (
|
||||
<Link
|
||||
to="/account/billing/select-plan"
|
||||
data-testid="select-plan-link"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
|
||||
'text-sm font-semibold text-white hover:brightness-110',
|
||||
)}
|
||||
>
|
||||
Pick a plan
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isTrialing && !isCanceled && (
|
||||
<Link
|
||||
to="/account/billing/select-plan"
|
||||
data-testid="change-plan-link"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg border border-border',
|
||||
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
Change plan
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={openingPortal}
|
||||
onClick={handleOpenPortal}
|
||||
data-testid="manage-billing-button"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Manage billing
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BillingPage
|
||||
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Check, CreditCard, Loader2 } from 'lucide-react'
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BillingInterval, CheckoutPlan } from '@/types/billing'
|
||||
|
||||
function formatPrice(cents: number | null | undefined): string {
|
||||
if (cents == null) return ''
|
||||
const dollars = cents / 100
|
||||
return `$${Math.round(dollars).toLocaleString()}`
|
||||
}
|
||||
|
||||
const PLAN_FALLBACK_FEATURES: Record<string, string[]> = {
|
||||
starter: ['AI Builder', 'Up to 1 seat', 'Email support'],
|
||||
pro: [
|
||||
'PSA Integration',
|
||||
'KB Accelerator',
|
||||
'AI Builder',
|
||||
'Priority support',
|
||||
],
|
||||
team: [
|
||||
'Everything in Pro',
|
||||
'Multi-seat collaboration',
|
||||
'Shared categories',
|
||||
],
|
||||
enterprise: [
|
||||
'Custom seats and SSO',
|
||||
'Custom branding',
|
||||
'Dedicated success contact',
|
||||
],
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PublicPlanResponse
|
||||
interval: BillingInterval
|
||||
isCurrent: boolean
|
||||
isEnterprise: boolean
|
||||
onSelect: (planKey: CheckoutPlan) => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
interval,
|
||||
isCurrent,
|
||||
isEnterprise,
|
||||
onSelect,
|
||||
isSubmitting,
|
||||
}: PlanCardProps) {
|
||||
const planKey = plan.plan.toLowerCase() as CheckoutPlan
|
||||
const cents =
|
||||
interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents
|
||||
const features = PLAN_FALLBACK_FEATURES[planKey] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`plan-card-${planKey}`}
|
||||
className={cn(
|
||||
'flex flex-col gap-4 rounded-xl border p-6',
|
||||
isCurrent
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'border-border bg-card hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{plan.display_name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<span
|
||||
data-testid={`plan-current-${planKey}`}
|
||||
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
Current plan
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground">{plan.description}</p>
|
||||
)}
|
||||
|
||||
<div className="min-h-[3rem]">
|
||||
{isEnterprise ? (
|
||||
<div className="text-base font-medium text-foreground">
|
||||
Custom pricing
|
||||
</div>
|
||||
) : cents != null ? (
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{formatPrice(cents)}
|
||||
</span>
|
||||
<span className="ml-1 text-sm text-muted-foreground">
|
||||
/ {interval === 'annual' ? 'year' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Contact us</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{isEnterprise ? (
|
||||
<Link
|
||||
to="/contact-sales"
|
||||
data-testid={`plan-cta-${planKey}`}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-lg border border-border',
|
||||
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-border-hover',
|
||||
)}
|
||||
>
|
||||
Talk to sales
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
data-testid={`plan-cta-${planKey}`}
|
||||
disabled={isCurrent || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
onClick={() => onSelect(planKey)}
|
||||
className="w-full"
|
||||
>
|
||||
{isCurrent ? 'Current plan' : 'Continue to checkout'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectPlanPage() {
|
||||
const subscription = useBillingStore((s) => s.subscription)
|
||||
const currentPlan = subscription?.plan ?? null
|
||||
const isCurrentActive =
|
||||
subscription?.status === 'active' || subscription?.status === 'trialing'
|
||||
|
||||
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [interval, setInterval] = useState<BillingInterval>('monthly')
|
||||
const [seats, setSeats] = useState<number>(1)
|
||||
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
plansApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
// Sort by sort_order so the layout is stable.
|
||||
const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order)
|
||||
setPlans(sorted)
|
||||
setLoadError(null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setLoadError('Unable to load plans. Please try again.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const seedSeats = useMemo(() => {
|
||||
return subscription?.seat_limit && subscription.seat_limit > 0
|
||||
? subscription.seat_limit
|
||||
: 1
|
||||
}, [subscription?.seat_limit])
|
||||
|
||||
useEffect(() => {
|
||||
setSeats(seedSeats)
|
||||
}, [seedSeats])
|
||||
|
||||
const handleSelectPlan = async (planKey: CheckoutPlan) => {
|
||||
if (planKey === 'enterprise') return
|
||||
setSubmittingPlan(planKey)
|
||||
try {
|
||||
const { url } = await billingApi.createCheckoutSession({
|
||||
plan: planKey,
|
||||
seats: Math.max(1, Math.floor(seats)),
|
||||
billing_interval: interval,
|
||||
})
|
||||
window.location.href = url
|
||||
} catch {
|
||||
toast.error('Could not start checkout. Please try again.')
|
||||
setSubmittingPlan(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Pick a plan" />
|
||||
<div>
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||
Pick a plan
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Choose the plan that fits your team. You can change or cancel any
|
||||
time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ───────────────────────────────────────────────────── */}
|
||||
<div className="mb-6 flex flex-wrap items-end gap-6">
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground">
|
||||
Billing interval
|
||||
</span>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Billing interval"
|
||||
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={interval === 'monthly'}
|
||||
data-testid="interval-monthly"
|
||||
onClick={() => setInterval('monthly')}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 font-medium',
|
||||
interval === 'monthly'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={interval === 'annual'}
|
||||
data-testid="interval-annual"
|
||||
onClick={() => setInterval('annual')}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 font-medium',
|
||||
interval === 'annual'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="seats-input"
|
||||
className="block text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Seats
|
||||
</label>
|
||||
<input
|
||||
id="seats-input"
|
||||
data-testid="seats-input"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={seats}
|
||||
onChange={(e) => {
|
||||
const next = Number.parseInt(e.target.value, 10)
|
||||
if (Number.isFinite(next) && next >= 1) {
|
||||
setSeats(next)
|
||||
} else if (e.target.value === '') {
|
||||
setSeats(1)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5',
|
||||
'text-sm text-foreground',
|
||||
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards ─────────────────────────────────────────────────── */}
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && !loading && (
|
||||
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && plans && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{plans.map((plan) => {
|
||||
const planKey = plan.plan.toLowerCase()
|
||||
const isEnterprise = planKey === 'enterprise'
|
||||
const isCurrent = !!(
|
||||
isCurrentActive &&
|
||||
currentPlan &&
|
||||
currentPlan.toLowerCase() === planKey
|
||||
)
|
||||
return (
|
||||
<PlanCard
|
||||
key={plan.plan}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
isCurrent={isCurrent}
|
||||
isEnterprise={isEnterprise}
|
||||
onSelect={handleSelectPlan}
|
||||
isSubmitting={submittingPlan === planKey}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/account/billing"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to billing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectPlanPage
|
||||
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { BillingPage } from '../BillingPage'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { BillingPortalError } from '@/types/billing'
|
||||
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
|
||||
|
||||
vi.mock('@/api/billing', () => ({
|
||||
billingApi: {
|
||||
getPortalSession: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
|
||||
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function setBilling(opts: {
|
||||
subscription: SubscriptionState | null
|
||||
planBilling?: PlanBillingState | null
|
||||
}) {
|
||||
useBillingStore.setState({
|
||||
subscription: opts.subscription,
|
||||
planBilling:
|
||||
opts.planBilling ??
|
||||
({
|
||||
display_name: 'Pro',
|
||||
description: 'Pro plan',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
} as PlanBillingState),
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<BillingPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BillingPage', () => {
|
||||
beforeEach(() => {
|
||||
getPortalSession.mockReset()
|
||||
toastError.mockReset()
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders subscription summary from useBillingStore', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
// Seats shown
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'trialing',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-22T00:00:00Z',
|
||||
current_period_end: '2026-05-06T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
|
||||
const pickPlan = screen.getByTestId('select-plan-link')
|
||||
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
|
||||
})
|
||||
|
||||
it('shows past-due banner with update payment CTA when status=past_due', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'past_due',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders complimentary message and hides CTAs when complimentary', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'complimentary',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: null,
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: null,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders canceled message + Pick a plan CTA when canceled', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'canceled',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-03-01T00:00:00Z',
|
||||
current_period_end: '2026-04-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows toast when portal session fails with no_stripe_customer', async () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
getPortalSession.mockRejectedValueOnce(
|
||||
new BillingPortalError('no_stripe_customer'),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
fireEvent.click(screen.getByTestId('manage-billing-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Complete checkout first to access billing portal.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { SelectPlanPage } from '../SelectPlanPage'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
|
||||
vi.mock('@/api/billing', () => ({
|
||||
billingApi: {
|
||||
createCheckoutSession: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/api/plans', () => ({
|
||||
plansApi: {
|
||||
getPublic: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { plansApi } from '@/api/plans'
|
||||
|
||||
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
|
||||
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const PLAN_FIXTURE = [
|
||||
{
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs.',
|
||||
monthly_price_cents: 1900,
|
||||
annual_price_cents: 19000,
|
||||
max_seats: 1,
|
||||
sort_order: 1,
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing teams.',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
max_seats: 5,
|
||||
sort_order: 2,
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom.',
|
||||
monthly_price_cents: null,
|
||||
annual_price_cents: null,
|
||||
max_seats: null,
|
||||
sort_order: 3,
|
||||
is_public: true,
|
||||
},
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<SelectPlanPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SelectPlanPage', () => {
|
||||
// Stub window.location.href setter so we can assert without a real navigation.
|
||||
let assignedHref: string | null = null
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
getPublic.mockReset()
|
||||
createCheckoutSession.mockReset()
|
||||
assignedHref = null
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
get href() {
|
||||
return assignedHref ?? originalLocation.href
|
||||
},
|
||||
set href(v: string) {
|
||||
assignedHref = v
|
||||
},
|
||||
},
|
||||
})
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders plan cards from plansApi', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
|
||||
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Bump seats and switch to annual.
|
||||
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByTestId('interval-annual'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('plan-cta-pro'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCheckoutSession).toHaveBeenCalledWith({
|
||||
plan: 'pro',
|
||||
seats: 3,
|
||||
billing_interval: 'annual',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
|
||||
})
|
||||
})
|
||||
|
||||
it('Talk to sales links to /contact-sales for enterprise', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
|
||||
expect(cta.getAttribute('href')).toBe('/contact-sales')
|
||||
})
|
||||
|
||||
it('marks the active current plan as Current plan and disables its CTA', async () => {
|
||||
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
||||
useBillingStore.setState({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
|
||||
})
|
||||
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
|
||||
expect(cta).toBeDisabled()
|
||||
})
|
||||
})
|
||||
31
frontend/src/pages/welcome/WelcomeRouter.tsx
Normal file
31
frontend/src/pages/welcome/WelcomeRouter.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoader } from '@/components/common/PageLoader'
|
||||
|
||||
/**
|
||||
* `/welcome` index — redirect to the next incomplete step (or `/` if done /
|
||||
* dismissed). Decision table:
|
||||
*
|
||||
* onboarding_dismissed === true → /
|
||||
* onboarding_step_completed >= 3 → /
|
||||
* onboarding_step_completed === null/0 → /welcome/step-1
|
||||
* onboarding_step_completed === 1 → /welcome/step-2
|
||||
* onboarding_step_completed === 2 → /welcome/step-3
|
||||
*/
|
||||
export function WelcomeRouter() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// Auth gate sits above us — but if the user object is still loading, render
|
||||
// the page loader rather than racing past the redirect.
|
||||
if (!user) return <PageLoader />
|
||||
|
||||
if (user.onboarding_dismissed) return <Navigate to="/" replace />
|
||||
|
||||
const completed = user.onboarding_step_completed ?? 0
|
||||
if (completed >= 3) return <Navigate to="/" replace />
|
||||
if (completed === 2) return <Navigate to="/welcome/step-3" replace />
|
||||
if (completed === 1) return <Navigate to="/welcome/step-2" replace />
|
||||
return <Navigate to="/welcome/step-1" replace />
|
||||
}
|
||||
|
||||
export default WelcomeRouter
|
||||
248
frontend/src/pages/welcome/WelcomeStep1.tsx
Normal file
248
frontend/src/pages/welcome/WelcomeStep1.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import {
|
||||
onboardingApi,
|
||||
type RoleAtSignup,
|
||||
type TeamSizeBucket,
|
||||
} from '@/api/onboarding'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TEAM_SIZE_OPTIONS: { value: TeamSizeBucket; label: string }[] = [
|
||||
{ value: '1-2', label: '1–2' },
|
||||
{ value: '3-5', label: '3–5' },
|
||||
{ value: '6-10', label: '6–10' },
|
||||
{ value: '11-25', label: '11–25' },
|
||||
{ value: '26+', label: '26+' },
|
||||
]
|
||||
|
||||
const ROLE_OPTIONS: { value: RoleAtSignup; label: string }[] = [
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'lead_tech', label: 'Lead Tech' },
|
||||
{ value: 'tech', label: 'Tech' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
/**
|
||||
* `/welcome/step-1` — first step of the welcome wizard. Captures shop context
|
||||
* (company name, team size, role). Persists server-side before navigating.
|
||||
*/
|
||||
export function WelcomeStep1() {
|
||||
const navigate = useNavigate()
|
||||
const account = useAuthStore((s) => s.account)
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>(account?.name ?? '')
|
||||
const [teamSize, setTeamSize] = useState<TeamSizeBucket | ''>('')
|
||||
const [role, setRole] = useState<RoleAtSignup | ''>('')
|
||||
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isBusy = submitting !== null
|
||||
|
||||
const handleContinue = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('continue')
|
||||
try {
|
||||
await onboardingApi.updateStep({
|
||||
step: 1,
|
||||
action: 'complete',
|
||||
data: {
|
||||
company_name: companyName.trim() || undefined,
|
||||
team_size_bucket: teamSize || undefined,
|
||||
role_at_signup: role || undefined,
|
||||
},
|
||||
})
|
||||
await fetchUser()
|
||||
navigate('/welcome/step-2')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipStep = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('skip')
|
||||
try {
|
||||
await onboardingApi.updateStep({ step: 1, action: 'skip' })
|
||||
await fetchUser()
|
||||
navigate('/welcome/step-2')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissRest = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('dismiss')
|
||||
try {
|
||||
await onboardingApi.dismissRest()
|
||||
await fetchUser()
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = cn(
|
||||
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Step 1 of 3
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
||||
Your shop
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
A couple of quick questions so we can tailor ResolutionFlow to your team.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleContinue}
|
||||
className="rounded-2xl border border-border bg-card p-6 space-y-5"
|
||||
data-testid="welcome-step-1-form"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="company_name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Company name
|
||||
</label>
|
||||
<input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Acme MSP"
|
||||
data-testid="welcome-step-1-company-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="team_size"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Team size
|
||||
</label>
|
||||
<select
|
||||
id="team_size"
|
||||
name="team_size"
|
||||
value={teamSize}
|
||||
onChange={(e) => setTeamSize(e.target.value as TeamSizeBucket | '')}
|
||||
className={inputClass}
|
||||
data-testid="welcome-step-1-team-size"
|
||||
>
|
||||
<option value="">Select team size…</option>
|
||||
{TEAM_SIZE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="role"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Your role
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as RoleAtSignup | '')}
|
||||
className={inputClass}
|
||||
data-testid="welcome-step-1-role"
|
||||
>
|
||||
<option value="">Select your role…</option>
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" data-testid="welcome-step-1-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-1-continue"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'continue' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkipStep}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-1-skip"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'skip' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissRest}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-1-dismiss-rest"
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:underline',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeStep1
|
||||
208
frontend/src/pages/welcome/WelcomeStep2.tsx
Normal file
208
frontend/src/pages/welcome/WelcomeStep2.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { onboardingApi, type PrimaryPsa } from '@/api/onboarding'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = [
|
||||
{ value: 'connectwise', label: 'ConnectWise', description: 'Manage / PSA' },
|
||||
{ value: 'autotask', label: 'Autotask', description: 'Datto Autotask PSA' },
|
||||
{ value: 'halopsa', label: 'HaloPSA', description: 'Halo Service Solutions' },
|
||||
{ value: 'none', label: 'No PSA yet', description: "We'll add one later" },
|
||||
]
|
||||
|
||||
/**
|
||||
* `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the
|
||||
* shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect
|
||||
* now" link that navigates out to `/account/integrations`. The wizard's
|
||||
* primary action is "Continue" — credential entry is intentionally OUT of
|
||||
* the wizard (per spec).
|
||||
*/
|
||||
export function WelcomeStep2() {
|
||||
const navigate = useNavigate()
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
|
||||
const [primaryPsa, setPrimaryPsa] = useState<PrimaryPsa | null>(null)
|
||||
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isBusy = submitting !== null
|
||||
const showConnectNow = primaryPsa !== null && primaryPsa !== 'none'
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('continue')
|
||||
try {
|
||||
await onboardingApi.updateStep({
|
||||
step: 2,
|
||||
action: 'complete',
|
||||
data: primaryPsa ? { primary_psa: primaryPsa } : undefined,
|
||||
})
|
||||
await fetchUser()
|
||||
navigate('/welcome/step-3')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipStep = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('skip')
|
||||
try {
|
||||
await onboardingApi.updateStep({ step: 2, action: 'skip' })
|
||||
await fetchUser()
|
||||
navigate('/welcome/step-3')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissRest = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('dismiss')
|
||||
try {
|
||||
await onboardingApi.dismissRest()
|
||||
await fetchUser()
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Step 2 of 3
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
||||
Your PSA
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Pick the PSA your team uses today. We'll wire it up later — no
|
||||
credentials needed yet.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="rounded-2xl border border-border bg-card p-6 space-y-5"
|
||||
data-testid="welcome-step-2-form"
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Primary PSA"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
>
|
||||
{PSA_OPTIONS.map((opt) => {
|
||||
const selected = primaryPsa === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setPrimaryPsa(opt.value)}
|
||||
disabled={isBusy}
|
||||
data-testid={`welcome-step-2-tile-${opt.value}`}
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-left transition-colors btn-press',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'disabled:opacity-60',
|
||||
selected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card hover:border-primary/40 hover:bg-foreground/5',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{opt.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{opt.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showConnectNow && (
|
||||
<div className="pt-1">
|
||||
<Link
|
||||
to="/account/integrations"
|
||||
data-testid="welcome-step-2-connect-now"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Connect now →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" data-testid="welcome-step-2-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-2-continue"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'continue' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkipStep}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-2-skip"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'skip' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Skip this step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissRest}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-2-dismiss-rest"
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:underline',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeStep2
|
||||
374
frontend/src/pages/welcome/WelcomeStep3.tsx
Normal file
374
frontend/src/pages/welcome/WelcomeStep3.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, Plus, X } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { onboardingApi } from '@/api/onboarding'
|
||||
import { accountsApi, type BulkInviteRow } from '@/api/accounts'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MAX_ROWS = 10
|
||||
const DEFAULT_ROW_COUNT = 3
|
||||
|
||||
type RowRole = 'engineer' | 'viewer'
|
||||
|
||||
interface InviteRow {
|
||||
email: string
|
||||
role: RowRole
|
||||
/**
|
||||
* Server-returned per-row error (from `failed[]`). Kept on the row so
|
||||
* users can fix and retry without losing the rest of their input.
|
||||
*/
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: { value: RowRole; label: string }[] = [
|
||||
{ value: 'engineer', label: 'Tech' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function makeEmptyRow(): InviteRow {
|
||||
return { email: '', role: 'engineer' }
|
||||
}
|
||||
|
||||
/**
|
||||
* `/welcome/step-3` — final step of the welcome wizard. Captures up to
|
||||
* `MAX_ROWS` teammate invites. On submit:
|
||||
*
|
||||
* 1. POST `/accounts/me/invites/bulk` with populated rows.
|
||||
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
|
||||
* 3. Navigate to `/?welcome=true` and fire a "You're all set" toast.
|
||||
*
|
||||
* Partial-failure UX: rows in `failed[]` keep their input and show an
|
||||
* inline error. The wizard does NOT auto-advance when there are failures —
|
||||
* the user can edit and retry, OR click "Continue anyway" to mark step 3
|
||||
* complete and head to the dashboard.
|
||||
*
|
||||
* Empty rows are filtered before submit, so empty-form + "Send" is a no-op
|
||||
* that just marks the step complete. (Skip does the same with `action: skip`.)
|
||||
*/
|
||||
export function WelcomeStep3() {
|
||||
const navigate = useNavigate()
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() =>
|
||||
Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow),
|
||||
)
|
||||
const [submitting, setSubmitting] = useState<
|
||||
'send' | 'skip' | 'dismiss' | 'continue-anyway' | null
|
||||
>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false)
|
||||
|
||||
const isBusy = submitting !== null
|
||||
|
||||
const updateRow = (idx: number, patch: Partial<InviteRow>) => {
|
||||
setRows((prev) =>
|
||||
prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)),
|
||||
)
|
||||
}
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
setRows((prev) => {
|
||||
if (prev.length <= 1) return [makeEmptyRow()]
|
||||
return prev.filter((_, i) => i !== idx)
|
||||
})
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) =>
|
||||
prev.length >= MAX_ROWS ? prev : [...prev, makeEmptyRow()],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate populated rows. Empty-email rows are dropped silently.
|
||||
* Returns either the list of valid rows OR a per-index error map.
|
||||
*/
|
||||
const validatePopulated = useMemo(
|
||||
() => () => {
|
||||
const errs: Record<number, string> = {}
|
||||
const populated: { idx: number; row: BulkInviteRow }[] = []
|
||||
rows.forEach((row, idx) => {
|
||||
const email = row.email.trim()
|
||||
if (!email) return
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
errs[idx] = 'Invalid email'
|
||||
return
|
||||
}
|
||||
populated.push({ idx, row: { email, role: row.role } })
|
||||
})
|
||||
return { errs, populated }
|
||||
},
|
||||
[rows],
|
||||
)
|
||||
|
||||
const completeWizardAndExit = async () => {
|
||||
await onboardingApi.updateStep({ step: 3, action: 'complete' })
|
||||
await fetchUser()
|
||||
toast.success("You're all set!")
|
||||
navigate('/?welcome=true')
|
||||
}
|
||||
|
||||
const handleSendInvites = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
|
||||
const { errs, populated } = validatePopulated()
|
||||
if (Object.keys(errs).length > 0) {
|
||||
// Surface client-side validation errors inline.
|
||||
setRows((prev) =>
|
||||
prev.map((row, idx) =>
|
||||
errs[idx] ? { ...row, error: errs[idx] } : { ...row, error: undefined },
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting('send')
|
||||
try {
|
||||
let failedSet = new Map<string, string>()
|
||||
if (populated.length > 0) {
|
||||
const result = await accountsApi.bulkInvite(populated.map((p) => p.row))
|
||||
failedSet = new Map(result.failed.map((f) => [f.email, f.error]))
|
||||
}
|
||||
|
||||
if (failedSet.size > 0) {
|
||||
// Stamp errors on the matching rows; do NOT auto-advance.
|
||||
setRows((prev) =>
|
||||
prev.map((row) => {
|
||||
const email = row.email.trim()
|
||||
const err = email ? failedSet.get(email) : undefined
|
||||
return { ...row, error: err }
|
||||
}),
|
||||
)
|
||||
setHasUnresolvedFailures(true)
|
||||
setSubmitting(null)
|
||||
return
|
||||
}
|
||||
|
||||
// All-clear (or zero invites sent): mark step complete and exit.
|
||||
await completeWizardAndExit()
|
||||
} catch {
|
||||
setError('Could not send invites. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContinueAnyway = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('continue-anyway')
|
||||
try {
|
||||
await completeWizardAndExit()
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkipStep = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('skip')
|
||||
try {
|
||||
await onboardingApi.updateStep({ step: 3, action: 'skip' })
|
||||
await fetchUser()
|
||||
toast.success("You're all set!")
|
||||
navigate('/?welcome=true')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissRest = async () => {
|
||||
if (isBusy) return
|
||||
setError(null)
|
||||
setSubmitting('dismiss')
|
||||
try {
|
||||
await onboardingApi.dismissRest()
|
||||
await fetchUser()
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Could not save. Please try again.')
|
||||
setSubmitting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = cn(
|
||||
'block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Step 3 of 3
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
||||
Invite your team
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Add up to {MAX_ROWS} teammates. They'll get an email with a link to
|
||||
join. Leave blank to do this later.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="rounded-2xl border border-border bg-card p-6 space-y-4"
|
||||
data-testid="welcome-step-3-form"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="space-y-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={row.email}
|
||||
onChange={(e) => updateRow(idx, { email: e.target.value, error: undefined })}
|
||||
placeholder="teammate@example.com"
|
||||
className={cn(inputClass, 'flex-1')}
|
||||
data-testid={`welcome-step-3-email-${idx}`}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<select
|
||||
value={row.role}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, { role: e.target.value as RowRole })
|
||||
}
|
||||
className={cn(inputClass, 'w-32 flex-shrink-0')}
|
||||
data-testid={`welcome-step-3-role-${idx}`}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(idx)}
|
||||
disabled={isBusy || rows.length <= 1}
|
||||
data-testid={`welcome-step-3-remove-${idx}`}
|
||||
aria-label="Remove row"
|
||||
className={cn(
|
||||
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
|
||||
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-30 disabled:hover:bg-transparent',
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{row.error && (
|
||||
<p
|
||||
className="pl-1 text-xs text-red-400"
|
||||
data-testid={`welcome-step-3-row-error-${idx}`}
|
||||
>
|
||||
{row.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
disabled={isBusy || rows.length >= MAX_ROWS}
|
||||
data-testid="welcome-step-3-add-row"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-xl px-2 py-1 text-xs font-medium',
|
||||
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add another
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400" data-testid="welcome-step-3-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendInvites}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-3-send"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'send' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Send invites and continue
|
||||
</button>
|
||||
{hasUnresolvedFailures && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinueAnyway}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-3-continue-anyway"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
|
||||
'bg-card border border-border text-foreground hover:bg-foreground/5',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'continue-anyway' && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Continue anyway
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkipStep}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-3-skip"
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground hover:underline',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'skip' ? 'Saving…' : 'Skip'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissRest}
|
||||
disabled={isBusy}
|
||||
data-testid="welcome-step-3-dismiss-rest"
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:underline',
|
||||
'disabled:opacity-60',
|
||||
)}
|
||||
>
|
||||
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeStep3
|
||||
125
frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx
Normal file
125
frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { WelcomeRouter } from '../WelcomeRouter'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type { User } from '@/types'
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'owner',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: null,
|
||||
onboarding_step_completed: null,
|
||||
onboarding_dismissed: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/welcome']}>
|
||||
<Routes>
|
||||
<Route path="/welcome" element={<WelcomeRouter />} />
|
||||
<Route path="/welcome/step-1" element={<div>step-1</div>} />
|
||||
<Route path="/welcome/step-2" element={<div>step-2</div>} />
|
||||
<Route path="/welcome/step-3" element={<div>step-3</div>} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WelcomeRouter', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
account: null,
|
||||
subscription: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('redirects to step-1 on null onboarding_step_completed', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ onboarding_step_completed: null }),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to step-1 when onboarding_step_completed is 0', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ onboarding_step_completed: 0 }),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to step-2 when onboarding_step_completed is 1', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ onboarding_step_completed: 1 }),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to step-3 when onboarding_step_completed is 2', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ onboarding_step_completed: 2 }),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to / when onboarding_step_completed >= 3', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ onboarding_step_completed: 3 }),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to / when onboarding_dismissed is true', async () => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser({
|
||||
onboarding_step_completed: 1,
|
||||
onboarding_dismissed: true,
|
||||
}),
|
||||
})
|
||||
renderRouter()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
189
frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
Normal file
189
frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { WelcomeStep1 } from '../WelcomeStep1'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { onboardingApi } from '@/api/onboarding'
|
||||
import type { Account, User } from '@/types'
|
||||
|
||||
vi.mock('@/api/onboarding', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
|
||||
'@/api/onboarding',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
onboardingApi: {
|
||||
...actual.onboardingApi,
|
||||
updateStep: vi.fn(),
|
||||
dismissRest: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'owner',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: null,
|
||||
onboarding_step_completed: null,
|
||||
onboarding_dismissed: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
id: 'acct-1',
|
||||
name: 'Acme MSP',
|
||||
display_code: 'ACME',
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/welcome/step-1']}>
|
||||
<Routes>
|
||||
<Route path="/welcome/step-1" element={<WelcomeStep1 />} />
|
||||
<Route path="/welcome/step-2" element={<div>step-2</div>} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WelcomeStep1', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser(),
|
||||
account: makeAccount(),
|
||||
subscription: null,
|
||||
token: null,
|
||||
isAuthenticated: true,
|
||||
// Stub fetchUser so it doesn't try to hit the network in jsdom.
|
||||
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
|
||||
onboarding_step_completed: 1,
|
||||
onboarding_dismissed: false,
|
||||
})
|
||||
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
|
||||
onboarding_step_completed: null,
|
||||
onboarding_dismissed: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('pre-fills the company name from the auth store account', () => {
|
||||
renderPage()
|
||||
const input = screen.getByTestId('welcome-step-1-company-name') as HTMLInputElement
|
||||
expect(input.value).toBe('Acme MSP')
|
||||
})
|
||||
|
||||
it('Continue persists data and navigates to /welcome/step-2', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const teamSize = screen.getByTestId('welcome-step-1-team-size') as HTMLSelectElement
|
||||
await user.selectOptions(teamSize, '3-5')
|
||||
const role = screen.getByTestId('welcome-step-1-role') as HTMLSelectElement
|
||||
await user.selectOptions(role, 'owner')
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-1-continue'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 1,
|
||||
action: 'complete',
|
||||
data: {
|
||||
company_name: 'Acme MSP',
|
||||
team_size_bucket: '3-5',
|
||||
role_at_signup: 'owner',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('Skip this step calls updateStep with action=skip and navigates to /welcome/step-2', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-1-skip'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 1,
|
||||
action: 'skip',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('Skip-the-rest dismisses and navigates to /', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const dismiss = screen.getByTestId('welcome-step-1-dismiss-rest')
|
||||
// Sanity check: it's a quiet text link, not a primary button.
|
||||
expect(dismiss.className).toMatch(/text-muted-foreground/)
|
||||
expect(dismiss.className).toMatch(/hover:underline/)
|
||||
expect(dismiss.className).toMatch(/text-xs/)
|
||||
expect(dismiss.className).not.toMatch(/bg-primary/)
|
||||
|
||||
await user.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.dismissRest).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error when the persist call fails and stays on the page', async () => {
|
||||
vi.mocked(onboardingApi.updateStep).mockRejectedValueOnce(
|
||||
new Error('boom'),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-1-continue'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-step-1-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should not have navigated.
|
||||
expect(screen.queryByText('step-2')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
174
frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
Normal file
174
frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { WelcomeStep2 } from '../WelcomeStep2'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { onboardingApi } from '@/api/onboarding'
|
||||
import type { Account, User } from '@/types'
|
||||
|
||||
vi.mock('@/api/onboarding', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
|
||||
'@/api/onboarding',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
onboardingApi: {
|
||||
...actual.onboardingApi,
|
||||
updateStep: vi.fn(),
|
||||
dismissRest: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'owner',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: null,
|
||||
onboarding_step_completed: 1,
|
||||
onboarding_dismissed: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
id: 'acct-1',
|
||||
name: 'Acme MSP',
|
||||
display_code: 'ACME',
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/welcome/step-2']}>
|
||||
<Routes>
|
||||
<Route path="/welcome/step-2" element={<WelcomeStep2 />} />
|
||||
<Route path="/welcome/step-3" element={<div>step-3</div>} />
|
||||
<Route path="/account/integrations" element={<div>integrations</div>} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WelcomeStep2', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser(),
|
||||
account: makeAccount(),
|
||||
subscription: null,
|
||||
token: null,
|
||||
isAuthenticated: true,
|
||||
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
|
||||
onboarding_step_completed: 2,
|
||||
onboarding_dismissed: false,
|
||||
})
|
||||
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
|
||||
onboarding_step_completed: null,
|
||||
onboarding_dismissed: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('selecting PSA persists primary_psa', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-2-tile-connectwise'))
|
||||
// Selecting a real PSA reveals the inline "Connect now" link.
|
||||
expect(screen.getByTestId('welcome-step-2-connect-now')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-2-continue'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 2,
|
||||
action: 'complete',
|
||||
data: { primary_psa: 'connectwise' },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('Skip advances without writing primary_psa', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-2-skip'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 2,
|
||||
action: 'skip',
|
||||
})
|
||||
})
|
||||
|
||||
// Confirm no `data` key on the call (skip doesn't persist primary_psa).
|
||||
const call = vi.mocked(onboardingApi.updateStep).mock.calls[0]?.[0]
|
||||
expect(call?.data).toBeUndefined()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('step-3')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('"No PSA yet" tile does NOT show the Connect now link', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-2-tile-none'))
|
||||
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('default action is Continue (not Connect now)', () => {
|
||||
renderPage()
|
||||
// Continue is rendered as a primary button.
|
||||
const continueBtn = screen.getByTestId('welcome-step-2-continue')
|
||||
expect(continueBtn.className).toMatch(/bg-primary/)
|
||||
// Connect-now is hidden until a real PSA is picked.
|
||||
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Skip-the-rest dismisses and navigates to /', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-2-dismiss-rest'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.dismissRest).toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
279
frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
Normal file
279
frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { WelcomeStep3 } from '../WelcomeStep3'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { onboardingApi } from '@/api/onboarding'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import type { Account, User } from '@/types'
|
||||
|
||||
vi.mock('@/api/onboarding', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
|
||||
'@/api/onboarding',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
onboardingApi: {
|
||||
...actual.onboardingApi,
|
||||
updateStep: vi.fn(),
|
||||
dismissRest: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/accounts', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/accounts')>(
|
||||
'@/api/accounts',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
accountsApi: {
|
||||
...actual.accountsApi,
|
||||
bulkInvite: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
promise: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function makeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'engineer',
|
||||
is_super_admin: false,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
account_id: 'acct-1',
|
||||
account_role: 'owner',
|
||||
team_id: null,
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
last_login: null,
|
||||
phone: null,
|
||||
job_title: null,
|
||||
timezone: 'UTC',
|
||||
avatar_url: null,
|
||||
email_verified_at: null,
|
||||
onboarding_step_completed: 2,
|
||||
onboarding_dismissed: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAccount(overrides: Partial<Account> = {}): Account {
|
||||
return {
|
||||
id: 'acct-1',
|
||||
name: 'Acme MSP',
|
||||
display_code: 'ACME',
|
||||
owner_id: 'user-1',
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/welcome/step-3']}>
|
||||
<Routes>
|
||||
<Route path="/welcome/step-3" element={<WelcomeStep3 />} />
|
||||
<Route path="/" element={<div>dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WelcomeStep3', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: makeUser(),
|
||||
account: makeAccount(),
|
||||
subscription: null,
|
||||
token: null,
|
||||
isAuthenticated: true,
|
||||
fetchUser: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
|
||||
onboarding_step_completed: 3,
|
||||
onboarding_dismissed: false,
|
||||
})
|
||||
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
|
||||
onboarding_step_completed: null,
|
||||
onboarding_dismissed: true,
|
||||
})
|
||||
vi.mocked(accountsApi.bulkInvite).mockResolvedValue({
|
||||
created: [],
|
||||
failed: [],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('valid emails create invites and complete wizard', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
|
||||
created: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
account_id: 'acct-1',
|
||||
email: 'a@example.com',
|
||||
role: 'engineer',
|
||||
code: 'c1',
|
||||
expires_at: null,
|
||||
used_at: null,
|
||||
created_at: '2026-05-06T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
account_id: 'acct-1',
|
||||
email: 'b@example.com',
|
||||
role: 'viewer',
|
||||
code: 'c2',
|
||||
expires_at: null,
|
||||
used_at: null,
|
||||
created_at: '2026-05-06T00:00:00Z',
|
||||
},
|
||||
],
|
||||
failed: [],
|
||||
})
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'a@example.com')
|
||||
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'b@example.com')
|
||||
await user.selectOptions(screen.getByTestId('welcome-step-3-role-1'), 'viewer')
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(accountsApi.bulkInvite).toHaveBeenCalledWith([
|
||||
{ email: 'a@example.com', role: 'engineer' },
|
||||
{ email: 'b@example.com', role: 'viewer' },
|
||||
])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 3,
|
||||
action: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('partial-failure shows inline error per failed email', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
|
||||
created: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
account_id: 'acct-1',
|
||||
email: 'good@example.com',
|
||||
role: 'engineer',
|
||||
code: 'c1',
|
||||
expires_at: null,
|
||||
used_at: null,
|
||||
created_at: '2026-05-06T00:00:00Z',
|
||||
},
|
||||
],
|
||||
failed: [
|
||||
{ email: 'bad@example.com', error: 'Email already invited' },
|
||||
],
|
||||
})
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'good@example.com')
|
||||
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'bad@example.com')
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(accountsApi.bulkInvite).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The bad-email row shows the error text.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-step-3-row-error-1')).toHaveTextContent(
|
||||
/already invited/i,
|
||||
)
|
||||
})
|
||||
|
||||
// Wizard did NOT auto-advance — onboarding-step is unchanged.
|
||||
expect(onboardingApi.updateStep).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('dashboard')).not.toBeInTheDocument()
|
||||
|
||||
// "Continue anyway" is offered.
|
||||
expect(screen.getByTestId('welcome-step-3-continue-anyway')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('empty + Skip advances without sending invites', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.click(screen.getByTestId('welcome-step-3-skip'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 3,
|
||||
action: 'skip',
|
||||
})
|
||||
})
|
||||
|
||||
// No bulk-invite call.
|
||||
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('empty + Send is a no-op bulk call but still completes the step', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
// All rows blank — Send should skip the bulk call entirely and just
|
||||
// mark the step complete.
|
||||
await user.click(screen.getByTestId('welcome-step-3-send'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
|
||||
step: 3,
|
||||
action: 'complete',
|
||||
})
|
||||
})
|
||||
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('+ Add another adds a row, capped at 10', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
// Starts with 3 default rows.
|
||||
expect(screen.getByTestId('welcome-step-3-email-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('welcome-step-3-email-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('welcome-step-3-email-2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('welcome-step-3-email-3')).not.toBeInTheDocument()
|
||||
|
||||
const addBtn = screen.getByTestId('welcome-step-3-add-row')
|
||||
// Click 7 more times → 10 total.
|
||||
for (let i = 0; i < 7; i++) await user.click(addBtn)
|
||||
expect(screen.getByTestId('welcome-step-3-email-9')).toBeInTheDocument()
|
||||
// Capped — button disabled at 10.
|
||||
expect(addBtn).toBeDisabled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user