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>
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
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: '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>
|
||
|
||
<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
|