mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
Send docs visitors to hanzo.ai, not hanzo.id, and count the plan click
The landing's "Sign up free" opened hanzo.id/signup, and "Sign in" opened
/login, whose authorize step sent the reader to hanzo.id too. No surface
sends a visitor there. Sign-in is hanzo.ai/login now (the /login address is
a redirect rule), sign-out clears the session in this browser instead of
carrying the reader to the issuer's end-session page, and the conversion
CTA reads "Choose plan" and opens hanzo.ai/pricing.
The click fires plan_clicked {source: 'docs'} on the client that counts the
page views, named from the @hanzo/events catalog that @hanzo/docs-analytics
now re-exports.
The ingest key comes from @hanzo/event's keyring, resolved from the page's
host, rather than a KMS value inlined at build. docs.hanzo.ai resolves the
hanzo org's key, the one the rest of the funnel reports under, and a sibling
docs site on a host no brand claims reports nothing rather than filing its
visitors under hanzo. The deploy resolves the same key, asks /v1/event
whether it names a project, and greps the export for it.
This commit is contained in:
+13
-35
@@ -63,42 +63,23 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
|
||||
# The ingest key is public (it ships in the bundle) and lives in KMS so it
|
||||
# rotates without a commit. Login and read are retried on a lost answer
|
||||
# (000/429/5xx) and not on a refusal.
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# The ingest key is the hanzo org's publishable key from @hanzo/event's
|
||||
# keyring, which the page resolves from its own host (packages/analytics).
|
||||
# This resolves it the same way for docs.hanzo.ai. A key that names a
|
||||
# deleted project answers 403 while the site looks perfect, so ask the
|
||||
# endpoint before building: a lost answer (000/429/5xx) warns, a refusal
|
||||
# stops the run.
|
||||
- name: ingest key
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KMS=https://api.hanzo.ai
|
||||
[ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ] || {
|
||||
echo "::error::KMS_CLIENT_ID/KMS_CLIENT_SECRET are unset on this forge org"; exit 1; }
|
||||
ask() { # ask <outfile> <curl args...> -> status code
|
||||
local out="$1"; shift; local code=""
|
||||
for wait in 0 5 15 45; do
|
||||
[ "$wait" = 0 ] || sleep "$wait"
|
||||
code="$(curl -s -o "$out" -w '%{http_code}' --max-time 30 "$@")" || code=000
|
||||
case "$code" in 000|429|5*) continue ;; *) break ;; esac
|
||||
done
|
||||
printf '%s' "$code"
|
||||
}
|
||||
code="$(ask /tmp/login.json "$KMS/v1/kms/auth/login" -H 'Content-Type: application/json' \
|
||||
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}")"
|
||||
[ "$code" = 200 ] || { echo "::error::KMS login answered $code"; exit 1; }
|
||||
token="$(jq -r '.accessToken // empty' /tmp/login.json)"
|
||||
[ -n "$token" ] || { echo "::error::KMS login returned no accessToken"; exit 1; }
|
||||
code="$(ask /tmp/key.json "$KMS/v1/kms/secrets/deploy/PUBLISHABLE_KEY?env=prod" \
|
||||
-H "Authorization: Bearer $token")"
|
||||
[ "$code" = 200 ] || { echo "::error::reading deploy/PUBLISHABLE_KEY answered $code (403: this client may not read it; 404: not sealed)"; exit 1; }
|
||||
key="$(jq -r '.value // empty' /tmp/key.json)"
|
||||
rm -f /tmp/login.json /tmp/key.json
|
||||
key="$(cd packages/analytics && node --input-type=module \
|
||||
-e "import { keyFor } from '@hanzo/event'; process.stdout.write(keyFor('docs.hanzo.ai') ?? '')")"
|
||||
case "$key" in
|
||||
pk-*) ;;
|
||||
*) echo "::error::deploy/PUBLISHABLE_KEY is empty or not a pk- key"; exit 1 ;;
|
||||
*) echo "::error::@hanzo/event resolves no pk- key for docs.hanzo.ai"; exit 1 ;;
|
||||
esac
|
||||
# A well-formed key can still name a deleted project: ask the endpoint.
|
||||
code="$(curl -s -o /tmp/ingest.json -w '%{http_code}' --max-time 20 \
|
||||
-X POST "https://api.hanzo.ai/v1/event?ingest_key=$key" \
|
||||
-H 'content-type: application/json' -d '{"batch":[]}')" || code=000
|
||||
@@ -107,10 +88,7 @@ jobs:
|
||||
000|429|5*) echo "::warning::ingest endpoint answered $code; key unverified" ;;
|
||||
*) echo "::error::the ingest key is refused ($code): $(cat /tmp/ingest.json)"; exit 1 ;;
|
||||
esac
|
||||
echo "::add-mask::$key"
|
||||
echo "NEXT_PUBLIC_PUBLISHABLE_KEY=$key" >> "$GITHUB_ENV"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
echo "INGEST_KEY=$key" >> "$GITHUB_ENV"
|
||||
|
||||
# The workspace packages first, on their own, so a failure here is not
|
||||
# read as a failure of the site. `docs^...` is turbo's word for "what docs
|
||||
@@ -159,7 +137,7 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
sh scripts/check-export.sh apps/docs/out
|
||||
grep -rqF "$NEXT_PUBLIC_PUBLISHABLE_KEY" apps/docs/out \
|
||||
grep -rqF "$INGEST_KEY" apps/docs/out \
|
||||
|| { echo "::error::the ingest key is not in the export"; exit 1; }
|
||||
|
||||
# The slug is the host's name, as hanzo-ai is hanzo.ai's and cloud-hanzo-ai
|
||||
|
||||
@@ -137,6 +137,20 @@ zt-docs) predate the unified `content/docs/` model — migrate their content int
|
||||
`content/docs/<section>/` (or a `hanzo-docs/<team>` submodule) then archive the
|
||||
app.
|
||||
|
||||
## Funnel
|
||||
|
||||
- **No page sends a visitor to hanzo.id.** Sign-in is `https://hanzo.ai/login`
|
||||
(`components/account.tsx`; `/login` is a rule in `public/_redirects`).
|
||||
Sign-out clears the session in this browser and navigates nowhere.
|
||||
- **The conversion CTA reads "Choose plan"** and opens `https://hanzo.ai/pricing`,
|
||||
firing `plan_clicked` `{ source: 'docs' }` (`components/landing.tsx`).
|
||||
- **Events are catalog names only** — `EVENTS` from @hanzo/events, re-exported
|
||||
by `@hanzo/docs-analytics`. That package sends page views (first load and each
|
||||
route change) to `api.hanzo.ai/v1/event` with the key `@hanzo/event`'s keyring
|
||||
resolves from the page's host: `docs.hanzo.ai` is the hanzo org's, `localhost`
|
||||
resolves none. The cold image lane's `PUBLISHABLE_KEY` build arg is no longer
|
||||
read by any app.
|
||||
|
||||
## Build guards, and why prose needs them
|
||||
|
||||
`scripts/pre-build.ts` ends with checks that fail the build. The first two exist for
|
||||
|
||||
+6
-6
@@ -58,17 +58,17 @@ completing a deployment reconciles the prefix against the manifest it sends.
|
||||
|
||||
## What the job proves before it publishes
|
||||
|
||||
- **The ingest key resolves.** `deploy/PUBLISHABLE_KEY` (env `prod`) comes from
|
||||
KMS — the org's Actions secrets carry only `KMS_CLIENT_ID`/`KMS_CLIENT_SECRET`
|
||||
— and is POSTed to `/v1/event` before the build. A key that is present but
|
||||
names a deleted project answers 403, and a site built on it loses every
|
||||
pageview while looking perfect.
|
||||
- **The ingest key resolves.** The page sends the hanzo org's publishable key
|
||||
from `@hanzo/event`'s keyring, resolved from its host (`packages/analytics`).
|
||||
The job resolves the same key for `docs.hanzo.ai` and POSTs it to `/v1/event`
|
||||
before the build. A key that names a deleted project answers 403, and a site
|
||||
built on it loses every pageview while looking perfect.
|
||||
- **The export is a site.** `scripts/check-export.sh` refuses an export without
|
||||
`index.html`, `docs/index.html`, 50+ pages, a rendered nav, and every path in
|
||||
`apps/docs/export.require` — the sections that vanish without breaking anything
|
||||
else (`docs/studio/` is a submodule, `docs/openapi/` and `docs/mcp-tools/` are
|
||||
generated).
|
||||
- **The key is in the bytes.** A build-time env that never reached the bundle is
|
||||
- **The key is in the bytes.** A keyring that never reached the bundle is
|
||||
invisible everywhere except the warehouse, so the export is grepped for it.
|
||||
- **Every link resolves.** `build:post` walks the export, follows every anchor
|
||||
through the serving ladder (`/docs` -> `docs.html` -> `docs/index.html`) and
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function CallbackPage() {
|
||||
title="Sign in failed"
|
||||
note={error}
|
||||
action={
|
||||
<Action tone="loud" render="a" href="/login" mt={6} rounded={999}>
|
||||
<Action tone="loud" render="a" href="https://hanzo.ai/login" mt={6} rounded={999}>
|
||||
Try again
|
||||
</Action>
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Action } from '@/components/action'
|
||||
import { Status } from '@/components/status'
|
||||
import { iam } from '@/lib/iam'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Already signed in — including in ANOTHER tab, now that the session is not
|
||||
// scoped to this one.
|
||||
if (iam().getAccessToken()) {
|
||||
window.location.href = '/docs'
|
||||
return
|
||||
}
|
||||
iam()
|
||||
.signinRedirect()
|
||||
.catch(() => setError('Failed to load authentication. Please try again.'))
|
||||
}, [])
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<Status
|
||||
title="Sign in error"
|
||||
note={error}
|
||||
action={
|
||||
<Action tone="loud" render="button" type="button" onPress={() => window.location.reload()} mt={6} rounded={999}>
|
||||
Try again
|
||||
</Action>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
return <Status note="Redirecting to sign in…" />
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, XStack } from '@hanzo/gui';
|
||||
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2';
|
||||
@@ -10,6 +9,8 @@ import { currentUser, iam, type DocsUser } from '@/lib/iam';
|
||||
import { muted } from '@/lib/ink';
|
||||
|
||||
const CONSOLE = 'https://platform.hanzo.ai';
|
||||
/** Sign-in is hanzo.ai's page. No surface sends a visitor to hanzo.id. */
|
||||
const LOGIN = 'https://hanzo.ai/login';
|
||||
|
||||
/**
|
||||
* Who is signed in, asked once per page load and shared by the rail, the bar and
|
||||
@@ -32,14 +33,12 @@ function useUser(): [DocsUser | null, (u: DocsUser | null) => void] {
|
||||
}
|
||||
|
||||
function signOut(done: () => void) {
|
||||
// The SDK owns the token store and clears every key it wrote.
|
||||
iam()
|
||||
.logout()
|
||||
.catch(() => iam().clearTokens())
|
||||
.finally(() => {
|
||||
done();
|
||||
window.location.reload();
|
||||
});
|
||||
// The session ends here, in this browser. The SDK's logout() would carry the
|
||||
// reader on to the issuer's end-session page on hanzo.id, and no surface sends
|
||||
// a visitor there. The SDK owns the token store and clears every key it wrote.
|
||||
iam().clearTokens();
|
||||
done();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +52,7 @@ export function Account() {
|
||||
if (!user)
|
||||
return (
|
||||
<XStack gap={8}>
|
||||
<Action tone="line" flex={1} render={<Link href="/login" prefetch={false} />}>
|
||||
<Action tone="line" flex={1} render="a" href={LOGIN}>
|
||||
Sign in
|
||||
</Action>
|
||||
<Action tone="loud" flex={2} render="a" href={CONSOLE}>
|
||||
@@ -113,7 +112,7 @@ export function Short() {
|
||||
|
||||
return (
|
||||
<XStack gap={8} items="center">
|
||||
<Action tone="quiet" render={<Link href="/login" prefetch={false} />} $max-sm={{ display: 'none' }}>
|
||||
<Action tone="quiet" render="a" href={LOGIN} $max-sm={{ display: 'none' }}>
|
||||
Sign in
|
||||
</Action>
|
||||
<Action tone="loud" render="a" href={CONSOLE}>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Workflow,
|
||||
} from '@hanzogui/lucide-icons-2';
|
||||
import { Grid } from '@hanzo/ui/grid';
|
||||
import { EVENTS, useAnalytics } from '@hanzo/docs-analytics';
|
||||
import { Action } from '@/components/action';
|
||||
import { Agent } from '@/components/agent';
|
||||
import { HeroField } from '@/components/hero-field';
|
||||
@@ -144,6 +145,7 @@ function Tile({ name, spec, mono = false }: { name: string; spec: string; mono?:
|
||||
}
|
||||
|
||||
export function Landing({ install, use }: { install: ReactNode; use: ReactNode }) {
|
||||
const analytics = useAnalytics();
|
||||
return (
|
||||
<YStack render="main" minW={0} pb={48}>
|
||||
{/* The hero, centred over its halftone. */}
|
||||
@@ -410,8 +412,16 @@ export function Landing({ install, use }: { install: ReactNode; use: ReactNode }
|
||||
curl hanzo.sh | sh
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap={12} justify="center">
|
||||
<Action tone="loud" render="a" href="https://hanzo.id/signup?redirect_uri=https://platform.hanzo.ai" height={44} px={28} rounded={999}>
|
||||
Sign up free
|
||||
<Action
|
||||
tone="loud"
|
||||
render="a"
|
||||
href="https://hanzo.ai/pricing"
|
||||
onPress={() => analytics.capture(EVENTS.PLAN_CLICKED, { source: 'docs' })}
|
||||
height={44}
|
||||
px={28}
|
||||
rounded={999}
|
||||
>
|
||||
Choose plan
|
||||
</Action>
|
||||
<Action tone="line" render={<Link href="/docs" prefetch={false} />} height={44} px={28} rounded={999}>
|
||||
Browse documentation
|
||||
|
||||
@@ -87,7 +87,7 @@ export const onRequestPost: PagesFunction<Env> = async (context) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: response.status === 429
|
||||
? 'Rate limit exceeded. Sign in at hanzo.id for higher limits.'
|
||||
? 'Rate limit exceeded. Sign in at hanzo.ai/login for higher limits.'
|
||||
: `AI API error: ${response.status}`,
|
||||
detail: text,
|
||||
}),
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
# Tutorials were merged into Guides.
|
||||
/tutorials /docs/guides 301
|
||||
|
||||
# Sign-in is hanzo.ai's page; this site no longer runs its own sign-in, whose
|
||||
# authorize step sent the reader to hanzo.id.
|
||||
/login https://hanzo.ai/login 301
|
||||
|
||||
# Top-level convenience aliases. docs.hanzo.ai is already the docs subdomain, so
|
||||
# bare product paths resolve to their canonical page under /docs. Explicit list
|
||||
# only — never a catch-all, which would shadow /, /blog, /showcase, /login.
|
||||
# only — never a catch-all, which would shadow /, /blog, /showcase.
|
||||
#
|
||||
# /cloud is where hanzo.id's Cloud card and every "Docs" button beside the
|
||||
# product send a reader, and Hanzo Cloud is not a section of this site — it is
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { keyForPage } from '@hanzo/event';
|
||||
import { AnalyticsProvider, usePageview } from '@hanzo/event/react';
|
||||
|
||||
/** The client, for a page that sends an event of its own. */
|
||||
/** The client, for a page that sends an event of its own, and the catalog
|
||||
* (@hanzo/events) it takes the event's name from. */
|
||||
export { useAnalytics } from '@hanzo/event/react';
|
||||
export { EVENTS } from '@hanzo/event';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -13,11 +16,13 @@ import type { ReactNode } from 'react';
|
||||
* tenant server-side from the publishable ingest key. */
|
||||
const HOST = 'https://api.hanzo.ai';
|
||||
|
||||
/** Publishable ingest key (pk_…), minted per org via POST /v1/ingest/keys. A docs
|
||||
* site is read-only and logged out, so no bearer can ride the request — this
|
||||
* write-only, bundle-safe key IS how anonymous pageviews and errors resolve to an
|
||||
* org. Unset → events are best-effort and dropped at the edge. */
|
||||
const INGEST_KEY = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined;
|
||||
/** Publishable ingest key: the org's, from the keyring @hanzo/event carries,
|
||||
* resolved from the host the page is served on — docs.hanzo.ai is the hanzo
|
||||
* org's. A docs site is read-only and logged out, so no bearer can ride the
|
||||
* request — this write-only, bundle-safe key IS how anonymous pageviews and
|
||||
* errors resolve to an org. A host no brand claims gets none and reports
|
||||
* nothing, rather than filing its visitors under another org. */
|
||||
const INGEST_KEY = keyForPage();
|
||||
|
||||
/** Honor an explicit browser opt-out (Global Privacy Control, then legacy DNT).
|
||||
* SSR (no navigator) defaults to consented; the browser reads the real signal on
|
||||
|
||||
Reference in New Issue
Block a user