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:
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user