mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
70 lines
2.5 KiB
TypeScript
70 lines
2.5 KiB
TypeScript
import { scope } from '@hanzo/app/lib/session'
|
|
import { api } from '@hanzo/app/lib/api'
|
|
|
|
/**
|
|
* The landing half of an affiliate link.
|
|
*
|
|
* hanzoai/cloud apps/affiliate mints `https://hanzo.ai/?aff=<code>` (linkBase),
|
|
* so this origin is where a referred visitor arrives. `keep` holds the code
|
|
* across the sign-in round trip and counts the click (POST /v1/affiliate/click,
|
|
* public). `bind` names the visitor's organization to the affiliate once the
|
|
* session has one (POST /v1/affiliate/attribute, which takes the referred org
|
|
* from the validated caller). The server keeps first touch, so a repeat is a
|
|
* no-op there.
|
|
*/
|
|
|
|
/**
|
|
* The console's name for the same code (console lib/affiliates/claim.ts). Not
|
|
* `hanzo`-prefixed on purpose: `own()` empties those keys when the person
|
|
* changes, and signing up is exactly that change.
|
|
*/
|
|
const KEY = 'hz_aff'
|
|
|
|
/** Take `?aff=` off the address into storage and count the click once per tab. */
|
|
export function keep(): void {
|
|
const code = (new URLSearchParams(window.location.search).get('aff') ?? '').trim()
|
|
if (!code) return
|
|
try {
|
|
window.localStorage.setItem(KEY, code)
|
|
if (window.sessionStorage.getItem(`${KEY}:${code}`)) return
|
|
window.sessionStorage.setItem(`${KEY}:${code}`, '1')
|
|
} catch {
|
|
/* no storage: the click still counts, but nothing is left to bind */
|
|
}
|
|
void fetch(`${api()}/v1/affiliate/click`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code }),
|
|
}).catch(() => {})
|
|
}
|
|
|
|
/**
|
|
* Attribute the kept code to the organization this browser works in.
|
|
*
|
|
* Nothing is sent without an organization: with none named, the caller would
|
|
* be whatever the token's owner claim says, which is the application's org and
|
|
* not the visitor's. The code is dropped on any final answer (bound, already
|
|
* bound, self, unknown) and kept for the next load on 401 or a server fault.
|
|
*/
|
|
export async function bind(): 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/affiliate/attribute`, {
|
|
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 */
|
|
}
|
|
}
|