Files
zeekay 70349b34c7 beta: a post about one topic, with that topic's page and card
The share card offers Hanzo, the products and the betas the member asked
for, the rest under More by group. A topic picks the posts Shuffle offers,
the link (/beta/for/<topic>, tagged utm_content) and the card it unfurls
to: that page carries the topic's title, line and card, a canonical naming
itself and the one noindex (lib/publish SHARES), and sends a person on to
/beta with the query kept, so ref and the campaign land as before.

lib/hanzo/topics is the list, its words the site's and BETAS's own.
generate-og draws a card per topic from it, palette-quantized with sharp:
49 cards, 2.9 MB. The cards gate reads every card's rendered words.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 12:03:39 -07:00

177 lines
6.4 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'
import { page } from './topics'
/**
* 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.
*/
/**
* The member's link about one topic (lib/hanzo/topics): its share page,
* /beta/for/<topic>, which unfurls to that topic's card, with the link's own
* query. A link that does not parse is shared as it is.
*/
export function about(link: string, topic: string): string {
try {
const u = new URL(link)
u.pathname = page(topic)
return u.toString()
} catch {
return link
}
}
/**
* 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. With a `topic` the link is that
* topic's page and `utm_content` names it.
*/
export function tagged(link: string, channel: string, campaign = 'referral', topic?: string): string {
try {
const u = new URL(topic ? about(link, topic) : link)
u.searchParams.set('utm_source', channel)
u.searchParams.set('utm_medium', channel === 'email' || channel === 'invite' ? 'email' : 'social')
u.searchParams.set('utm_campaign', campaign)
if (topic) u.searchParams.set('utm_content', topic)
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 */
}
}