mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
/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
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
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 */
|
||
}
|
||
}
|