feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

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:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View 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: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ 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&rsquo;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 &mdash; we&rsquo;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