Skip to content

Commit 2a0684f

Browse files
authored
Merge pull request #14 from Wieedze/feat/webapp-affiliate-singleton
feat(webapp): affiliate UI on the FeeProxy singleton (live testnet)
2 parents 98d8005 + f68f84f commit 2a0684f

12 files changed

Lines changed: 625 additions & 1676 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ forge-cache/
4343

4444
# Auto-synced ABIs
4545
packages/sdk/src/abis/*.json
46+
47+
#intuition contract
48+
intuition-contracts-v2

bun.lock

Lines changed: 45 additions & 1513 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 92 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,109 @@
1+
import type { ReactNode } from 'react'
2+
13
import type { AffiliateConfig, AffiliateStats } from '../contracts'
24
import Address from './Address'
3-
import { Stat } from './Stat'
4-
import { Metric } from './Metric'
55
import { formatBps, formatDateParts, formatTrust } from '../lib/format'
66

7-
/** Read-only render of an affiliate's registry row. */
8-
export function AffiliateConfigCard({ config }: { config: AffiliateConfig }) {
9-
const reg = formatDateParts(Number(config.registeredAt))
7+
/** Glassy card matching the home stats card, with a kicker title + cell grid. */
8+
function Card({
9+
title,
10+
badge,
11+
cols,
12+
children,
13+
}: {
14+
title?: string
15+
badge?: ReactNode
16+
cols: 2 | 3
17+
children: ReactNode
18+
}) {
19+
const hasHeader = Boolean(title || badge)
20+
// Drop the hairline on the first row so it never sits at the card's top edge;
21+
// inner rows keep their top border as a separator.
22+
const firstRowFlat =
23+
cols === 3
24+
? '[&>*:nth-child(-n+3)]:border-t-0 [&>*:nth-child(-n+3)]:pt-0'
25+
: '[&>*:nth-child(-n+2)]:border-t-0 [&>*:nth-child(-n+2)]:pt-0'
1026
return (
11-
<section className="space-y-4">
12-
<div className="flex items-center gap-3">
13-
<h2 className="font-semibold text-ink">Configuration</h2>
14-
{config.paused ? (
15-
<span className="text-[10px] font-mono uppercase tracking-wider text-rose-400 border border-rose-400/40 rounded px-1.5 py-0.5">
16-
Paused
17-
</span>
18-
) : (
19-
<span className="text-[10px] font-mono uppercase tracking-wider text-brand border border-brand/40 rounded px-1.5 py-0.5">
20-
Active
21-
</span>
22-
)}
27+
<section className="rounded-2xl border border-line bg-surface/70 p-6 backdrop-blur-md">
28+
{hasHeader && (
29+
<div className="flex items-center gap-3">
30+
{title && (
31+
<h2 className="text-[11px] font-medium uppercase tracking-wider text-subtle">
32+
{title}
33+
</h2>
34+
)}
35+
{badge}
36+
</div>
37+
)}
38+
<div
39+
className={`grid gap-x-6 gap-y-5 ${firstRowFlat} ${hasHeader ? 'mt-5' : ''} ${
40+
cols === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2'
41+
}`}
42+
>
43+
{children}
2344
</div>
45+
</section>
46+
)
47+
}
2448

25-
<div className="grid gap-3 sm:grid-cols-2">
26-
<Stat label="Fee recipient" value={config.feeRecipient} mono />
27-
<Stat label="Registered" value={`${reg.date} ${reg.time}`} />
28-
<Stat
29-
label="Deposit fee"
30-
value={`${formatBps(config.fees.depositBps)} + ${formatTrust(config.fees.depositFixedFee)} TRUST`}
31-
/>
32-
<Stat
33-
label="Creation fee"
34-
value={`${formatBps(config.fees.creationBps)} + ${formatTrust(config.fees.creationFixedFee)} TRUST`}
35-
/>
49+
function Cell({
50+
label,
51+
children,
52+
emphasize,
53+
}: {
54+
label: string
55+
children: ReactNode
56+
emphasize?: boolean
57+
}) {
58+
return (
59+
<div className="space-y-1 border-t border-line pt-3">
60+
<div className="text-[11px] uppercase tracking-wider text-subtle">{label}</div>
61+
<div className={`text-lg font-semibold ${emphasize ? 'text-brand' : 'text-ink'}`}>
62+
{children}
3663
</div>
64+
</div>
65+
)
66+
}
3767

38-
<div className="text-xs text-subtle inline-flex items-center gap-2">
39-
Recipient: <Address value={config.feeRecipient} variant="short" />
40-
</div>
41-
</section>
68+
/** Read-only render of an affiliate's registry row. */
69+
export function AffiliateConfigCard({ config }: { config: AffiliateConfig }) {
70+
const reg = formatDateParts(Number(config.registeredAt))
71+
72+
return (
73+
<Card cols={2}>
74+
<Cell label="Fee recipient">
75+
<Address value={config.feeRecipient} variant="short" />
76+
</Cell>
77+
<Cell label="Registered">{`${reg.date} ${reg.time}`}</Cell>
78+
<Cell label="Deposit fee">
79+
{`${formatBps(config.fees.depositBps)} + ${formatTrust(config.fees.depositFixedFee)} TRUST`}
80+
</Cell>
81+
<Cell label="Creation fee">
82+
{`${formatBps(config.fees.creationBps)} + ${formatTrust(config.fees.creationFixedFee)} TRUST`}
83+
</Cell>
84+
</Card>
4285
)
4386
}
4487

4588
/** Read-only render of an affiliate's aggregate analytics. */
4689
export function AffiliateStatsCard({ stats }: { stats: AffiliateStats }) {
4790
return (
48-
<section className="space-y-4">
49-
<h2 className="font-semibold text-ink">Analytics</h2>
50-
<div className="grid gap-3 sm:grid-cols-3">
51-
<Metric label="Transactions" value={stats.txCount.toString()} />
52-
<Metric label="Unique users" value={stats.uniqueUsers.toString()} />
53-
<Metric label="Total fees" value={`${formatTrust(stats.totalFees)} TRUST`} emphasize />
54-
<Metric label="Gross routed" value={`${formatTrust(stats.totalGrossAssets)} TRUST`} />
55-
<Metric label="Forwarded" value={`${formatTrust(stats.totalForwardedAssets)} TRUST`} />
56-
<Metric label="Deposits" value={stats.depositCount.toString()} />
57-
<Metric label="Atom/triple creations" value={stats.creationCount.toString()} />
58-
<Metric label="Deposit fees" value={`${formatTrust(stats.depositFees)} TRUST`} />
59-
<Metric label="Creation fees" value={`${formatTrust(stats.creationFees)} TRUST`} />
60-
</div>
61-
</section>
91+
<div className="space-y-4">
92+
<Card cols={3}>
93+
<Cell label="Transactions">{stats.txCount.toString()}</Cell>
94+
<Cell label="Unique users">{stats.uniqueUsers.toString()}</Cell>
95+
<Cell label="Total fees" emphasize>
96+
{`${formatTrust(stats.totalFees)} TRUST`}
97+
</Cell>
98+
</Card>
99+
<Card cols={3}>
100+
<Cell label="Gross routed">{`${formatTrust(stats.totalGrossAssets)} TRUST`}</Cell>
101+
<Cell label="Forwarded">{`${formatTrust(stats.totalForwardedAssets)} TRUST`}</Cell>
102+
<Cell label="Deposits">{stats.depositCount.toString()}</Cell>
103+
<Cell label="Atom/triple creations">{stats.creationCount.toString()}</Cell>
104+
<Cell label="Deposit fees">{`${formatTrust(stats.depositFees)} TRUST`}</Cell>
105+
<Cell label="Creation fees">{`${formatTrust(stats.creationFees)} TRUST`}</Cell>
106+
</Card>
107+
</div>
62108
)
63109
}

packages/webapp/src/components/FeeConfigFields.tsx

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,39 @@ import type { FeeConfig } from '../contracts'
33
import { formatBps, formatTrust } from '../lib/format'
44

55
export type FeeFields = {
6-
depositBps: string
7-
creationBps: string
6+
/** Percentage fees entered as a human percent (e.g. "1.5" = 1.5%). */
7+
depositPct: string
8+
creationPct: string
89
/** Fixed fees entered as a decimal TRUST amount (converted to wei). */
910
depositFixedFee: string
1011
creationFixedFee: string
1112
}
1213

14+
/**
15+
* Convert a human percentage string to on-chain bps (basis points, base 10000).
16+
* "1" → 100, "2.5" → 250, "0.05" → 5. Max 2 decimals (1 bps = 0.01%).
17+
*/
18+
function pctToBps(pct: string): bigint {
19+
const t = (pct || '0').trim()
20+
if (!/^\d+(\.\d{1,2})?$/.test(t)) {
21+
throw new Error(`Invalid percentage "${pct}" — use up to 2 decimals (e.g. 1.5)`)
22+
}
23+
return BigInt(Math.round(parseFloat(t) * 100))
24+
}
25+
26+
/** Convert on-chain bps to a clean percentage string. 250 → "2.5", 5 → "0.05". */
27+
function bpsToPct(bps: bigint): string {
28+
const whole = bps / 100n
29+
const frac = bps % 100n
30+
if (frac === 0n) return whole.toString()
31+
const fracStr = frac.toString().padStart(2, '0').replace(/0$/, '')
32+
return `${whole}.${fracStr}`
33+
}
34+
1335
/** Parse the string form fields into an on-chain `FeeConfig` (bigints). */
1436
export function parseFeeFields(f: FeeFields): FeeConfig {
15-
const depositBps = BigInt(f.depositBps || '0')
16-
const creationBps = BigInt(f.creationBps || '0')
37+
const depositBps = pctToBps(f.depositPct)
38+
const creationBps = pctToBps(f.creationPct)
1739
const depositFixedFee = parseEther((f.depositFixedFee || '0').trim())
1840
const creationFixedFee = parseEther((f.creationFixedFee || '0').trim())
1941
return { depositBps, creationBps, depositFixedFee, creationFixedFee }
@@ -22,8 +44,8 @@ export function parseFeeFields(f: FeeFields): FeeConfig {
2244
/** Build editable string fields from an on-chain `FeeConfig`. */
2345
export function feeFieldsFrom(fees: FeeConfig): FeeFields {
2446
return {
25-
depositBps: fees.depositBps.toString(),
26-
creationBps: fees.creationBps.toString(),
47+
depositPct: bpsToPct(fees.depositBps),
48+
creationPct: bpsToPct(fees.creationBps),
2749
depositFixedFee: formatTrust(fees.depositFixedFee),
2850
creationFixedFee: formatTrust(fees.creationFixedFee),
2951
}
@@ -38,15 +60,16 @@ interface Props {
3860

3961
/**
4062
* Shared fee-schedule editor used by registration and fee-update flows.
41-
* bps are integers (base 10000); fixed fees are entered in TRUST.
63+
* Percentages are entered as human percents and converted to bps on submit;
64+
* fixed fees are entered in TRUST.
4265
*/
4366
export function FeeConfigFields({ fields, onChange, maxBps, maxFixedFee }: Props) {
4467
const set = (key: keyof FeeFields) => (e: React.ChangeEvent<HTMLInputElement>) =>
4568
onChange({ ...fields, [key]: e.target.value })
4669

4770
const capHint =
4871
maxBps !== undefined
49-
? `Caps: max ${maxBps.toString()} bps (${formatBps(maxBps)})${
72+
? `Caps: max ${formatBps(maxBps)}${
5073
maxFixedFee !== undefined ? `, max ${formatTrust(maxFixedFee)} TRUST fixed` : ''
5174
}`
5275
: null
@@ -56,17 +79,17 @@ export function FeeConfigFields({ fields, onChange, maxBps, maxFixedFee }: Props
5679
<div className="text-xs text-subtle">Fee schedule{capHint ? ` — ${capHint}` : ''}</div>
5780
<div className="grid gap-4 sm:grid-cols-2">
5881
<Field
59-
label="Deposit bps"
60-
hint="base 10000"
61-
value={fields.depositBps}
62-
onChange={set('depositBps')}
82+
label="Deposit fee (%)"
83+
hint="e.g. 1.5 for 1.5%"
84+
value={fields.depositPct}
85+
onChange={set('depositPct')}
6386
mono
6487
/>
6588
<Field
66-
label="Creation bps"
67-
hint="base 10000"
68-
value={fields.creationBps}
69-
onChange={set('creationBps')}
89+
label="Creation fee (%)"
90+
hint="e.g. 1.5 for 1.5%"
91+
value={fields.creationPct}
92+
onChange={set('creationPct')}
7093
mono
7194
/>
7295
<Field

packages/webapp/src/components/LaserFlow.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,21 @@ export const LaserFlow = ({
427427
}
428428

429429
setSizeNow()
430+
// First-paint robustness: re-measure over the first frames so the beam
431+
// renders as soon as the container has its real height (no long blank wait).
432+
const sizeRetryRaf = requestAnimationFrame(setSizeNow)
433+
const sizeRetryTimers = [60, 180, 400, 800].map((ms) => setTimeout(setSizeNow, ms))
430434
const ro = new ResizeObserver(scheduleResize)
431435
ro.observe(mount)
432436

433437
const io = new IntersectionObserver(
434438
(entries) => {
435439
inViewRef.current = entries[0]?.isIntersecting ?? true
436440
},
437-
{ root: null, threshold: 0 },
441+
// Generous margin: the beam sits at the top of the page, so keep it
442+
// "in view" even before layout settles — otherwise a 0-height first
443+
// measurement reports not-intersecting and freezes the fade-in.
444+
{ root: null, threshold: 0, rootMargin: '600px' },
438445
)
439446
io.observe(mount)
440447

@@ -551,6 +558,8 @@ export const LaserFlow = ({
551558

552559
return () => {
553560
cancelAnimationFrame(raf)
561+
cancelAnimationFrame(sizeRetryRaf)
562+
sizeRetryTimers.forEach(clearTimeout)
554563
ro.disconnect()
555564
io.disconnect()
556565
document.removeEventListener('visibilitychange', onVis)

packages/webapp/src/components/Layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,14 @@ export default function Layout() {
5555
<main className="relative flex-1">
5656
<div
5757
aria-hidden
58-
className={`hidden dark:block pointer-events-none absolute inset-x-0 top-0 h-[820px] z-0 transition-opacity ease-out ${
58+
className={`hidden dark:block pointer-events-none absolute inset-x-0 top-0 h-[1400px] z-0 transition-opacity ease-out ${
5959
isHome ? 'opacity-100 duration-1000' : 'opacity-0 duration-100'
6060
}`}
6161
style={{
6262
maskImage:
63-
'linear-gradient(to bottom, transparent 0px, black 120px)',
63+
'linear-gradient(to bottom, transparent 0px, black 32px)',
6464
WebkitMaskImage:
65-
'linear-gradient(to bottom, transparent 0px, black 120px)',
65+
'linear-gradient(to bottom, transparent 0px, black 32px)',
6666
}}
6767
>
6868
<div className="mx-auto max-w-6xl h-full px-6">
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useMemo } from 'react'
2+
import type { Abi } from 'viem'
3+
import { useReadContracts } from 'wagmi'
4+
5+
import { FeeProxyABI, type AffiliateStats } from '../contracts'
6+
7+
import { useAffiliates } from './useAffiliates'
8+
import { useFeeProxyAddress } from './useFeeProxyAddress'
9+
10+
/**
11+
* Network-wide aggregate stats for the Home hero card. Enumerates affiliates
12+
* from registration events, then batch-reads each `affiliateStats` and sums
13+
* the headline figures. No indexer needed; fine for the current scale.
14+
*/
15+
export function useProtocolStats() {
16+
const { feeProxy, configured } = useFeeProxyAddress()
17+
const { affiliates, isLoading: listLoading } = useAffiliates()
18+
19+
const contracts = useMemo(
20+
() =>
21+
affiliates.map((a) => ({
22+
address: feeProxy,
23+
abi: FeeProxyABI as Abi,
24+
functionName: 'affiliateStats',
25+
args: [a.affiliate],
26+
})),
27+
[affiliates, feeProxy],
28+
)
29+
30+
const { data, isLoading: statsLoading } = useReadContracts({
31+
contracts: contracts as any,
32+
query: { enabled: configured && affiliates.length > 0 },
33+
})
34+
35+
const agg = useMemo(() => {
36+
let totalForwarded = 0n
37+
let totalFees = 0n
38+
let totalGross = 0n
39+
let totalTx = 0n
40+
if (data) {
41+
for (const r of data) {
42+
if (r.status === 'success' && r.result) {
43+
const s = r.result as unknown as AffiliateStats
44+
totalForwarded += s.totalForwardedAssets
45+
totalFees += s.totalFees
46+
totalGross += s.totalGrossAssets
47+
totalTx += s.txCount
48+
}
49+
}
50+
}
51+
return { totalForwarded, totalFees, totalGross, totalTx }
52+
}, [data])
53+
54+
return {
55+
affiliateCount: affiliates.length,
56+
...agg,
57+
configured,
58+
isLoading: listLoading || statsLoading,
59+
}
60+
}

packages/webapp/src/pages/AffiliateDetail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default function AffiliateDetailPage() {
2626

2727
if (!valid) {
2828
return (
29-
<div className="max-w-2xl space-y-4">
29+
<div className="mx-auto max-w-2xl space-y-4">
3030
<p className="text-sm text-rose-400 font-mono">Invalid affiliate address.</p>
3131
<Link to="/affiliates" className="text-sm text-brand underline">
3232
← Back to affiliates
@@ -36,7 +36,7 @@ export default function AffiliateDetailPage() {
3636
}
3737

3838
return (
39-
<div className="max-w-3xl space-y-8">
39+
<div className="mx-auto max-w-3xl space-y-8">
4040
<header className="space-y-2">
4141
<Link to="/affiliates" className="text-xs text-muted hover:text-ink transition-colors">
4242
← Affiliates

0 commit comments

Comments
 (0)