mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Inline style objects became gui props (Box for a div, View or Text rendering the element for the rest, since Box styles only a div), raw colours and sizes became the design's tokens, the brand swatches read @hanzo/design's own values, and the project and creation hues moved to palette.json, which is data. The newsletter's subscribe button is the standard filled Action. connect-steps.tsx, which nothing imports, is deleted with the brand constants nobody read. Pages compared before and after at 1440x900 and 390x844: blockchain, brand, directory, projects, settings, unsubscribe identical; founder 0.03% of pixels; buying specialist's title snaps from 28 to the ramp's 26px and the newsletter's button is now filled. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* General: the person, not the org. Their profile as IAM keeps it and how the app
|
|
* looks to them.
|
|
*
|
|
* The profile is IAM's row, written through `PUT /v1/iam/account` — the one write
|
|
* a person may make about themselves (contexts/AccountContext.tsx). Four fields
|
|
* save: name, picture, bio and website. The email address is a login identifier
|
|
* and the place a sign-in code goes, so it is shown and not edited: IAM moves it
|
|
* by proving the new address, never by a profile save.
|
|
*/
|
|
|
|
import dynamic from 'next/dynamic'
|
|
import Link from 'next/link'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { ArrowRight, Building2, Check, Wallet } from 'lucide-react'
|
|
import { Avatar, AvatarFallback, AvatarImage, Button, Input, Text, Textarea, XStack, YStack, toast } from '@hanzo/ui'
|
|
import { Field } from '@/components/account/form'
|
|
import { Look } from '@hanzo/app/look'
|
|
import { LanguagePicker, useI18n } from '@hanzo/i18n/react'
|
|
import { useAccount } from '@hanzo/app/lib/account'
|
|
import { api } from '@hanzo/app/lib/api'
|
|
import { mark } from '@hanzo/app/lib/mark'
|
|
import { say } from '@hanzo/app/lib/org'
|
|
import { bearer } from '@hanzo/app/lib/session'
|
|
import { paying } from '@hanzo/app/lib/tier'
|
|
import { Heading, Section } from './kit'
|
|
|
|
/** Fetched on "Create a memoji", with the AI client it draws through. */
|
|
const Memoji = dynamic(() => import('./Memoji'), { ssr: false })
|
|
|
|
export function General() {
|
|
const { user, updateUserProfile } = useAccount()
|
|
const { t } = useI18n()
|
|
const [name, setName] = useState('')
|
|
const [bio, setBio] = useState('')
|
|
const [website, setWebsite] = useState('')
|
|
const [avatar, setAvatar] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const picker = useRef<HTMLInputElement>(null)
|
|
// A memoji of yourself is a paid plan's (./Memoji).
|
|
const [member, setMember] = useState(false)
|
|
const [memoji, setMemoji] = useState(false)
|
|
const [walletAddress, setWalletAddress] = useState('')
|
|
const [walletSaving, setWalletSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const stop = new AbortController()
|
|
void paying(stop.signal).then(setMember, () => setMember(false))
|
|
if (typeof window !== 'undefined') {
|
|
const saved = window.localStorage.getItem('hanzo.rewards.wallet')
|
|
if (saved) setWalletAddress(saved)
|
|
}
|
|
const token = bearer()
|
|
if (token) {
|
|
fetch(`${api()}/v1/iam/preferences`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: stop.signal,
|
|
})
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((body) => {
|
|
const w = body?.data?.wallet?.address || body?.data?.onboarding?.walletAddress
|
|
if (typeof w === 'string' && w.trim()) {
|
|
setWalletAddress(w.trim())
|
|
if (typeof window !== 'undefined') window.localStorage.setItem('hanzo.rewards.wallet', w.trim())
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
return () => stop.abort()
|
|
}, [])
|
|
|
|
const saveWallet = async (addr: string) => {
|
|
const trimmed = addr.trim()
|
|
setWalletSaving(true)
|
|
try {
|
|
if (typeof window !== 'undefined') {
|
|
if (trimmed) window.localStorage.setItem('hanzo.rewards.wallet', trimmed)
|
|
else window.localStorage.removeItem('hanzo.rewards.wallet')
|
|
}
|
|
const token = bearer()
|
|
if (token) {
|
|
await fetch(`${api()}/v1/iam/preferences`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({
|
|
wallet: { address: trimmed, updatedAt: new Date().toISOString() },
|
|
onboarding: { wallet: Boolean(trimmed), walletAddress: trimmed },
|
|
}),
|
|
})
|
|
}
|
|
setWalletAddress(trimmed)
|
|
toast.success(trimmed ? 'Rewards payout wallet connected' : 'Wallet disconnected')
|
|
} catch (e) {
|
|
toast.error(say(e))
|
|
} finally {
|
|
setWalletSaving(false)
|
|
}
|
|
}
|
|
|
|
const connectBrowserWallet = async () => {
|
|
const eth = typeof window !== 'undefined' ? (window as unknown as { ethereum?: { request?: (args: { method: string }) => Promise<string[]> } }).ethereum : undefined
|
|
if (!eth?.request) {
|
|
toast.error('No Web3 browser wallet detected. Paste your EVM or Solana address below.')
|
|
return
|
|
}
|
|
try {
|
|
const accounts = await eth.request({ method: 'eth_requestAccounts' })
|
|
if (Array.isArray(accounts) && accounts[0]) {
|
|
await saveWallet(accounts[0])
|
|
}
|
|
} catch (e) {
|
|
toast.error(say(e))
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!user) return
|
|
setName(user.name || '')
|
|
setBio(user.bio || '')
|
|
setWebsite(user.website || '')
|
|
setAvatar(user.avatar || '')
|
|
}, [user])
|
|
|
|
const save = async (patch: Parameters<typeof updateUserProfile>[0], done: string) => {
|
|
setSaving(true)
|
|
try {
|
|
await updateUserProfile(patch)
|
|
toast.success(done)
|
|
} catch (e) {
|
|
setAvatar(user?.avatar || '')
|
|
toast.error(say(e))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const pick = async (file: File | undefined) => {
|
|
if (!file) return
|
|
try {
|
|
const image = await mark(file)
|
|
setAvatar(image)
|
|
await save({ avatar: image }, 'Picture updated')
|
|
} catch (e) {
|
|
toast.error(say(e))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Heading title="General" lede="Your profile and how Hanzo looks to you, on every Hanzo surface you sign in to." />
|
|
|
|
<Section title="Profile" ruled={false}>
|
|
<XStack items="center" gap="$4" flexWrap="wrap">
|
|
<Avatar size={64}>
|
|
<AvatarImage src={avatar || undefined} />
|
|
<AvatarFallback>
|
|
<Text fontSize="$6" color="$ink">
|
|
{(name || user?.email || '?')[0].toUpperCase()}
|
|
</Text>
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<input
|
|
ref={picker}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
hidden
|
|
onChange={(e) => {
|
|
void pick(e.target.files?.[0])
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
<Button variant="outline" size="sm" disabled={saving} onClick={() => picker.current?.click()}>
|
|
Upload picture
|
|
</Button>
|
|
{member && !memoji ? (
|
|
<Button variant="outline" size="sm" disabled={saving} onClick={() => setMemoji(true)}>
|
|
Create a memoji
|
|
</Button>
|
|
) : null}
|
|
{avatar ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={saving}
|
|
onClick={() => {
|
|
setAvatar('')
|
|
void save({ avatar: '' }, 'Picture removed')
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
) : null}
|
|
</XStack>
|
|
</XStack>
|
|
{member && memoji ? (
|
|
<Memoji
|
|
busy={saving}
|
|
onCancel={() => setMemoji(false)}
|
|
onDrawn={async (image) => {
|
|
setAvatar(image)
|
|
setMemoji(false)
|
|
await save({ avatar: image }, 'Memoji saved')
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void save({ name: name.trim(), bio, website: website.trim() }, 'Profile updated')
|
|
}}
|
|
>
|
|
<YStack gap="$5" maxW={576}>
|
|
<Field id="settings-name" label="Name">
|
|
<Input id="settings-name" value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" />
|
|
</Field>
|
|
<Field id="settings-email" label="Email">
|
|
<Input id="settings-email" type="email" value={user?.email ?? ''} disabled readOnly />
|
|
</Field>
|
|
<Field id="settings-bio" label="Bio">
|
|
<Textarea id="settings-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="A line about you" />
|
|
</Field>
|
|
<Field id="settings-website" label="Website">
|
|
<Input
|
|
id="settings-website"
|
|
type="url"
|
|
value={website}
|
|
onChange={(e) => setWebsite(e.target.value)}
|
|
placeholder="https://example.com"
|
|
/>
|
|
</Field>
|
|
<XStack>
|
|
<Button type="submit" disabled={saving}>
|
|
{saving ? 'Saving…' : 'Save profile'}
|
|
</Button>
|
|
</XStack>
|
|
</YStack>
|
|
</form>
|
|
</Section>
|
|
|
|
<Section title="Appearance">
|
|
<Look />
|
|
</Section>
|
|
|
|
{/* @hanzo/i18n's own picker, the one every Hanzo surface draws: the
|
|
language Hanzo's words are shown in, kept with the account. In a row,
|
|
so the field is as wide as its longest name rather than the page. */}
|
|
<Section title={t('Language')} lede={t('The language Hanzo is shown in, on every Hanzo surface you sign in to.')}>
|
|
<XStack>
|
|
<LanguagePicker />
|
|
</XStack>
|
|
</Section>
|
|
|
|
<Section
|
|
title="Web3 Wallet & Rewards Payouts"
|
|
lede="Connect your Web3 wallet (Ethereum, Base, Arbitrum, or Solana) to collect referral payouts, affiliate commissions, and compute rewards."
|
|
>
|
|
<YStack gap="$4" maxW={576}>
|
|
<XStack items="center" gap="$3" flexWrap="wrap">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={walletSaving}
|
|
onClick={() => void connectBrowserWallet()}
|
|
gap={8}
|
|
>
|
|
<Wallet size={16} aria-hidden />
|
|
<span>{walletAddress ? 'Switch Web3 Wallet' : 'Connect Web3 Wallet'}</span>
|
|
</Button>
|
|
{walletAddress ? (
|
|
<XStack items="center" gap="$1.5" px="$2.5" py="$1" rounded={9999} borderWidth={1} borderColor="$edge">
|
|
<Check size={14} color="var(--state-online)" aria-hidden />
|
|
<Text fontSize="$2" fontFamily="$mono" color="$ink">
|
|
{walletAddress.length > 16 ? `${walletAddress.slice(0, 8)}…${walletAddress.slice(-6)}` : walletAddress}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
</XStack>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void saveWallet(walletAddress)
|
|
}}
|
|
>
|
|
<YStack gap="$3">
|
|
<Field id="settings-wallet" label="Payout Wallet Address (0x… or Solana)">
|
|
<Input
|
|
id="settings-wallet"
|
|
value={walletAddress}
|
|
onChange={(e) => setWalletAddress(e.target.value)}
|
|
placeholder="0x… or Solana wallet address for rewards payouts"
|
|
/>
|
|
</Field>
|
|
<XStack gap="$2">
|
|
<Button type="submit" size="sm" disabled={walletSaving}>
|
|
{walletSaving ? 'Saving…' : 'Save payout wallet'}
|
|
</Button>
|
|
{walletAddress ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={walletSaving}
|
|
onClick={() => void saveWallet('')}
|
|
>
|
|
Disconnect
|
|
</Button>
|
|
) : null}
|
|
</XStack>
|
|
</YStack>
|
|
</form>
|
|
</YStack>
|
|
</Section>
|
|
|
|
<Section title="Organization">
|
|
<YStack gap="$3" maxW={576}>
|
|
<Text fontSize="$3" color="$soft">
|
|
Manage your organization details, members, billing, usage and API keys.
|
|
</Text>
|
|
<XStack>
|
|
<Text
|
|
render={<Link href="/settings/organization" />}
|
|
display="inline"
|
|
textDecorationLine="none"
|
|
>
|
|
<Button variant="outline" size="sm" gap={8}>
|
|
<Building2 size={16} aria-hidden />
|
|
<span>Go to Organization settings</span>
|
|
<ArrowRight size={14} aria-hidden data-i18n-mirror />
|
|
</Button>
|
|
</Text>
|
|
</XStack>
|
|
</YStack>
|
|
</Section>
|
|
</>
|
|
)
|
|
}
|