Files
zooqueenandHanzo Dev d5315d5859 A checkout's sign-in keeps its plan, coupon and campaign, says the offer, and goes back to the checkout
pay sends a buyer with no session to /login with the checkout as `next`.
That page is now the checkout's first step:

- /login for a checkout carrying a coupon becomes /signup before it paints
  (lib/auth/sale EARLY); the page opens in create-account mode, says the
  offer across the top in pay's words from commerce's quote, drops the
  eyebrow and the demo, and draws pay's 56px controls at 1rem. "I already
  have an account" switches the mode in place.
- /login and /signup are one page (components/auth/page.tsx); the switch
  between them carries the whole query, so `next` and everything in it
  survives a change of mind.
- Signed in, a checkout chosen before signing in is opened as it was asked
  for (components/onboarding/flow.tsx), with the organization worked in and
  a coupon the tab keeps added only where it names none. The terms page
  stays for an account a provider made a moment ago, which has accepted
  none on record, and returns to the checkout; billing is not asked. Free
  chosen earlier in the tab no longer skips that page for a checkout.
- Try again after a refused sign-in carries the destination as `next`.
- /pricing's paid plans read "Get <plan>" and open the sign-up carrying
  pay's card step; signed in, the picker, which opens it with nothing to
  press unless terms are owed. A team's plan still names its team first.
  A catalog row's own link is kept only when it is a page on this site.
- Home's Try Hanzo brings the hero's sign-up into view and focuses it when
  the panel is on the page.

e2e/gates/sale.spec.ts holds the chain; e2e/campaign.spec.ts drives the
live offer link up to IAM's authorize.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-15 20:21:21 -07:00

93 lines
4.4 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { serveExport } from './export'
import { serve } from './api'
/**
* The link out of the ladder.
*
* Every paid card on /pricing is a link to the same place, and two properties of
* that link decide whether a reader who presses it can still buy. Both were
* wrong at once, and neither is visible in a screenshot of the page:
*
* IT MUST NAME THE PLAN. `?plan=<slug>` is the only thing that travels — the
* price is never in the URL and must never be, because commerce prices the
* charge from its own catalog. Downstream, billing continues the choice and
* pay renders the plan and its price from that one id. A link with no plan on
* it drops the reader at the top of a ladder they have already read.
*
* IT MUST STAY IN THIS TAB. Buying is not consulting a reference. A new tab
* opens with an empty history, so Back is dead in it and the ladder the buyer
* was comparing is stranded behind a tab strip — measured: a click on Pro
* opened a second tab, crossed billing and landed on the identity provider,
* with no way back to the tier below the one they clicked.
*
* Against `out/` — the bytes that ship — and after hydration, because the cards
* and their hrefs are drawn from the catalog the page reads when it opens
* (lib/api; served here by e2e/gates/api), and the export holds none.
*
* Nothing here states a PRICE. The ladder is re-priced upstream in commerce, so
* a number written down in this file is wrong the week it is written; what a
* gate can own is the shape of the link, which does not move.
*/
/** The paths and hosts a checkout may point at. Anything else on a plan CTA is not a sale. */
const CHECKOUT = /^(\/billing|https?:\/\/[^/]+\/billing|https:\/\/(billing|pay)\.hanzo\.ai\/|https:\/\/hanzo\.ai\/pay(\/|\?|$))/
type Cta = { href: string; target: string; text: string }
/**
* Sign-in comes first: signed out, a card plan's CTA is /signup carrying its
* checkout as `next`, a path on hanzo.ai (lib/auth/destination `onward` keeps it
* on this site). A team's plan goes through the picker, which names the team and
* hands it on to pay's card step carried as its `next`. The checkout is that, on
* the site the export is.
*/
function checkoutOf(href: string): string {
const u = new URL(href)
const next = ['/login', '/signup', '/onboarding'].includes(u.pathname) ? u.searchParams.get('next') : null
return next ? checkoutOf(new URL(next, 'https://hanzo.ai').href) : href
}
test.describe('the checkout link', () => {
test('names its plan and keeps the ladder in reach', async ({ page }) => {
const server = await serveExport()
await serve(page)
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
// The cards are drawn once the catalog is read; waiting for the first CTA
// is waiting for the render rather than for a duration.
// Checkout is a PATH on this site now — the billing host answered 404, so
// every plan CTA on this page led nowhere. The checkout is hanzo.ai/pay,
// reached through /signup (`?next=`), and a catalog row may still carry its own link.
const ctas = page.locator('a[href^="/billing"], a[href*="/billing/?plan="], a[href^="https://hanzo.ai/pay"], a[href^="/signup?next=%2Fpay"], a[href^="/signup?next=%2Fonboarding"]')
await expect(ctas.first()).toBeAttached({ timeout: 15000 })
const links: Cta[] = await ctas.evaluateAll((as) =>
as.map((a) => ({
href: (a as HTMLAnchorElement).href,
target: (a as HTMLAnchorElement).target,
text: (a.textContent ?? '').trim(),
})),
)
// A pricing page with no way to buy is the failure this gate would
// otherwise pass on, so the count is asserted before the properties.
expect(links.length, 'no plan on /pricing links to a checkout').toBeGreaterThan(0)
for (const cta of links) {
const to = checkoutOf(cta.href)
expect(to, `"${cta.text}" points somewhere that is not a checkout`).toMatch(CHECKOUT)
expect(
new URL(to).searchParams.get('plan'),
`"${cta.text}" opens the checkout without naming a plan`,
).toBeTruthy()
expect(cta.target, `"${cta.text}" opens a new tab, which kills the back button`).toBe('')
}
} finally {
await server.close()
}
})
})