feat(sales): add /contact-sales form + landing page CTA
Public Talk-to-Sales surface and a "See pricing" hero CTA on the marketing landing page. Phase 2 Task 43 of self-serve signup. - frontend/src/api/sales.ts: salesApi.createLead -> POST /sales-leads. - ContactSalesPage at /contact-sales (public, gated by self_serve_enabled with a 404-style fallback). Form fields: name, work email, company, team size (1-2 / 3-5 / 6-10 / 11-25 / 26+), and an optional "what brought you here?" textarea -> message. Submit button disabled while in flight to block duplicate submissions. - Confirmation surface replaces the form on success. Calendly block is hidden when VITE_CALENDLY_URL is unset. - detectSource(): 'pricing_page' if document.referrer contains '/pricing', else 'landing_page'. Server emits the canonical PostHog talk_to_sales_form_submitted event with this source. - LandingPage: new "See pricing" hero CTA gated by useAppConfig(). self_serve_enabled. - frontend/.env.example + Dockerfile: VITE_CALENDLY_URL ARG/ENV. - Tests: ContactSalesPage submit/confirmation, Calendly hide-when-unset, in-flight de-dup, 404 when self-serve off; LandingPage CTA on/off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE=
|
|||||||
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
|
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
|
||||||
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
|
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
|
||||||
VITE_SELF_SERVE_ENABLED=false
|
VITE_SELF_SERVE_ENABLED=false
|
||||||
|
|
||||||
|
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
|
||||||
|
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
|
||||||
|
# requires ARG+ENV in frontend/Dockerfile.
|
||||||
|
VITE_CALENDLY_URL=
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID
|
|||||||
ARG VITE_MS_CLIENT_ID
|
ARG VITE_MS_CLIENT_ID
|
||||||
ARG VITE_OAUTH_REDIRECT_BASE
|
ARG VITE_OAUTH_REDIRECT_BASE
|
||||||
ARG VITE_SELF_SERVE_ENABLED
|
ARG VITE_SELF_SERVE_ENABLED
|
||||||
|
ARG VITE_CALENDLY_URL
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
||||||
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
|
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
|
||||||
@@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
|||||||
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
|
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
|
||||||
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
|
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
|
||||||
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
|
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
|
||||||
|
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export { default as accountsApi } from './accounts'
|
|||||||
export { default as billingApi } from './billing'
|
export { default as billingApi } from './billing'
|
||||||
export { default as plansApi } from './plans'
|
export { default as plansApi } from './plans'
|
||||||
export type { PublicPlanResponse } from './plans'
|
export type { PublicPlanResponse } from './plans'
|
||||||
|
export { default as salesApi } from './sales'
|
||||||
|
export type {
|
||||||
|
SalesLeadCreatePayload,
|
||||||
|
SalesLeadCreateResponse,
|
||||||
|
SalesLeadSource,
|
||||||
|
} from './sales'
|
||||||
export { default as usageApi } from './usage'
|
export { default as usageApi } from './usage'
|
||||||
export { default as adminApi } from './admin'
|
export { default as adminApi } from './admin'
|
||||||
export { treeMarkdownApi } from './treeMarkdown'
|
export { treeMarkdownApi } from './treeMarkdown'
|
||||||
|
|||||||
32
frontend/src/api/sales.ts
Normal file
32
frontend/src/api/sales.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
|
||||||
|
|
||||||
|
export interface SalesLeadCreatePayload {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
company: string
|
||||||
|
team_size?: string
|
||||||
|
message?: string
|
||||||
|
source: SalesLeadSource
|
||||||
|
posthog_distinct_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesLeadCreateResponse {
|
||||||
|
id: string
|
||||||
|
status: 'received'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesApi = {
|
||||||
|
/**
|
||||||
|
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
|
||||||
|
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
|
||||||
|
* — frontend should NOT also fire this event.
|
||||||
|
*/
|
||||||
|
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
|
||||||
|
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default salesApi
|
||||||
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,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||||
import '@/styles/landing.css'
|
import '@/styles/landing.css'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
@@ -29,6 +30,7 @@ const FAQ_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const appConfig = useAppConfig()
|
||||||
const [navScrolled, setNavScrolled] = useState(false)
|
const [navScrolled, setNavScrolled] = useState(false)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [betaEmail, setBetaEmail] = useState('')
|
const [betaEmail, setBetaEmail] = useState('')
|
||||||
@@ -174,6 +176,15 @@ export default function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="landing-hero-actions">
|
<div className="landing-hero-actions">
|
||||||
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
|
<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>
|
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="landing-hero-credibility">
|
<p className="landing-hero-credibility">
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,7 @@ const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPag
|
|||||||
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
|
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
|
||||||
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
|
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
|
||||||
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
|
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
|
||||||
|
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
|
||||||
|
|
||||||
// Standalone auth pages
|
// Standalone auth pages
|
||||||
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
|
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
|
||||||
@@ -137,6 +138,11 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
element: page(PricingPage),
|
element: page(PricingPage),
|
||||||
errorElement: <RouteError />,
|
errorElement: <RouteError />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/contact-sales',
|
||||||
|
element: page(ContactSalesPage),
|
||||||
|
errorElement: <RouteError />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
|
|||||||
Reference in New Issue
Block a user