mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
BOOKING is Hanzo AI's page at cal.com, where a visitor picks the 15- or 30-minute call. The sales form and "Book a 30-minute call" open INTRO, the 30-minute event page, which takes the name, email and notes the form fills in; the profile does not. A click to cal.com/hanzo is the sales_contacted moment, as cal.hanzo.ai was. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
279 lines
15 KiB
TypeScript
279 lines
15 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { answer } from '../../lib/edge'
|
|
import { serveExport } from './export'
|
|
import { featured, stories } from '../../lib/data/customers'
|
|
import { config, person } from './heard'
|
|
|
|
/**
|
|
* THE SALES PAGE FILES A LEAD AND SAYS SO.
|
|
*
|
|
* /contact-sales asks five things in one card: name, work email, company, team
|
|
* size and what they want to build. The answers become a lead in Hanzo's own
|
|
* CRM (POST /v1/marketing/leads), which states it server-side with the visit
|
|
* the page sends — consent, the ad click, the browser ids — so the moment
|
|
* reaches the ad tags under the lead's own id and our stream is not told twice.
|
|
* The card then says the request is in and offers the intro call at
|
|
* cal.com/hanzo, filled in with their details and the lead's reference. A lead
|
|
* that cannot be filed still counts, and the card offers the call and an email
|
|
* that carry the answers. Whatever fills the hidden field is thanked and files
|
|
* nothing. Measured on the shipped bytes at a desktop and a phone width, with
|
|
* the lead endpoint and the calendar answered here.
|
|
*/
|
|
|
|
const LEAD = 'lead_0123456789abcdef01234567'
|
|
|
|
type Filed = { body: Record<string, unknown> | null; stream: string[] }
|
|
|
|
/** Answer the lead endpoint with `status`, the calendar with its own address,
|
|
* record what our stream is told, and write every GA4 event and Meta call
|
|
* down where it outlives the trip to the calendar. gtag stands up the way
|
|
* gtag.js leaves it, so GA4 is the browser's to send. */
|
|
async function arrive(page: Page, base: string, status = 201): Promise<Filed> {
|
|
const filed: Filed = { body: null, stream: [] }
|
|
await person(page)
|
|
await config(page)
|
|
await page.route('https://www.googletagmanager.com/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
|
|
)
|
|
await page.route('https://connect.facebook.net/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
|
|
)
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/event',
|
|
(route) => {
|
|
filed.stream.push(route.request().postData() ?? '')
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
|
},
|
|
)
|
|
await page.route('**/v1/marketing/leads', (route) => {
|
|
filed.body = route.request().postDataJSON()
|
|
return route.fulfill(
|
|
status === 201
|
|
? { status, contentType: 'application/json', body: JSON.stringify({ lead: LEAD, stage: 'new' }) }
|
|
: { status, contentType: 'application/problem+json', body: '{"title":"Service Unavailable"}' },
|
|
)
|
|
})
|
|
await page.route('https://cal.com/**', (route) =>
|
|
// Escaped: `¬es=` read as HTML is the entity `¬`.
|
|
route.fulfill({ status: 200, contentType: 'text/html', body: `<title>cal</title><p id="at">${route.request().url().replace(/&/g, '&')}</p>` }),
|
|
)
|
|
// gtag() pushes its arguments onto dataLayer, which the head keeps if it is
|
|
// already there, so an event is written down the moment it is sent. The Pixel
|
|
// is the tag manager's to install, after the tag config answers, so its calls
|
|
// are recorded by wrapping whatever it installs as `fbq`; the stub's own
|
|
// fields (queue, loaded) stay on the stub.
|
|
await page.addInitScript(() => {
|
|
const layer: unknown[] = []
|
|
layer.push = (...items: unknown[]) => {
|
|
for (const a of items as ArrayLike<unknown>[]) {
|
|
if (a && a[0] === 'event') sessionStorage.setItem('gate.ga', JSON.stringify([a[1], a[2]]))
|
|
}
|
|
return Array.prototype.push.apply(layer, items)
|
|
}
|
|
;(window as unknown as { dataLayer: unknown[] }).dataLayer = layer
|
|
;(window as unknown as { google_tag_manager: object }).google_tag_manager = { 'G-RBVVVLFZDR': {} }
|
|
let fbq: unknown
|
|
Object.defineProperty(window, 'fbq', {
|
|
configurable: true,
|
|
get: () => fbq,
|
|
set: (real: unknown) => {
|
|
fbq =
|
|
typeof real === 'function'
|
|
? new Proxy(real, {
|
|
apply(target, self, a: unknown[]) {
|
|
if (a[0] === 'track') sessionStorage.setItem('gate.meta', JSON.stringify([a[1], a[3]]))
|
|
return Reflect.apply(target, self, a)
|
|
},
|
|
})
|
|
: real
|
|
},
|
|
})
|
|
})
|
|
await page.goto(`${base}/contact-sales?gclid=G9&utm_source=google&utm_medium=cpc&utm_campaign=brand&coupon=50OFF`, { waitUntil: 'domcontentloaded' })
|
|
// The visit is reported by an effect, so once our stream has it the form's
|
|
// handlers are attached: a press before that is the bare form.
|
|
await expect.poll(() => filed.stream.some((b) => b.includes('"$pageview"')), { timeout: 20_000, message: 'the page reported its visit' }).toBe(true)
|
|
return filed
|
|
}
|
|
|
|
/** The form, the way a person fills it. */
|
|
async function fill(page: Page) {
|
|
await page.getByLabel('Full name').fill('Ada Lovelace')
|
|
await page.getByLabel('Work email').fill('[email protected]')
|
|
await page.getByRole('textbox', { name: 'Company' }).fill('Analytical Engines')
|
|
await page.getByLabel('Team size').selectOption('250-999')
|
|
await page.getByLabel('What do you want to build?').fill('A private deployment')
|
|
}
|
|
|
|
/** The page is no wider than the window. */
|
|
async function fits(page: Page) {
|
|
const [scroll, client] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth])
|
|
expect(scroll, 'no sideways scroll').toBeLessThanOrEqual(client)
|
|
}
|
|
|
|
test.describe('the sales page', () => {
|
|
let base: string
|
|
let stop: () => Promise<void>
|
|
test.beforeAll(async () => {
|
|
const served = await serveExport()
|
|
base = served.url
|
|
stop = served.close
|
|
})
|
|
test.afterAll(async () => stop())
|
|
|
|
for (const [width, height] of [
|
|
[1440, 900],
|
|
[390, 844],
|
|
] as const) {
|
|
test(`at ${width}: files the lead, says so, and offers the calendar filled in`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height })
|
|
const filed = await arrive(page, base)
|
|
const head = page.getByRole('heading', { level: 1, name: 'Talk to sales' })
|
|
const card = page.locator('[data-slot="sales-card"]')
|
|
await expect(head).toBeVisible()
|
|
await expect(page.getByText('Enterprise', { exact: true }).first()).toBeVisible()
|
|
await expect(page.locator('[data-slot="sales-features"] li')).toHaveCount(5)
|
|
await expect(page.getByRole('link', { name: 'See everything in Enterprise' })).toHaveAttribute('href', '/enterprise')
|
|
// Real customers, each opening its own study: the Trusted by marquee (8a5cd309),
|
|
// every customer with a mark once for the reader, its looping copy hidden.
|
|
const logos = page.locator('[data-marquee] a:not([tabindex="-1"])')
|
|
await expect(logos).toHaveCount([...featured, ...stories].filter((s) => s.logo).length)
|
|
for (const a of await logos.all()) await expect(a).toHaveAttribute('href', /^\/customers(\/|#)[a-z-]+$/)
|
|
await expect(page.getByText('[email protected]', { exact: true })).toBeVisible()
|
|
await expect(page.getByRole('link', { name: 'Get Hanzo Team' })).toHaveAttribute('href', 'https://hanzo.team/start')
|
|
// The call is optional, and it is the 30-minute intro on cal.com/hanzo.
|
|
await expect(card.getByRole('link', { name: 'Or book a 30-minute call' })).toHaveAttribute('href', 'https://cal.com/hanzo/30min')
|
|
|
|
// Beside the pitch on a desktop; right under the heading on a phone.
|
|
const h = (await head.boundingBox())!
|
|
const c = (await card.boundingBox())!
|
|
const features = (await page.locator('[data-slot="sales-features"]').boundingBox())!
|
|
if (width >= 1024) {
|
|
expect(c.x, 'the form sits to the right of the heading').toBeGreaterThan(h.x + h.width / 2)
|
|
expect(Math.abs(c.y - h.y), 'and level with it').toBeLessThan(200)
|
|
} else {
|
|
expect(c.y, 'the form comes after the heading').toBeGreaterThan(h.y + h.height)
|
|
expect(c.y, 'and before the features').toBeLessThan(features.y)
|
|
}
|
|
// The funnel's measure: every field 56px, the select included (WebKit draws a
|
|
// native menu list at 26px unless told otherwise), and the call a 56px pill
|
|
// (a touch screen's tap-target rule may hold a pill at its 44px floor).
|
|
for (const id of ['sales-name', 'sales-email', 'sales-company', 'sales-size'])
|
|
expect(Math.round((await page.locator(`#${id}`).boundingBox())!.height), `#${id} is 56px`).toBe(56)
|
|
const submit = card.getByRole('button', { name: 'Talk to sales' })
|
|
expect(Math.round((await submit.boundingBox())!.height)).toBeGreaterThanOrEqual(width >= 1024 ? 56 : 44)
|
|
await fits(page)
|
|
|
|
// Nothing is sent until every required answer holds, and the first one missing takes the focus.
|
|
await submit.click()
|
|
for (const said of ['Enter your name.', 'Enter a work email, like [email protected].', 'Enter your company.', 'Choose a team size.'])
|
|
await expect(card.getByText(said, { exact: true })).toBeVisible()
|
|
await expect(page.getByLabel('Full name')).toBeFocused()
|
|
await expect(page.getByLabel('Full name')).toHaveAttribute('aria-invalid', 'true')
|
|
// An address that is not one is said wrong as the field is left.
|
|
await page.getByLabel('Work email').fill('ada@analytical')
|
|
await page.getByLabel('Full name').focus()
|
|
await expect(page.getByLabel('Work email')).toHaveAttribute('aria-describedby', 'sales-email-wrong')
|
|
expect(filed.body, 'nothing was sent').toBeNull()
|
|
|
|
await fill(page)
|
|
await fits(page)
|
|
await submit.click()
|
|
await expect(card).toHaveAttribute('data-state', 'sent')
|
|
await expect(card.getByRole('heading', { name: 'Thanks, Ada. Your request is in.' })).toBeVisible()
|
|
await expect(card).toContainText('[email protected]')
|
|
await expect(card).toBeFocused()
|
|
await fits(page)
|
|
|
|
const { visit, ...form } = filed.body as Record<string, unknown>
|
|
expect(form, 'the lead endpoint heard the whole form').toEqual({
|
|
email: '[email protected]',
|
|
name: 'Ada Lovelace',
|
|
company: 'Analytical Engines',
|
|
size: '250-999',
|
|
need: 'A private deployment',
|
|
source: 'hanzo.ai/contact-sales',
|
|
campaign: { utm_source: 'google', utm_medium: 'cpc', utm_campaign: 'brand' },
|
|
coupon: '50OFF',
|
|
})
|
|
expect(visit, 'and the visit it forwards the lead with').toMatchObject({
|
|
gclid: 'G9',
|
|
consent: 'analytics,marketing,ads',
|
|
tags: 'ga,meta',
|
|
userAgent: expect.any(String),
|
|
url: expect.stringContaining('/contact-sales?gclid=G9'),
|
|
})
|
|
expect((visit as { fbp?: string }).fbp).toMatch(/^fb\.1\./)
|
|
const at = new URL((await card.getByRole('link', { name: 'Book a call' }).getAttribute('href'))!)
|
|
expect(`${at.origin}${at.pathname}`).toBe('https://cal.com/hanzo/30min')
|
|
expect(at.searchParams.get('name')).toBe('Ada Lovelace')
|
|
expect(at.searchParams.get('email')).toBe('[email protected]')
|
|
expect(at.searchParams.get('notes')).toContain('Analytical Engines')
|
|
expect(at.searchParams.get('notes'), 'the booking names its lead').toContain(LEAD)
|
|
const [name, params] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.ga')))!)
|
|
expect(name, 'GA4 heard the lead').toBe('generate_lead')
|
|
expect(params).toMatchObject({ lead_id: LEAD, event_id: LEAD })
|
|
const [meta, options] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.meta')))!)
|
|
expect(meta, 'Meta heard it').toBe('Lead')
|
|
expect(options.eventID, 'under the id cloud states it with, so Meta keeps one').toBe(LEAD)
|
|
expect(filed.stream.some((b) => b.includes('"sales_contacted"')), 'our stream is not told twice').toBe(false)
|
|
})
|
|
}
|
|
|
|
test('a lead that cannot be filed still reaches a person, with the answers', async ({ page }) => {
|
|
const filed = await arrive(page, base, 503)
|
|
await fill(page)
|
|
await page.getByRole('button', { name: 'Talk to sales' }).click()
|
|
const card = page.locator('[data-slot="sales-card"]')
|
|
await expect(card).toHaveAttribute('data-state', 'held')
|
|
await expect(card.getByRole('heading', { name: 'One more step' })).toBeVisible()
|
|
expect(filed.body?.email).toBe('[email protected]')
|
|
const to = new URL((await card.getByRole('link', { name: 'Pick a time' }).getAttribute('href'))!)
|
|
expect(`${to.origin}${to.pathname}`).toBe('https://cal.com/hanzo/30min')
|
|
expect(to.searchParams.get('email')).toBe('[email protected]')
|
|
expect(to.searchParams.get('name')).toBe('Ada Lovelace')
|
|
const mail = (await card.getByRole('link', { name: /^Or email enterprise@hanzo\.ai/ }).getAttribute('href'))!
|
|
expect(mail).toMatch(/^mailto:enterprise@hanzo\.ai\?/)
|
|
expect(decodeURIComponent(mail)).toContain('Analytical Engines')
|
|
// Unfiled, the lead still counts, under an id of its own.
|
|
const [name, params] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.ga')))!)
|
|
expect(name).toBe('generate_lead')
|
|
expect(params.lead_id, 'no lead id to name').toBeUndefined()
|
|
await expect.poll(() => filed.stream.some((b) => b.includes('"sales_contacted"')), { timeout: 20_000 }).toBe(true)
|
|
})
|
|
|
|
test('whatever fills the hidden field is thanked, files nothing, and is counted', async ({ page }) => {
|
|
const filed = await arrive(page, base)
|
|
await fill(page)
|
|
// Set the way a script sets it, with no input event for the page to hear.
|
|
await page.locator('input[name="website"]').evaluate((el: HTMLInputElement) => (el.value = 'https://spam.example'))
|
|
await page.getByRole('button', { name: 'Talk to sales' }).click()
|
|
await expect(page.locator('[data-slot="sales-card"]')).toHaveAttribute('data-state', 'sent')
|
|
expect(filed.body, 'no lead was sent').toBeNull()
|
|
await expect.poll(() => filed.stream.some((b) => b.includes('"sales_trapped"')), { timeout: 20_000 }).toBe(true)
|
|
})
|
|
|
|
test('answers an earlier form left in the address are taken out of it, and the click is kept', async ({ page }) => {
|
|
await arrive(page, base)
|
|
await page.goto(`${base}/contact-sales?name=Ada+Lovelace&email=ada%40analytical.co&company=AE&gclid=G9`, { waitUntil: 'domcontentloaded' })
|
|
await expect.poll(() => new URL(page.url()).search, { timeout: 20_000 }).toBe('?gclid=G9')
|
|
})
|
|
|
|
test.describe('before the page runs', () => {
|
|
test.use({ javaScriptEnabled: false })
|
|
test('the export\'s form sends nothing, and puts nothing in the address', async ({ page }) => {
|
|
await page.goto(`${base}/contact-sales`, { waitUntil: 'domcontentloaded' })
|
|
const submit = page.locator('[data-slot="sales-card"] button[type="submit"]')
|
|
await expect(submit).toBeDisabled()
|
|
await page.locator('#sales-email').fill('[email protected]')
|
|
await page.locator('#sales-email').press('Enter')
|
|
await page.waitForTimeout(500)
|
|
expect(page.url(), 'no answer in the address').not.toContain('email=')
|
|
})
|
|
})
|
|
|
|
test('the old address forwards here', () => {
|
|
expect(answer('https://hanzo.ai/contact/sales?plan=team')).toEqual({ location: 'https://hanzo.ai/contact-sales?plan=team' })
|
|
})
|
|
})
|