Files
hanzo.ai/lib/hanzo/referrals.ts
zeekay 3a8a35d13c beta: the portal is the app's Betas pane, after a plan step
/beta's portal is one component now. A stranger has it in the site's frame;
a member has the app in its Betas mode (@hanzo/app 0.2.27: no sidebar there)
with the same portal as its pane: the form if there is no application, a plan
step once after filing (Pro with the beta code, or stay on Free), then the
dashboard.

Every share link, on the dashboard, the referral card, /referral and the page
share widget, goes out through tagged(): the sharer's ref and the channel as
utm_source, so a visit, a lead and a signup each say who shared and where. The
page share widget's tracking was a no-op; it records waitlist_shared now.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-25 18:38:26 -07:00

159 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { scope } from '@hanzo/app/lib/session'
import { api } from '@hanzo/app/lib/api'
/**
* Referrals, on hanzoai/cloud apps/referral.
*
* GET /v1/referral the org's stable code, its share link, and the orgs
* that signed up with it
* POST /v1/referral/claim a new org records the code it arrived with
*
* The link the service mints is `https://hanzo.ai/?ref=<code>`, so this origin is
* where a referred visitor lands. `keep` holds the code across the sign-in round
* trip and `claim` records it once the session works in an organization — the
* same two halves lib/hanzo/affiliate.ts runs for `?aff=`.
*/
/** One org that signed up with this org's code. */
export interface Referee {
id: string
/** The org that signed up. */
referee: string
/** `signup` until the referee makes metered spend, then `qualified`. */
status: string
/** Unix seconds. */
createdAt: number
qualifiedAt: number
}
export interface Referrals {
code: string
link: string
counts: { total: number; signup: number; qualified: number }
referrals: Referee[]
}
/**
* The signed-in org's referral code, link and referees.
*
* A refusal is said in words a person can act on. The status code is not one of
* them: "API error 404" told a reader nothing except that something was broken.
*/
export async function mine(): Promise<Referrals> {
const headers = scope()
if (!headers.Authorization) throw new Error('Sign in to see your referral link.')
let res: Response
try {
res = await fetch(`${api()}/v1/referral`, { headers })
} catch {
throw new Error('Your referral link could not be loaded. Check your connection and try again.')
}
if (res.status === 401) throw new Error('Your session expired. Sign in again to see your referral link.')
if (!res.ok) throw new Error('Your referral link could not be loaded right now. Try again in a moment.')
const body = (await res.json()) as Partial<Referrals>
return {
code: body.code ?? '',
link: body.link ?? '',
counts: { total: 0, signup: 0, qualified: 0, ...body.counts },
referrals: Array.isArray(body.referrals) ? body.referrals : [],
}
}
/**
* Update the signed-in org's referral code via PUT /v1/referral.
*/
export async function updateCode(code: string): Promise<Referrals> {
const headers = scope()
if (!headers.Authorization) throw new Error('Sign in to customize your referral link.')
let res: Response
try {
res = await fetch(`${api()}/v1/referral`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ code }),
})
} catch {
throw new Error('Could not update your referral link. Check your connection and try again.')
}
if (res.status === 401) throw new Error('Your session expired. Sign in again to customize your referral link.')
if (res.status === 409) throw new Error('That referral code is already taken. Try another one.')
if (res.status === 400) throw new Error('Referral code must be 2–32 letters, numbers, hyphens, or underscores.')
if (!res.ok) throw new Error('Could not update your referral link right now. Try again in a moment.')
const body = (await res.json()) as Partial<Referrals>
return {
code: body.code ?? code.toUpperCase(),
link: body.link ?? `https://hanzo.ai/?ref=${body.code ?? code.toUpperCase()}`,
counts: { total: 0, signup: 0, qualified: 0, ...body.counts },
referrals: Array.isArray(body.referrals) ? body.referrals : [],
}
}
/**
* Where the code waits between landing and signing in. Not `hanzo`-prefixed on
* purpose: `own()` empties those keys when the person changes, and signing up is
* exactly that change.
*/
/**
* A referral link as it is shared on one channel: `ref` credits the referrer and
* `utm_source` names the channel, so a visit, a lead and a signup each say who
* shared and where. `link` is the account's own link (GET /v1/referral); a link
* that does not parse is shared as it is.
*/
export function tagged(link: string, channel: string, campaign = 'referral'): string {
try {
const u = new URL(link)
u.searchParams.set('utm_source', channel)
u.searchParams.set('utm_medium', channel === 'email' || channel === 'invite' ? 'email' : 'social')
u.searchParams.set('utm_campaign', campaign)
return u.toString()
} catch {
return link
}
}
const KEY = 'hz_ref'
/** A code as the service mints it: short, letters and digits. Anything else is not one. */
const CODE = /^[A-Za-z0-9_-]{1,64}$/
/** Take `?ref=` off the address into storage. */
export function keep(): void {
const code = (new URLSearchParams(window.location.search).get('ref') ?? '').trim()
if (!CODE.test(code)) return
try {
window.localStorage.setItem(KEY, code)
} catch {
/* no storage: nothing is left to claim, and nothing is shown */
}
}
/**
* Record the kept code for the organization this browser works in.
*
* Silent in every outcome — the person followed a link, and whether it credited
* anyone is not theirs to be told. The code is dropped on any final answer
* (recorded, already recorded, self, unknown) and kept for the next load on 401
* or a server fault, the way `bind` keeps an affiliate code.
*/
export async function claim(): Promise<void> {
let code = ''
try {
code = (window.localStorage.getItem(KEY) ?? '').trim()
} catch {
return
}
const headers = scope()
if (!code || !headers.Authorization || !headers['X-Org-Id']) return
try {
const res = await fetch(`${api()}/v1/referral/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ code }),
})
if (res.status === 401 || res.status >= 500) return
window.localStorage.removeItem(KEY)
} catch {
/* offline or refused: the code stays for the next load */
}
}