Files
resolutionflow/frontend/src/pages/ContactSalesPage.tsx
Michael Chihlas ba45cfeec1
All checks were successful
CI / frontend (push) Successful in 6m47s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m16s
CI / backend (push) Successful in 11m13s
feat(legal): add /policies, /contact, /promotions pages + MarketingFooter (#165)
Adds the three legal/contact pages needed for Stripe live-mode site review: /policies (consolidated customer policies — refunds, cancellation, legal restrictions, promotions), /contact (phone (470) 949-4131 + support/sales/billing/security inboxes), /promotions (stub satisfying §6.2 cross-ref).

Extracts the existing landing footer into components/common/MarketingFooter.tsx and mounts it on /pricing and /contact-sales so all four legal links are reachable from every marketing surface.

Privacy and Terms closing sections updated to point at /contact + /policies; stale hello@ mailto removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-12 05:23:43 +00:00

400 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { MarketingFooter } from '@/components/common/MarketingFooter'
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>
<MarketingFooter />
</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