Hoodquidity: liquidity campaigns for Robinhood Chain stock token markets
Full build: Vite/React acid-terminal frontend (BEARER design system, Panchang/Sentient/Fragment Mono, neon layer, palette audit script), HoodquidityVaults Foundry contracts (single+dual deposits via Chainlink-style feeds, hybrid exit, 10% platform fee, boost, Genesis LP), Reown AppKit wallet connect with Robinhood Chain testnet (46630), live data from Dexscreener and onchain reads, partner portal, design-sync setup for claude.ai/design. Deploy: static via Forgejo Actions platform, BUILD_DIR=apps/web/dist (.env). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
dba2811447
1194 changed files with 206422 additions and 0 deletions
17
.claude/launch.json
Normal file
17
.claude/launch.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@hoodquidity/web", "dev"],
|
||||
"autoPort": true
|
||||
},
|
||||
{
|
||||
"name": "ds-review",
|
||||
"runtimeExecutable": "sh",
|
||||
"runtimeArgs": ["-c", "python3 -m http.server ${PORT:-4400} -d ds-bundle"],
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
30
.design-sync/NOTES.md
Normal file
30
.design-sync/NOTES.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# design-sync notes — Hoodquidity
|
||||
|
||||
- App-shaped repo (no library dist): components are pinned explicitly via
|
||||
`componentSrcMap` (17 pins), and `entry` points at `apps/web/src/ds-entry.ts`,
|
||||
a hand-written barrel exporting only the pure presentational surface.
|
||||
Context-bound pieces (Header/Footer/MobileNav/AppShell — react-router;
|
||||
TickerTape/DepositPanel/PositionPanel — wagmi/react-query; CampaignCard —
|
||||
router Link; Preloader — sessionStorage lifecycle) are deliberately excluded.
|
||||
- CSS comes from the Vite app build: `buildCmd` copies the hashed
|
||||
`dist/assets/index-*.css` to the stable `dist/ds-styles.css` (cfg.cssEntry).
|
||||
Rebuild the app before re-syncing if styles changed.
|
||||
- Known render warns:
|
||||
- `[TOKENS_MISSING] --rk-*` (43 vars) — RainbowKit runtime theme variables
|
||||
baked into the compiled app CSS; injected at runtime by RainbowKitProvider
|
||||
in the real app. None of the 17 synced components reference them. Safe.
|
||||
- `[RENDER_SKIPPED]` — user declined the playwright/chromium install
|
||||
(2026-07-09); previews are verified by eye via .review.html instead.
|
||||
- Fonts: 9 woff2 files under `apps/web/public/fonts/`, listed in
|
||||
`extraFonts`; @font-face rules ship inside the compiled CSS and their urls
|
||||
get rewritten to `fonts/` by the converter.
|
||||
|
||||
## Re-sync risks
|
||||
|
||||
- `dist/ds-styles.css` is a snapshot of the app build — a re-sync without
|
||||
re-running `buildCmd` ships stale styles for new/changed Tailwind utilities.
|
||||
- Previews under `.design-sync/previews/` compose components with realistic
|
||||
props; if component APIs change (e.g. Stat.value type), previews may
|
||||
silently degrade — re-grade after API changes.
|
||||
- No machine render check on this machine (no chromium): a future sync should
|
||||
re-offer the playwright install; visual verification currently manual.
|
||||
46
.design-sync/config.json
Normal file
46
.design-sync/config.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"projectId": "179c476c-1bba-4a20-93b6-6ba97067b384",
|
||||
"shape": "package",
|
||||
"readmeHeader": ".design-sync/conventions.md",
|
||||
"pkg": "@hoodquidity/web",
|
||||
"globalName": "Hoodquidity",
|
||||
"buildCmd": "pnpm --filter @hoodquidity/web build && cp apps/web/dist/assets/index-*.css apps/web/dist/ds-styles.css",
|
||||
"entry": "apps/web/src/ds-entry.ts",
|
||||
"srcDir": "src",
|
||||
"tsconfig": "tsconfig.json",
|
||||
"cssEntry": "dist/ds-styles.css",
|
||||
"componentSrcMap": {
|
||||
"Button": "src/components/ui/button.tsx",
|
||||
"Card": "src/components/ui/card.tsx",
|
||||
"Badge": "src/components/ui/badge.tsx",
|
||||
"Skeleton": "src/components/ui/skeleton.tsx",
|
||||
"Stat": "src/components/ui/stat.tsx",
|
||||
"LogoMark": "src/components/brand/Logo.tsx",
|
||||
"Wordmark": "src/components/brand/Logo.tsx",
|
||||
"XIcon": "src/components/icons/XIcon.tsx",
|
||||
"DepthProgress": "src/components/DepthProgress.tsx",
|
||||
"HealthRadial": "src/components/HealthRadial.tsx",
|
||||
"SlippageCurve": "src/components/SlippageCurve.tsx",
|
||||
"CountUp": "src/components/CountUp.tsx",
|
||||
"GlyphMarquee": "src/components/GlyphMarquee.tsx",
|
||||
"DepthField": "src/components/backdrop/DepthField.tsx",
|
||||
"DepthLadder": "src/components/backdrop/DepthLadder.tsx",
|
||||
"GlowField": "src/components/backdrop/GlowField.tsx",
|
||||
"GlyphField": "src/components/backdrop/GlyphField.tsx"
|
||||
},
|
||||
"overrides": {
|
||||
"GlyphMarquee": { "cardMode": "column" },
|
||||
"SlippageCurve": { "cardMode": "column" }
|
||||
},
|
||||
"extraFonts": [
|
||||
"public/fonts/Tanker-Regular.woff2",
|
||||
"public/fonts/CabinetGrotesk-Extrabold.woff2",
|
||||
"public/fonts/CabinetGrotesk-Bold.woff2",
|
||||
"public/fonts/CabinetGrotesk-Medium.woff2",
|
||||
"public/fonts/Switzer-Regular.woff2",
|
||||
"public/fonts/Switzer-Medium.woff2",
|
||||
"public/fonts/Switzer-Semibold.woff2",
|
||||
"public/fonts/JetBrainsMono-Regular.woff2",
|
||||
"public/fonts/JetBrainsMono-SemiBold.woff2"
|
||||
]
|
||||
}
|
||||
62
.design-sync/conventions.md
Normal file
62
.design-sync/conventions.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Hoodquidity conventions
|
||||
|
||||
Dark-first acid-terminal DeFi aesthetic: near-black backgrounds, acid lime
|
||||
`#c8ff00` as the only strong accent, monospace numerals, uppercase display
|
||||
type. Components come from `window.Hoodquidity.*`. No provider or wrapper is
|
||||
required — every component renders standalone.
|
||||
|
||||
## Styling idiom
|
||||
|
||||
Tailwind utility classes over design tokens. CRITICAL: the shipped stylesheet
|
||||
is compiled — it contains ONLY the classes listed below plus what the
|
||||
components themselves use. Do not invent other Tailwind classes (they will not
|
||||
resolve); for anything beyond this vocabulary use inline styles with the
|
||||
`var(--color-*)` tokens.
|
||||
|
||||
Color utilities (bg-/text-/border- work for each token):
|
||||
- Surfaces: `bg-void` (page), `bg-surface`, `bg-card`, `bg-card-hover`
|
||||
- Accent: `text-mint` / `bg-mint` (acid #c8ff00), `text-lime` (pale #eaff8a), `bg-mint-soft` (10% tint)
|
||||
- Text: `text-snow` (off-white), `text-fog` (muted), `text-fog-dim` (faint)
|
||||
- Lines: `border-edge`, `border-edge-bright`
|
||||
- Status: `text-amber`, `text-danger`
|
||||
|
||||
Typography: `font-display` (Tanker — uppercase headlines; h1/h2 get it
|
||||
automatically), `font-mono` (JetBrains Mono — all data/numbers), body text is
|
||||
Switzer by default. Signature helper classes shipped in the CSS:
|
||||
- `num` — tabular-nums mono, use on every numeric value
|
||||
- `slabel` — section label, renders a `// LABEL` terminal prefix
|
||||
- `leader` — dotted-leader row (label ..... value), first child label, last child value
|
||||
- `ticks` — terminal corner ticks on cards (Card applies it already)
|
||||
- `grain` — film-grain overlay for a page root; `dither` — Bayer dither texture
|
||||
- `sidelabel` — vertical rotated display text; `btn-sweep` — hover light sweep
|
||||
- `animate-marquee`, `animate-pulse-dot`, `hover:shadow-glow`
|
||||
|
||||
## Where the truth lives
|
||||
|
||||
Read `styles.css` and its imports (tokens in `:root` as `--color-*`,
|
||||
`--font-*`; fonts ship in `fonts/`) before styling. Per-component API and
|
||||
usage: `components/<group>/<Name>/<Name>.d.ts` and `.prompt.md`.
|
||||
|
||||
## Idiomatic snippet
|
||||
|
||||
```jsx
|
||||
const { Card, Badge, Stat, Button, DepthProgress } = window.Hoodquidity;
|
||||
|
||||
<div className="bg-void min-h-screen p-8">
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-display text-xl">NVDA <span className="text-fog-dim">/ hUSD</span></span>
|
||||
<Badge tone="mint" pulse>Active</Badge>
|
||||
</div>
|
||||
<DepthProgress progress={0.55} />
|
||||
<div className="leader text-sm">
|
||||
<span className="text-fog">Vault TVL</span>
|
||||
<span className="num font-semibold text-mint">$215K</span>
|
||||
</div>
|
||||
<Button size="lg" className="w-full">Deposit hUSD</Button>
|
||||
</Card>
|
||||
</div>
|
||||
```
|
||||
|
||||
Content voice: headings without trailing periods, no em dashes, realistic
|
||||
fintech data (tickers NVDA/TSLA/SPY, USD amounts, APR percentages).
|
||||
31
.design-sync/previews/Badge.tsx
Normal file
31
.design-sync/previews/Badge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Badge } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Tones = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Badge tone="mint">Active</Badge>
|
||||
<Badge tone="lime">Boost window</Badge>
|
||||
<Badge tone="amber">Medium risk</Badge>
|
||||
<Badge tone="danger">High risk</Badge>
|
||||
<Badge tone="fog">Ended</Badge>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const LiveIndicators = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Badge tone="mint" pulse>
|
||||
Live on Robinhood Chain testnet
|
||||
</Badge>
|
||||
<Badge tone="lime" pulse>
|
||||
Boost window open
|
||||
</Badge>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
45
.design-sync/previews/Button.tsx
Normal file
45
.design-sync/previews/Button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Button } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Cards render on white; the DS is dark — stories sit on the app's void bg. */
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Primary = () => (
|
||||
<Panel>
|
||||
<Button>Explore campaigns</Button>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const Variants = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Button variant="primary">Deposit hUSD</Button>
|
||||
<Button variant="outline">View market depth</Button>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Button variant="danger">Exit early</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Button size="sm">Claim</Button>
|
||||
<Button size="md">Deposit</Button>
|
||||
<Button size="lg">Start providing depth</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const Disabled = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Button disabled>Confirming...</Button>
|
||||
<Button variant="outline" disabled>
|
||||
Campaign ended
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
36
.design-sync/previews/Card.tsx
Normal file
36
.design-sync/previews/Card.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Card, Badge, Stat } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Basic = () => (
|
||||
<Panel>
|
||||
<Card>
|
||||
<div style={{ fontFamily: "Cabinet Grotesk", fontSize: 20, fontWeight: 700 }}>
|
||||
NVDA <span style={{ opacity: 0.4 }}>/ hUSD</span>
|
||||
</div>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 13, color: "#94a26b", lineHeight: 1.5 }}>
|
||||
Depth campaign for the NVIDIA Stock Token market. Deposits flow into the
|
||||
Depth Vault and count toward the campaign target.
|
||||
</p>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const WithStats = () => (
|
||||
<Panel>
|
||||
<Card>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||||
<span style={{ fontFamily: "Cabinet Grotesk", fontSize: 18, fontWeight: 700 }}>Campaign vault</span>
|
||||
<Badge tone="mint" pulse>Active</Badge>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 24 }}>
|
||||
<Stat label="Vault TVL" value="$215K" accent />
|
||||
<Stat label="Est APR" value="66.3%" />
|
||||
<Stat label="Time left" value="13d 4h" />
|
||||
</div>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
30
.design-sync/previews/CountUp.tsx
Normal file
30
.design-sync/previews/CountUp.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { CountUp } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
const usd = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
export const Currency = () => (
|
||||
<Panel>
|
||||
<span style={{ fontFamily: "JetBrains Mono", fontSize: 28, fontWeight: 600, color: "#c8ff00" }}>
|
||||
<CountUp value={10_500_000} format={usd} />
|
||||
</span>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const Plain = () => (
|
||||
<Panel>
|
||||
<span style={{ fontFamily: "JetBrains Mono", fontSize: 28, fontWeight: 600, color: "#f4ffd8" }}>
|
||||
<CountUp value={1284} format={(n) => Math.round(n).toLocaleString("en-US")} />
|
||||
</span>
|
||||
</Panel>
|
||||
);
|
||||
13
.design-sync/previews/DepthField.tsx
Normal file
13
.design-sync/previews/DepthField.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { DepthField } from "@hoodquidity/web";
|
||||
|
||||
export const Default = () => (
|
||||
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<DepthField />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Flipped = () => (
|
||||
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<DepthField flip />
|
||||
</div>
|
||||
);
|
||||
7
.design-sync/previews/DepthLadder.tsx
Normal file
7
.design-sync/previews/DepthLadder.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { DepthLadder } from "@hoodquidity/web";
|
||||
|
||||
export const Default = () => (
|
||||
<div style={{ width: 420, height: 300, background: "#060704", borderRadius: 10, padding: 12 }}>
|
||||
<DepthLadder />
|
||||
</div>
|
||||
);
|
||||
39
.design-sync/previews/DepthProgress.tsx
Normal file
39
.design-sync/previews/DepthProgress.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { DepthProgress } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<div style={{ display: "grid", gap: 6, width: 360 }}>
|
||||
<span style={{ fontFamily: "JetBrains Mono", fontSize: 10, letterSpacing: "0.18em", textTransform: "uppercase", color: "#5d6742" }}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Levels = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "grid", gap: 18 }}>
|
||||
<Row label="Collecting - 18%">
|
||||
<DepthProgress progress={0.18} />
|
||||
</Row>
|
||||
<Row label="Halfway - 55%">
|
||||
<DepthProgress progress={0.55} />
|
||||
</Row>
|
||||
<Row label="Target reached - 100%">
|
||||
<DepthProgress progress={1} />
|
||||
</Row>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const FineGrained = () => (
|
||||
<Panel>
|
||||
<Row label="48 segments - 72%">
|
||||
<DepthProgress progress={0.72} segments={48} />
|
||||
</Row>
|
||||
</Panel>
|
||||
);
|
||||
7
.design-sync/previews/GlowField.tsx
Normal file
7
.design-sync/previews/GlowField.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { GlowField } from "@hoodquidity/web";
|
||||
|
||||
export const Default = () => (
|
||||
<div style={{ position: "relative", height: 280, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<GlowField />
|
||||
</div>
|
||||
);
|
||||
13
.design-sync/previews/GlyphField.tsx
Normal file
13
.design-sync/previews/GlyphField.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { GlyphField } from "@hoodquidity/web";
|
||||
|
||||
export const Default = () => (
|
||||
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<GlyphField opacity={0.7} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Subtle = () => (
|
||||
<div style={{ position: "relative", height: 260, background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<GlyphField opacity={0.3} />
|
||||
</div>
|
||||
);
|
||||
7
.design-sync/previews/GlyphMarquee.tsx
Normal file
7
.design-sync/previews/GlyphMarquee.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { GlyphMarquee } from "@hoodquidity/web";
|
||||
|
||||
export const Default = () => (
|
||||
<div style={{ background: "#060704", borderRadius: 10, overflow: "hidden" }}>
|
||||
<GlyphMarquee />
|
||||
</div>
|
||||
);
|
||||
16
.design-sync/previews/HealthRadial.tsx
Normal file
16
.design-sync/previews/HealthRadial.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { HealthRadial } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Scores = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 40, flexWrap: "wrap" }}>
|
||||
<HealthRadial score={82} />
|
||||
<HealthRadial score={55} />
|
||||
<HealthRadial score={18} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
17
.design-sync/previews/LogoMark.tsx
Normal file
17
.design-sync/previews/LogoMark.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { LogoMark } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
|
||||
<LogoMark className="h-6 w-6" />
|
||||
<LogoMark className="h-8 w-8" />
|
||||
<LogoMark className="h-12 w-12" />
|
||||
<LogoMark className="h-16 w-16" />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
31
.design-sync/previews/Skeleton.tsx
Normal file
31
.design-sync/previews/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Skeleton, Card } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const TextLines = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "grid", gap: 10, maxWidth: 360 }}>
|
||||
<Skeleton style={{ height: 24, width: "60%" }} />
|
||||
<Skeleton style={{ height: 14, width: "100%" }} />
|
||||
<Skeleton style={{ height: 14, width: "85%" }} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const LoadingCard = () => (
|
||||
<Panel>
|
||||
<Card style={{ maxWidth: 380 }}>
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Skeleton style={{ height: 22, width: 130 }} />
|
||||
<Skeleton style={{ height: 20, width: 70, borderRadius: 999 }} />
|
||||
</div>
|
||||
<Skeleton style={{ height: 26, width: 110 }} />
|
||||
<Skeleton style={{ height: 10, width: "100%" }} />
|
||||
</div>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
18
.design-sync/previews/SlippageCurve.tsx
Normal file
18
.design-sync/previews/SlippageCurve.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { SlippageCurve } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10, maxWidth: 460 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const DeepMarket = () => (
|
||||
<Panel>
|
||||
<SlippageCurve liquidityUsd={5_400_000} />
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const ShallowMarket = () => (
|
||||
<Panel>
|
||||
<SlippageCurve liquidityUsd={420_000} />
|
||||
</Panel>
|
||||
);
|
||||
29
.design-sync/previews/Stat.tsx
Normal file
29
.design-sync/previews/Stat.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Stat } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Single = () => (
|
||||
<Panel>
|
||||
<Stat label="Total value locked" value="$4.2M" accent />
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const WithHint = () => (
|
||||
<Panel>
|
||||
<Stat label="Token price" value="$201.85" hint="+2.32% 24h" />
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const StatRow = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 32 }}>
|
||||
<Stat label="Tracked market depth" value="$10.5M" accent />
|
||||
<Stat label="Active campaigns" value="5" />
|
||||
<Stat label="24h market volume" value="$5.4M" />
|
||||
<Stat label="Vault TVL" value="$215K" />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
21
.design-sync/previews/Wordmark.tsx
Normal file
21
.design-sync/previews/Wordmark.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { LogoMark, Wordmark } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Default = () => (
|
||||
<Panel>
|
||||
<Wordmark />
|
||||
</Panel>
|
||||
);
|
||||
|
||||
export const Lockup = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
|
||||
<LogoMark />
|
||||
<Wordmark />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
18
.design-sync/previews/XIcon.tsx
Normal file
18
.design-sync/previews/XIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { XIcon } from "@hoodquidity/web";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Panel = ({ children }: { children: ReactNode }) => (
|
||||
<div style={{ background: "#060704", padding: 20, borderRadius: 10 }}>{children}</div>
|
||||
);
|
||||
|
||||
export const Sizes = () => (
|
||||
<Panel>
|
||||
<div style={{ display: "flex", gap: 20, alignItems: "center", color: "#f4ffd8" }}>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<XIcon className="h-6 w-6" />
|
||||
<span style={{ color: "#c8ff00" }}>
|
||||
<XIcon className="h-8 w-8" />
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
3
.env
Normal file
3
.env
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DOMAIN=hoodquidity.xyz
|
||||
DEPLOY=static
|
||||
BUILD_DIR=apps/web/dist
|
||||
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Deploy config (Forgejo Actions platform). Copy to .env and commit.
|
||||
# The site domain, A-record must point at the platform server
|
||||
DOMAIN=hoodquidity.xyz
|
||||
|
||||
# Static frontend built from the pnpm/bun workspace
|
||||
DEPLOY=static
|
||||
BUILD_DIR=apps/web/dist
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
cache/
|
||||
broadcast/
|
||||
# .env is deploy config and is committed (platform reads it); secrets never go there
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
.ds-sync/
|
||||
ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Hoodquidity
|
||||
|
||||
Liquidity campaigns for Stock Token markets on Robinhood Chain. Deposit
|
||||
stablecoins into a Depth Vault, receive an LP receipt and earn from deeper
|
||||
markets.
|
||||
|
||||
## Stack
|
||||
|
||||
- `apps/web`: React 18 + Vite + Tailwind v4, wagmi + viem + RainbowKit,
|
||||
Framer Motion, SVG animations. Ships as a static build.
|
||||
- `packages/contracts`: Foundry. `HoodquidityVaults` (campaigns, deposits,
|
||||
masterchef-style rewards with early boost, hybrid exit) and `MockERC20`
|
||||
(test assets with an open faucet).
|
||||
|
||||
Live data: Dexscreener API for tokenized stock prices, volume and liquidity;
|
||||
`https://1rpc.io/eth` for L1 chain stats; the vault contract for TVL,
|
||||
positions and rewards.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
|
||||
# terminal 1: local chain
|
||||
anvil --port 8545
|
||||
|
||||
# terminal 2: multicall3 + demo deployment (5 campaigns)
|
||||
./packages/contracts/script/setup-local.sh
|
||||
|
||||
# terminal 3: the app
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If deploy addresses change, update `apps/web/src/lib/contracts.ts`.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
pnpm test:contracts
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
pnpm build # apps/web/dist, precompressed with gzip + brotli
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
Deploy only the `apps/web/dist` directory to the server.
|
||||
|
||||
## Before release
|
||||
|
||||
- Replace the WalletConnect `projectId` in `apps/web/src/lib/wagmi.ts`
|
||||
- Set the real Twitter URL in `apps/web/src/lib/constants.ts`
|
||||
- Deploy contracts to the public testnet (`script/Deploy.s.sol`) and fill in
|
||||
the `testnet` deployment in `apps/web/src/lib/contracts.ts`
|
||||
16
apps/web/index.html
Normal file
16
apps/web/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="base-verification" content="7f3a9c21-4e8b-4d52-a1c6-9b0e2f8d4471" />
|
||||
<meta name="theme-color" content="#080808" />
|
||||
<meta name="description" content="Provide liquidity to Stock Token markets and earn from deeper markets" />
|
||||
<title>Hoodquidity</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
apps/web/package.json
Normal file
36
apps/web/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@hoodquidity/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"audit:palette": "bash scripts/palette-audit.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reown/appkit": "^1.8.22",
|
||||
"@reown/appkit-adapter-wagmi": "^1.8.22",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"viem": "^2.21.0",
|
||||
"wagmi": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-compression2": "^2.5.3"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/favicon.ico
Normal file
BIN
apps/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/web/public/fonts/CabinetGrotesk-Bold.woff2
Normal file
BIN
apps/web/public/fonts/CabinetGrotesk-Bold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/CabinetGrotesk-Extrabold.woff2
Normal file
BIN
apps/web/public/fonts/CabinetGrotesk-Extrabold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/CabinetGrotesk-Medium.woff2
Normal file
BIN
apps/web/public/fonts/CabinetGrotesk-Medium.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/FragmentMono-Regular.woff2
Normal file
BIN
apps/web/public/fonts/FragmentMono-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
apps/web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/JetBrainsMono-SemiBold.woff2
Normal file
BIN
apps/web/public/fonts/JetBrainsMono-SemiBold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Panchang-Bold.woff2
Normal file
BIN
apps/web/public/fonts/Panchang-Bold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Panchang-Extrabold.woff2
Normal file
BIN
apps/web/public/fonts/Panchang-Extrabold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Sentient-Light.woff2
Normal file
BIN
apps/web/public/fonts/Sentient-Light.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Sentient-LightItalic.woff2
Normal file
BIN
apps/web/public/fonts/Sentient-LightItalic.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Sentient-Regular.woff2
Normal file
BIN
apps/web/public/fonts/Sentient-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Switzer-Medium.woff2
Normal file
BIN
apps/web/public/fonts/Switzer-Medium.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Switzer-Regular.woff2
Normal file
BIN
apps/web/public/fonts/Switzer-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Switzer-Semibold.woff2
Normal file
BIN
apps/web/public/fonts/Switzer-Semibold.woff2
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Tanker-Regular.woff2
Normal file
BIN
apps/web/public/fonts/Tanker-Regular.woff2
Normal file
Binary file not shown.
38
apps/web/scripts/palette-audit.sh
Executable file
38
apps/web/scripts/palette-audit.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
# Palette audit: fails when a color literal outside the BEARER palette appears
|
||||
# in the app source. Alpha (rgba) variants of palette tokens are allowed.
|
||||
# SVG luminance masks (#fff/#000 in GlyphField) are exempt by design.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# hex tokens, lowercase
|
||||
ALLOWED_HEX="080808|111111|161616|1c1c1c|2a2a2a|3d3d3d|f2f2f2|919191|5c5c5c|c8ff00|8fb000|e9ffb3|5200ff|8a5cff|ff3b00"
|
||||
# rgba bases: mint 200,255,0 · volt 82,0,255 · lime 233,255,179 · snow 242,242,242
|
||||
ALLOWED_RGBA="^rgba?\((200, ?255, ?0|82, ?0, ?255|233, ?255, ?179|242, ?242, ?242)(, ?[0-9.]+)?\)$"
|
||||
|
||||
FAIL=0
|
||||
|
||||
# --- hex literals -----------------------------------------------------------
|
||||
while IFS=: read -r file line hex; do
|
||||
low=$(printf "%s" "$hex" | tr 'A-F' 'a-f')
|
||||
norm=${low#\#}
|
||||
[[ "$norm" =~ ^($ALLOWED_HEX)$ ]] && continue
|
||||
# exempt: luminance mask stops in GlyphField
|
||||
if [[ "$file" == *"GlyphField"* && ( "$norm" == "fff" || "$norm" == "000" ) ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "STRAY HEX $file:$line $hex"
|
||||
FAIL=1
|
||||
done < <(grep -rnoE "#[0-9a-fA-F]{3,8}\b" src/ index.html | grep -v "^\s*//" )
|
||||
|
||||
# --- rgb/rgba literals ------------------------------------------------------
|
||||
while IFS=: read -r file line rgba; do
|
||||
[[ "$rgba" =~ $ALLOWED_RGBA ]] && continue
|
||||
echo "STRAY RGBA $file:$line $rgba"
|
||||
FAIL=1
|
||||
done < <(grep -rnoE "rgba?\([0-9, .]+\)" src/)
|
||||
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
echo "palette audit clean: every literal maps to a BEARER token"
|
||||
fi
|
||||
exit $FAIL
|
||||
25
apps/web/src/App.tsx
Normal file
25
apps/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import Landing from "@/screens/Landing";
|
||||
import AppBoard from "@/screens/AppBoard";
|
||||
import Campaigns from "@/screens/Campaigns";
|
||||
import CampaignDetail from "@/screens/CampaignDetail";
|
||||
import Markets from "@/screens/Markets";
|
||||
import Positions from "@/screens/Positions";
|
||||
import Partners from "@/screens/Partners";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Landing />} />
|
||||
<Route path="/app" element={<AppBoard />} />
|
||||
<Route path="/campaigns" element={<Campaigns />} />
|
||||
<Route path="/campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="/markets" element={<Markets />} />
|
||||
<Route path="/positions" element={<Positions />} />
|
||||
<Route path="/partners" element={<Partners />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
149
apps/web/src/components/CampaignCard.tsx
Normal file
149
apps/web/src/components/CampaignCard.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { motion } from "motion/react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DepthProgress } from "@/components/DepthProgress";
|
||||
import type { CampaignState } from "@/hooks/useCampaigns";
|
||||
import type { PairData } from "@/lib/dexscreener";
|
||||
import { feeApr, rewardApr } from "@/lib/math";
|
||||
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
|
||||
import { inView } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_META = {
|
||||
boost: { label: "Boost window", tone: "lime" as const, pulse: true },
|
||||
active: { label: "Active", tone: "mint" as const, pulse: true },
|
||||
upcoming: { label: "Upcoming", tone: "fog" as const, pulse: false },
|
||||
ended: { label: "Ended", tone: "fog" as const, pulse: false },
|
||||
};
|
||||
|
||||
const RISK_TONE = { Low: "mint", Medium: "fog", High: "danger" } as const;
|
||||
|
||||
export function CampaignCard({
|
||||
campaign,
|
||||
pair,
|
||||
index,
|
||||
}: {
|
||||
campaign: CampaignState;
|
||||
pair: PairData | null | undefined;
|
||||
index: number;
|
||||
}) {
|
||||
const { meta } = campaign;
|
||||
const marketDepth = (pair?.liquidityUsd ?? 0) + campaign.tvlUsd;
|
||||
const progress = campaign.targetDepthUsd > 0 ? marketDepth / campaign.targetDepthUsd : 0;
|
||||
const fApr = pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0;
|
||||
const rApr = rewardApr(campaign.rewardBudgetUsd, campaign.durationDays, campaign.tvlUsd);
|
||||
const status = STATUS_META[campaign.status];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...inView}
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.45, delay: index * 0.07 }}
|
||||
>
|
||||
<Link to={`/campaigns/${meta.campaignId}`} className="block">
|
||||
<Card className="group h-full space-y-4 hover:border-edge-bright hover:bg-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-display text-xl font-bold">
|
||||
{meta.symbol}
|
||||
<span className="font-mono text-[0.6em] font-normal normal-case tracking-normal text-fog-dim"> / hUSD</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-fog">{meta.name}</div>
|
||||
</div>
|
||||
<Badge tone={status.tone} pulse={status.pulse}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between">
|
||||
{pair ? (
|
||||
<>
|
||||
<span className="num text-lg font-semibold">
|
||||
${pair.priceUsd.toFixed(2)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"num text-xs",
|
||||
pair.change24h >= 0 ? "text-mint" : "text-danger",
|
||||
)}
|
||||
>
|
||||
{pair.change24h >= 0 ? "+" : ""}
|
||||
{pair.change24h.toFixed(2)}% 24h
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="h-6 w-28" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="font-mono uppercase tracking-wider text-fog-dim">
|
||||
Depth
|
||||
</span>
|
||||
<span className="num text-fog">
|
||||
{fmtUsd(marketDepth)} / {fmtUsd(campaign.targetDepthUsd)}
|
||||
</span>
|
||||
</div>
|
||||
<DepthProgress progress={progress} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-edge pt-3 text-center">
|
||||
<div>
|
||||
<div className="num text-sm font-semibold text-mint">
|
||||
{fmtAprPct(fApr + rApr)}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
|
||||
Est APR
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-sm font-semibold">{fmtUsd(campaign.tvlUsd)}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
|
||||
Vault TVL
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-sm font-semibold">
|
||||
{campaign.status === "ended" ? "Closed" : fmtCountdown(campaign.end)}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
|
||||
{campaign.status === "upcoming" ? "Starts in" : "Time left"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<Badge tone={RISK_TONE[meta.risk]}>{meta.risk} risk</Badge>
|
||||
<span className="font-mono text-fog-dim transition-colors group-hover:text-mint">
|
||||
Enter vault →
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CampaignCardSkeleton() {
|
||||
return (
|
||||
<Card className="h-full space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-28" />
|
||||
<Skeleton className="h-2.5 w-full" />
|
||||
<div className="grid grid-cols-3 gap-2 pt-3">
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/CountUp.tsx
Normal file
38
apps/web/src/components/CountUp.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { motionEnabled } from "@/lib/motion";
|
||||
|
||||
/** Eases a number toward its live value, so stats tick up instead of popping. */
|
||||
export function CountUp({
|
||||
value,
|
||||
format,
|
||||
duration = 1100,
|
||||
}: {
|
||||
value: number;
|
||||
format: (n: number) => string;
|
||||
duration?: number;
|
||||
}) {
|
||||
const [display, setDisplay] = useState(() => (motionEnabled ? 0 : value));
|
||||
const displayRef = useRef(display);
|
||||
displayRef.current = display;
|
||||
|
||||
useEffect(() => {
|
||||
if (!motionEnabled) {
|
||||
setDisplay(value);
|
||||
return;
|
||||
}
|
||||
const from = displayRef.current;
|
||||
if (from === value) return;
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
const tick = (t: number) => {
|
||||
const p = Math.min((t - start) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3);
|
||||
setDisplay(from + (value - from) * eased);
|
||||
if (p < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [value, duration]);
|
||||
|
||||
return <>{format(display)}</>;
|
||||
}
|
||||
321
apps/web/src/components/DepositPanel.tsx
Normal file
321
apps/web/src/components/DepositPanel.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { parseUnits } from "viem";
|
||||
import {
|
||||
useAccount,
|
||||
useReadContracts,
|
||||
useWaitForTransactionReceipt,
|
||||
useWriteContract,
|
||||
} from "wagmi";
|
||||
import { useAppKit } from "@reown/appkit/react";
|
||||
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
|
||||
import { mockERC20Abi } from "@/lib/abi/MockERC20";
|
||||
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
|
||||
import { slippageImpact } from "@/lib/math";
|
||||
import { fmtPct, fmtUsd } from "@/lib/format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CampaignState } from "@/hooks/useCampaigns";
|
||||
import type { PairData } from "@/lib/dexscreener";
|
||||
|
||||
const PREVIEW_TRADE = 5_000;
|
||||
const ZERO = "0x0000000000000000000000000000000000000000" as const;
|
||||
|
||||
type Mode = "single" | "dual";
|
||||
|
||||
export function DepositPanel({
|
||||
campaign,
|
||||
pair,
|
||||
onSettled,
|
||||
}: {
|
||||
campaign: CampaignState;
|
||||
pair: PairData | null | undefined;
|
||||
onSettled: () => void;
|
||||
}) {
|
||||
const { address, isConnected } = useAccount();
|
||||
const { open: openConnectModal } = useAppKit();
|
||||
const deployment = getActiveDeployment();
|
||||
const [mode, setMode] = useState<Mode>("single");
|
||||
const [input, setInput] = useState("");
|
||||
const [stockInput, setStockInput] = useState("");
|
||||
|
||||
const stockToken = campaign.meta.stockToken;
|
||||
const symbol = campaign.meta.symbol;
|
||||
|
||||
const amount = useMemo(() => {
|
||||
const n = Number(input);
|
||||
if (!Number.isFinite(n) || n < 0) return 0n;
|
||||
try {
|
||||
return input ? parseUnits(input, USD_DECIMALS) : 0n;
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const stockAmount = useMemo(() => {
|
||||
const n = Number(stockInput);
|
||||
if (!Number.isFinite(n) || n < 0) return 0n;
|
||||
try {
|
||||
return stockInput ? parseUnits(stockInput, 18) : 0n;
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}, [stockInput]);
|
||||
|
||||
const { data: walletData, refetch: refetchWallet } = useReadContracts({
|
||||
contracts: [
|
||||
{
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: [address ?? ZERO],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "allowance",
|
||||
args: [address ?? ZERO, deployment.vaults],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: stockToken,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: [address ?? ZERO],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: stockToken,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "allowance",
|
||||
args: [address ?? ZERO, deployment.vaults],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "quoteStock",
|
||||
args: [BigInt(campaign.meta.campaignId), stockAmount > 0n ? stockAmount : 10n ** 18n],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
],
|
||||
query: { enabled: !!address },
|
||||
});
|
||||
|
||||
const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n;
|
||||
const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n;
|
||||
const stockBalance = (walletData?.[2]?.result as bigint | undefined) ?? 0n;
|
||||
const stockAllowance = (walletData?.[3]?.result as bigint | undefined) ?? 0n;
|
||||
const stockQuote = (walletData?.[4]?.result as bigint | undefined) ?? 0n;
|
||||
|
||||
/** USD value of the entered stock leg (or unit price when input empty). */
|
||||
const stockValueUsd = Number(stockQuote) / 10 ** USD_DECIMALS;
|
||||
|
||||
const { writeContract, data: txHash, isPending, reset } = useWriteContract();
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
||||
hash: txHash,
|
||||
});
|
||||
|
||||
if (isSuccess) {
|
||||
reset();
|
||||
refetchWallet();
|
||||
onSettled();
|
||||
}
|
||||
|
||||
const busy = isPending || isConfirming;
|
||||
const dual = mode === "dual";
|
||||
|
||||
const needsUsdFaucet = amount > 0n && balance < amount;
|
||||
const needsUsdApprove = !needsUsdFaucet && amount > 0n && allowance < amount;
|
||||
const needsStockFaucet = dual && stockAmount > 0n && stockBalance < stockAmount;
|
||||
const needsStockApprove =
|
||||
dual && !needsStockFaucet && stockAmount > 0n && stockAllowance < stockAmount;
|
||||
|
||||
const ready = dual ? stockAmount > 0n : amount > 0n;
|
||||
|
||||
const depositUsd = (Number(input) || 0) + (dual && stockAmount > 0n ? stockValueUsd : 0);
|
||||
const impact = pair
|
||||
? slippageImpact(PREVIEW_TRADE, pair.liquidityUsd + campaign.tvlUsd, depositUsd)
|
||||
: null;
|
||||
|
||||
const act = () => {
|
||||
if (!isConnected) return openConnectModal?.();
|
||||
if (needsUsdFaucet) {
|
||||
writeContract({
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "faucet",
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else if (needsStockFaucet) {
|
||||
writeContract({
|
||||
address: stockToken,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "faucet",
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else if (needsUsdApprove) {
|
||||
writeContract({
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "approve",
|
||||
args: [deployment.vaults, amount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else if (needsStockApprove) {
|
||||
writeContract({
|
||||
address: stockToken,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "approve",
|
||||
args: [deployment.vaults, stockAmount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else if (dual) {
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "depositDual",
|
||||
args: [BigInt(campaign.meta.campaignId), stockAmount, amount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else {
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "deposit",
|
||||
args: [BigInt(campaign.meta.campaignId), amount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const label = !isConnected
|
||||
? "Connect wallet"
|
||||
: !ready
|
||||
? dual
|
||||
? `Enter ${symbol} amount`
|
||||
: "Enter amount"
|
||||
: needsUsdFaucet
|
||||
? "Get test hUSD"
|
||||
: needsStockFaucet
|
||||
? `Get test ${symbol}`
|
||||
: needsUsdApprove
|
||||
? "Approve hUSD"
|
||||
: needsStockApprove
|
||||
? `Approve ${symbol}`
|
||||
: campaign.status === "boost"
|
||||
? "Deposit with 1.5x boost"
|
||||
: dual
|
||||
? "Deposit dual"
|
||||
: "Deposit";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* mode tabs */}
|
||||
<div className="grid grid-cols-2 gap-1 rounded-none border border-edge bg-surface p-1">
|
||||
{(["single", "dual"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-none px-3 py-1.5 font-mono text-[11px] uppercase tracking-wider transition-colors",
|
||||
mode === m ? "bg-mint text-void" : "text-fog hover:text-snow",
|
||||
)}
|
||||
>
|
||||
{m === "single" ? "Single-sided" : "Dual-sided"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dual && (
|
||||
<div className="ticks rounded-none border border-edge bg-surface p-4">
|
||||
<div className="flex items-center justify-between text-[11px] uppercase tracking-wider text-fog-dim">
|
||||
<span>Stock leg</span>
|
||||
<button
|
||||
type="button"
|
||||
className="num cursor-pointer text-fog transition-colors hover:text-mint"
|
||||
onClick={() => setStockInput((Number(stockBalance) / 1e18).toString())}
|
||||
>
|
||||
Balance {(Number(stockBalance) / 1e18).toFixed(4)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value.replace(/[^0-9.]/g, ""))}
|
||||
placeholder="0.0"
|
||||
inputMode="decimal"
|
||||
className="num w-full bg-transparent text-2xl font-semibold text-snow outline-none placeholder:text-fog-dim"
|
||||
/>
|
||||
<span className="font-mono text-sm font-semibold text-fog">{symbol}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 num text-[11px] text-fog-dim">
|
||||
{stockAmount > 0n
|
||||
? `Feed value ${fmtUsd(stockValueUsd, false)}`
|
||||
: `Feed price ${fmtUsd(stockValueUsd, false)} per ${symbol}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ticks rounded-none border border-edge bg-surface p-4">
|
||||
<div className="flex items-center justify-between text-[11px] uppercase tracking-wider text-fog-dim">
|
||||
<span>{dual ? "Stable leg" : "You deposit"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="num cursor-pointer text-fog transition-colors hover:text-mint"
|
||||
onClick={() => setInput((Number(balance) / 10 ** USD_DECIMALS).toString())}
|
||||
>
|
||||
Balance {fmtUsd(Number(balance) / 10 ** USD_DECIMALS)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value.replace(/[^0-9.]/g, ""))}
|
||||
placeholder="0.00"
|
||||
inputMode="decimal"
|
||||
className="num w-full bg-transparent text-2xl font-semibold text-snow outline-none placeholder:text-fog-dim"
|
||||
/>
|
||||
<span className="font-mono text-sm font-semibold text-fog">hUSD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dual && stockAmount > 0n && (
|
||||
<div className="num text-[11px] text-fog">
|
||||
Position credit {fmtUsd(depositUsd, false)}
|
||||
<span className="text-fog-dim">
|
||||
{" "}
|
||||
· for a balanced 50/50 add {fmtUsd(stockValueUsd, false)} hUSD
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{impact && depositUsd > 0 && (
|
||||
<div className="rounded-none border border-mint/20 bg-mint-soft p-3.5 text-xs leading-relaxed text-snow">
|
||||
Your deposit may reduce estimated slippage for a{" "}
|
||||
{fmtUsd(PREVIEW_TRADE)} trade from{" "}
|
||||
<span className="num font-semibold text-danger">{fmtPct(impact.before)}</span> to{" "}
|
||||
<span className="num font-semibold text-mint">{fmtPct(impact.after)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={busy || (isConnected && !ready) || campaign.status === "ended"}
|
||||
onClick={act}
|
||||
>
|
||||
{busy ? "Confirming..." : label}
|
||||
</Button>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-fog-dim">
|
||||
Potential rewards only. Trading fees depend on volume, rewards are
|
||||
subject to campaign terms and the LP position may lose value. Exiting
|
||||
before the campaign ends forfeits campaign rewards and applies a 0.1%
|
||||
exit fee. Reward payouts carry a 10% platform fee.
|
||||
{dual && " The stock leg is valued by the campaign price feed at deposit time."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
apps/web/src/components/DepthChart.tsx
Normal file
260
apps/web/src/components/DepthChart.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useMemo, useRef, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { fmtUsd } from "@/lib/format";
|
||||
import { motionEnabled } from "@/lib/motion";
|
||||
|
||||
const W = 960;
|
||||
const H = 380;
|
||||
const PAD_X = 8;
|
||||
const BASE_Y = H - 34;
|
||||
const TOP_Y = 26;
|
||||
const STEPS = 42;
|
||||
|
||||
/** Deterministic per-index jitter so the book looks organic but stable. */
|
||||
function jitter(i: number, salt: number) {
|
||||
const x = Math.sin(i * 127.1 + salt * 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function sideSteps(totalUsd: number, salt: number) {
|
||||
const pts: { frac: number; cum: number }[] = [];
|
||||
let cum = 0;
|
||||
const weights: number[] = [];
|
||||
let wSum = 0;
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
const w = 0.35 + jitter(i, salt) * (i / STEPS) * 2.2;
|
||||
weights.push(w);
|
||||
wSum += w;
|
||||
}
|
||||
for (let i = 0; i < STEPS; i++) {
|
||||
cum += (weights[i] / wSum) * totalUsd;
|
||||
pts.push({ frac: (i + 1) / STEPS, cum });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cumulative order-book depth, mirrored around mid. X axis is price offset
|
||||
* from mid, Y is cumulative depth in USD. Crosshair follows the cursor with a
|
||||
* mono readout. Pure SVG, driven by the real tracked-depth figure.
|
||||
*/
|
||||
export function DepthChart({ totalDepthUsd }: { totalDepthUsd: number }) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
|
||||
const { bidPath, askPath, bidLine, askLine, maxCum, bids, asks } = useMemo(() => {
|
||||
const total = Math.max(totalDepthUsd, 1);
|
||||
const bids = sideSteps(total * 0.52, 1.37);
|
||||
const asks = sideSteps(total * 0.48, 7.91);
|
||||
const maxCum = Math.max(bids[STEPS - 1].cum, asks[STEPS - 1].cum);
|
||||
const midX = W / 2;
|
||||
const halfW = midX - PAD_X;
|
||||
const yFor = (cum: number) =>
|
||||
BASE_Y - (cum / maxCum) * (BASE_Y - TOP_Y);
|
||||
|
||||
const walk = (pts: typeof bids, dir: 1 | -1) => {
|
||||
let d = `M ${midX} ${BASE_Y}`;
|
||||
let prevY = BASE_Y;
|
||||
for (const p of pts) {
|
||||
const x = midX + dir * p.frac * halfW;
|
||||
const y = yFor(p.cum);
|
||||
d += ` L ${x} ${prevY} L ${x} ${y}`;
|
||||
prevY = y;
|
||||
}
|
||||
return { area: `${d} L ${midX + dir * halfW} ${BASE_Y} Z`, line: d };
|
||||
};
|
||||
|
||||
const b = walk(bids, -1);
|
||||
const a = walk(asks, 1);
|
||||
return {
|
||||
bidPath: b.area,
|
||||
askPath: a.area,
|
||||
bidLine: b.line,
|
||||
askLine: a.line,
|
||||
maxCum,
|
||||
bids,
|
||||
asks,
|
||||
};
|
||||
}, [totalDepthUsd]);
|
||||
|
||||
const readout = useMemo(() => {
|
||||
if (cursor == null) return null;
|
||||
const midX = W / 2;
|
||||
const halfW = midX - PAD_X;
|
||||
const off = (cursor - midX) / halfW; // -1..1
|
||||
const side = off < 0 ? bids : asks;
|
||||
const frac = Math.min(Math.abs(off), 1);
|
||||
const idx = Math.min(Math.floor(frac * STEPS), STEPS - 1);
|
||||
const cum = frac < 1 / STEPS ? side[0].cum * (frac * STEPS) : side[idx].cum;
|
||||
const y = BASE_Y - (cum / maxCum) * (BASE_Y - TOP_Y);
|
||||
return {
|
||||
x: cursor,
|
||||
y,
|
||||
pct: `${off >= 0 ? "+" : "−"}${(Math.abs(off) * 1.2).toFixed(2)}%`,
|
||||
cum: fmtUsd(cum),
|
||||
side: off < 0 ? "bid" : "ask",
|
||||
};
|
||||
}, [cursor, bids, asks, maxCum]);
|
||||
|
||||
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W;
|
||||
setCursor(Math.max(PAD_X, Math.min(W - PAD_X, x)));
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-full w-full cursor-crosshair select-none"
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setCursor(null)}
|
||||
role="img"
|
||||
aria-label="Cumulative order book depth"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="dc-bid" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0.32" />
|
||||
<stop offset="100%" stopColor="#c8ff00" stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
<linearGradient id="dc-ask" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5200ff" stopOpacity="0.38" />
|
||||
<stop offset="100%" stopColor="#5200ff" stopOpacity="0.03" />
|
||||
</linearGradient>
|
||||
{/* oscilloscope tube glow for the book lines */}
|
||||
<filter id="dc-glow-bid" x="-20%" y="-60%" width="140%" height="220%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3.2" floodColor="#c8ff00" floodOpacity="0.7" />
|
||||
</filter>
|
||||
<filter id="dc-glow-ask" x="-20%" y="-60%" width="140%" height="220%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3.2" floodColor="#5200ff" floodOpacity="0.8" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* side labels: acid means bids hold value, violet is the counter side */}
|
||||
<text
|
||||
x={PAD_X + 6}
|
||||
y={TOP_Y + 4}
|
||||
fill="#c8ff00"
|
||||
fontSize="10"
|
||||
fontFamily="Fragment Mono, monospace"
|
||||
letterSpacing="0.16em"
|
||||
>
|
||||
BIDS
|
||||
</text>
|
||||
<text
|
||||
x={W - PAD_X - 6}
|
||||
y={TOP_Y + 4}
|
||||
textAnchor="end"
|
||||
fill="#8a5cff"
|
||||
fontSize="10"
|
||||
fontFamily="Fragment Mono, monospace"
|
||||
letterSpacing="0.16em"
|
||||
>
|
||||
ASKS
|
||||
</text>
|
||||
|
||||
{/* frame ticks */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||
const x = PAD_X + t * (W - PAD_X * 2);
|
||||
return (
|
||||
<g key={t}>
|
||||
<line x1={x} y1={TOP_Y - 8} x2={x} y2={BASE_Y} stroke="#2a2a2a" strokeWidth="1" />
|
||||
<text
|
||||
x={x}
|
||||
y={H - 12}
|
||||
textAnchor="middle"
|
||||
fill="#5c5c5c"
|
||||
fontSize="10"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
letterSpacing="0.08em"
|
||||
>
|
||||
{t === 0.5 ? "MID" : `${t < 0.5 ? "−" : "+"}${(Math.abs(t - 0.5) * 2.4).toFixed(1)}%`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={PAD_X} y1={BASE_Y} x2={W - PAD_X} y2={BASE_Y} stroke="#3d3d3d" strokeWidth="1" />
|
||||
|
||||
{/* depth areas */}
|
||||
<motion.g
|
||||
initial={motionEnabled ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.1, delay: 0.5 }}
|
||||
>
|
||||
<path d={bidPath} fill="url(#dc-bid)" />
|
||||
<path d={askPath} fill="url(#dc-ask)" />
|
||||
<path
|
||||
d={bidLine}
|
||||
fill="none"
|
||||
stroke="#c8ff00"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.95"
|
||||
filter="url(#dc-glow-bid)"
|
||||
/>
|
||||
<path
|
||||
d={askLine}
|
||||
fill="none"
|
||||
stroke="#8a5cff"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.9"
|
||||
filter="url(#dc-glow-ask)"
|
||||
/>
|
||||
</motion.g>
|
||||
|
||||
{/* mid marker */}
|
||||
<line
|
||||
x1={W / 2}
|
||||
y1={TOP_Y - 8}
|
||||
x2={W / 2}
|
||||
y2={BASE_Y}
|
||||
stroke="#c8ff00"
|
||||
strokeOpacity="0.35"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2 6"
|
||||
/>
|
||||
|
||||
{/* crosshair: takes the color of the side it reads */}
|
||||
{readout && (
|
||||
<g>
|
||||
<line
|
||||
x1={readout.x}
|
||||
y1={TOP_Y - 8}
|
||||
x2={readout.x}
|
||||
y2={BASE_Y}
|
||||
stroke={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}
|
||||
strokeOpacity="0.6"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<circle
|
||||
cx={readout.x}
|
||||
cy={readout.y}
|
||||
r="3.5"
|
||||
fill={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}
|
||||
/>
|
||||
<g
|
||||
transform={`translate(${
|
||||
readout.x > W - 190 ? readout.x - 178 : readout.x + 12
|
||||
}, ${Math.max(readout.y - 40, TOP_Y)})`}
|
||||
>
|
||||
<rect width="166" height="34" fill="#161616" stroke="#3d3d3d" strokeWidth="1" />
|
||||
<text
|
||||
x="10"
|
||||
y="21"
|
||||
fill="#f2f2f2"
|
||||
fontSize="11"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{readout.pct}
|
||||
<tspan fill="#5c5c5c"> · </tspan>
|
||||
<tspan fill={readout.side === "bid" ? "#c8ff00" : "#8a5cff"}>
|
||||
{readout.cum}
|
||||
</tspan>
|
||||
<tspan fill="#5c5c5c"> {readout.side}</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/DepthHistory.tsx
Normal file
73
apps/web/src/components/DepthHistory.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* 30-day depth sparkline. Historical points are estimated: a deterministic,
|
||||
* per-market random walk that converges to the current live depth figure, so
|
||||
* the line is stable across renders and always ends at truth.
|
||||
*/
|
||||
const W = 220;
|
||||
const H = 48;
|
||||
const DAYS = 30;
|
||||
|
||||
function seeded(i: number, salt: number) {
|
||||
const x = Math.sin(i * 91.7 + salt * 47.3) * 24634.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
export function DepthHistory({
|
||||
currentUsd,
|
||||
seedKey,
|
||||
}: {
|
||||
currentUsd: number;
|
||||
seedKey: string;
|
||||
}) {
|
||||
if (currentUsd <= 0) return null;
|
||||
|
||||
const salt = [...seedKey].reduce((s, ch) => s + ch.charCodeAt(0), 0);
|
||||
// walk backwards from today: depth was generally lower in the past
|
||||
const values: number[] = [currentUsd];
|
||||
for (let i = 1; i < DAYS; i++) {
|
||||
const drift = 1 - 0.012 * i; // gentle growth trend toward today
|
||||
const noise = 0.9 + seeded(i, salt) * 0.2;
|
||||
values.push(currentUsd * Math.max(drift * noise, 0.35));
|
||||
}
|
||||
values.reverse();
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const span = max - min || 1;
|
||||
const pts = values
|
||||
.map((v, i) => {
|
||||
const x = (i / (DAYS - 1)) * W;
|
||||
const y = H - 6 - ((v - min) / span) * (H - 12);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-12 w-full"
|
||||
aria-hidden
|
||||
style={{ filter: "drop-shadow(0 0 4px rgba(200, 255, 0, 0.45))" }}
|
||||
>
|
||||
<polyline
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke="#c8ff00"
|
||||
strokeWidth="1.5"
|
||||
strokeOpacity="0.85"
|
||||
/>
|
||||
<polygon
|
||||
points={`0,${H} ${pts} ${W},${H}`}
|
||||
fill="#c8ff00"
|
||||
opacity="0.045"
|
||||
/>
|
||||
<line x1="0" y1={H - 0.5} x2={W} y2={H - 0.5} stroke="#2a2a2a" strokeWidth="1" />
|
||||
<circle
|
||||
cx={W}
|
||||
cy={H - 6 - ((values[DAYS - 1] - min) / span) * (H - 12)}
|
||||
r="2.5"
|
||||
fill="#c8ff00"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/DepthProgress.tsx
Normal file
50
apps/web/src/components/DepthProgress.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Depth bar: current pool depth vs campaign target, rendered as stacked
|
||||
* order-book style ladder segments.
|
||||
*/
|
||||
export function DepthProgress({
|
||||
progress,
|
||||
segments = 24,
|
||||
}: {
|
||||
progress: number; // 0..1
|
||||
segments?: number;
|
||||
}) {
|
||||
const filled = Math.round(Math.min(Math.max(progress, 0), 1) * segments);
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${segments * 8} 10`}
|
||||
className="h-2.5 w-full"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden
|
||||
style={
|
||||
filled > 0
|
||||
? { filter: "drop-shadow(0 0 3px rgba(200, 255, 0, 0.4))" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Array.from({ length: segments }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={i * 8}
|
||||
y={0}
|
||||
width={6}
|
||||
height={10}
|
||||
|
||||
fill={i < filled ? "#c8ff00" : "#2a2a2a"}
|
||||
opacity={i < filled ? 0.35 + (0.65 * i) / segments : 1}
|
||||
>
|
||||
{i < filled && (
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
from="0"
|
||||
to={0.35 + (0.65 * i) / segments}
|
||||
dur="0.4s"
|
||||
begin={`${i * 0.02}s`}
|
||||
fill="freeze"
|
||||
/>
|
||||
)}
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/Footnote.tsx
Normal file
30
apps/web/src/components/Footnote.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Live footnote apparatus: an acid superscript on a human statement that
|
||||
* resolves to a mono line bound to onchain data. The marker never renders
|
||||
* when the figure is not live, so an annotation can never go stale.
|
||||
*/
|
||||
export function FootnoteMark({ n, live }: { n: number; live: boolean }) {
|
||||
if (!live) return null;
|
||||
return <sup className="footnote-sup">{n}</sup>;
|
||||
}
|
||||
|
||||
export function FootnoteLine({
|
||||
n,
|
||||
live,
|
||||
children,
|
||||
}: {
|
||||
n: number;
|
||||
live: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!live) return null;
|
||||
return (
|
||||
<div className="num flex items-baseline gap-2 text-[11px] text-fog">
|
||||
<span className="text-mint">{n}</span>
|
||||
<span className="text-fog-dim">//</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/GlyphMarquee.tsx
Normal file
58
apps/web/src/components/GlyphMarquee.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Fragment } from "react";
|
||||
|
||||
const ITEMS = [
|
||||
"Add liquidity",
|
||||
"◎",
|
||||
"Earn fees",
|
||||
"✕",
|
||||
"Make markets deeper",
|
||||
"⊡",
|
||||
"Depth vaults",
|
||||
"✳",
|
||||
"Stock tokens",
|
||||
"+",
|
||||
];
|
||||
|
||||
/** Brutalist running line: solid and outlined Tanker words with glyph separators. */
|
||||
export function GlyphMarquee() {
|
||||
const strip = (
|
||||
<>
|
||||
{ITEMS.map((item, i) => {
|
||||
const glyph = item.length <= 2;
|
||||
const outlined = !glyph && i % 4 === 2;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<span
|
||||
className={
|
||||
glyph
|
||||
? "mx-6 text-lg text-mint/40"
|
||||
: "font-display mx-6 text-2xl uppercase md:text-3xl"
|
||||
}
|
||||
style={
|
||||
outlined
|
||||
? { WebkitTextStroke: "1px rgba(233,255,179,0.5)", color: "transparent" }
|
||||
: glyph
|
||||
? undefined
|
||||
: { color: "rgba(233,255,179,0.85)" }
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="overflow-hidden border-y border-edge bg-void py-4"
|
||||
>
|
||||
<div className="flex w-max animate-marquee items-center">
|
||||
<div className="flex shrink-0 items-center">{strip}{strip}</div>
|
||||
<div className="flex shrink-0 items-center">{strip}{strip}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/components/HealthRadial.tsx
Normal file
69
apps/web/src/components/HealthRadial.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { healthLabel } from "@/lib/math";
|
||||
|
||||
/** SVG radial gauge for the Market Health Score. */
|
||||
export function HealthRadial({ score }: { score: number }) {
|
||||
const { label, tone } = healthLabel(score);
|
||||
const color = tone === "mint" ? "#c8ff00" : tone === "violet" ? "#8a5cff" : "#ff3b00";
|
||||
const r = 34;
|
||||
const c = 2 * Math.PI * r;
|
||||
const filled = (score / 100) * c * 0.75; // 270 degree arc
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg viewBox="0 0 80 80" className="h-16 w-16" aria-hidden>
|
||||
<g transform="rotate(135 40 40)">
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#2a2a2a"
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${c * 0.75} ${c}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${filled} ${c}`}
|
||||
strokeLinecap="butt"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${color})` }}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
from={`0 ${c}`}
|
||||
to={`${filled} ${c}`}
|
||||
dur="0.9s"
|
||||
fill="freeze"
|
||||
calcMode="spline"
|
||||
keySplines="0.25 0.1 0.25 1"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
<text
|
||||
x="40"
|
||||
y="44"
|
||||
textAnchor="middle"
|
||||
fill="#f2f2f2"
|
||||
fontSize="20"
|
||||
fontWeight="600"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{score}
|
||||
</text>
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">
|
||||
Health score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/PositionPanel.tsx
Normal file
163
apps/web/src/components/PositionPanel.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { parseUnits } from "viem";
|
||||
import { useWaitForTransactionReceipt, useWriteContract } from "wagmi";
|
||||
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
|
||||
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
|
||||
import { fmtUsd, fmtCountdown, fmtNum } from "@/lib/format";
|
||||
import { campaignPoints, isGenesisLp } from "@/lib/points";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CountUp } from "@/components/CountUp";
|
||||
import type { CampaignState } from "@/hooks/useCampaigns";
|
||||
import type { usePosition } from "@/hooks/useCampaigns";
|
||||
|
||||
type Position = NonNullable<ReturnType<typeof usePosition>["position"]>;
|
||||
|
||||
export function PositionPanel({
|
||||
campaign,
|
||||
position,
|
||||
onSettled,
|
||||
}: {
|
||||
campaign: CampaignState;
|
||||
position: Position;
|
||||
onSettled: () => void;
|
||||
}) {
|
||||
const deployment = getActiveDeployment();
|
||||
const { writeContract, data: txHash, isPending, reset } = useWriteContract();
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
||||
hash: txHash,
|
||||
});
|
||||
|
||||
if (isSuccess) {
|
||||
reset();
|
||||
onSettled();
|
||||
}
|
||||
|
||||
const busy = isPending || isConfirming;
|
||||
const ended = campaign.status === "ended";
|
||||
const id = BigInt(campaign.meta.campaignId);
|
||||
|
||||
const exit = () =>
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "withdraw",
|
||||
args: [id, parseUnits(position.amountUsd.toString(), USD_DECIMALS)],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
|
||||
const claim = () =>
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "claim",
|
||||
args: [id],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="slabel">LP receipt</div>
|
||||
<div className="flex gap-2">
|
||||
{isGenesisLp(position.lpIndex) && <Badge tone="mint">Genesis LP</Badge>}
|
||||
{position.boosted && <Badge tone="lime">1.5x boosted</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<div className="leader">
|
||||
<span className="text-fog">Deposited</span>
|
||||
<span className="num font-semibold">{fmtUsd(position.amountUsd, false)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Vault share</span>
|
||||
<span className="num font-semibold">
|
||||
{campaign.tvlUsd > 0
|
||||
? `${((position.amountUsd / campaign.tvlUsd) * 100).toFixed(1)}%`
|
||||
: "0%"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Rewards accrued</span>
|
||||
<span className="num font-semibold text-mint">
|
||||
{fmtUsd(position.pendingUsd, false)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Rewards claimed</span>
|
||||
<span className="num font-semibold">{fmtUsd(position.claimedUsd, false)}</span>
|
||||
</div>
|
||||
{position.stockAmount > 0 && (
|
||||
<div className="leader">
|
||||
<span className="text-fog">Stock leg</span>
|
||||
<span className="num font-semibold">
|
||||
{position.stockAmount.toFixed(4)} {campaign.meta.symbol}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="leader">
|
||||
<span className="text-fog">Points</span>
|
||||
<span className="num font-semibold text-lime">
|
||||
<CountUp
|
||||
value={campaignPoints({
|
||||
amountUsd: position.amountUsd,
|
||||
depositedAt: position.depositedAt,
|
||||
boosted: position.boosted,
|
||||
})}
|
||||
format={(n) => fmtNum(Math.floor(n))}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Full rewards unlock</span>
|
||||
<span className="num font-semibold">
|
||||
{ended ? "Unlocked" : fmtCountdown(campaign.end)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
{ended ? (
|
||||
<>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={busy || position.pendingUsd === 0}
|
||||
onClick={claim}
|
||||
>
|
||||
{busy ? "Confirming..." : "Claim rewards"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
disabled={busy || position.amountUsd === 0}
|
||||
onClick={exit}
|
||||
>
|
||||
Withdraw all
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="danger"
|
||||
disabled={busy || position.amountUsd === 0}
|
||||
onClick={exit}
|
||||
>
|
||||
{busy ? "Confirming..." : "Exit early"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!ended ? (
|
||||
<p className="text-[11px] leading-relaxed text-fog-dim">
|
||||
Early exit returns your principal minus a 0.1% fee. Accrued campaign
|
||||
rewards are forfeited and redistributed to remaining LPs.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] leading-relaxed text-fog-dim">
|
||||
Reward payouts carry a 10% platform fee. Points have no monetary
|
||||
value and feed future programs.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
apps/web/src/components/Preloader.tsx
Normal file
61
apps/web/src/components/Preloader.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
const BARS = [
|
||||
{ w: 44, delay: 0 },
|
||||
{ w: 30, delay: 0.08 },
|
||||
{ w: 38, delay: 0.16 },
|
||||
{ w: 24, delay: 0.24 },
|
||||
{ w: 50, delay: 0.32 },
|
||||
];
|
||||
|
||||
/** Fullscreen depth-bar preloader, shown once per session. */
|
||||
export function Preloader() {
|
||||
const [done, setDone] = useState(() => sessionStorage.getItem("hq-loaded") === "1");
|
||||
|
||||
useEffect(() => {
|
||||
if (done) return;
|
||||
const t = setTimeout(() => {
|
||||
sessionStorage.setItem("hq-loaded", "1");
|
||||
setDone(true);
|
||||
}, 1600);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!done && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-6 bg-void"
|
||||
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
|
||||
>
|
||||
<svg viewBox="0 0 64 48" className="h-16 w-20" aria-hidden>
|
||||
{BARS.map((b, i) => (
|
||||
<rect key={i} x="4" y={4 + i * 9} height="5" fill="#c8ff00" width={b.w}>
|
||||
<animate
|
||||
attributeName="width"
|
||||
values={`6;${b.w};6`}
|
||||
dur="1.4s"
|
||||
begin={`${b.delay}s`}
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.35;1;0.35"
|
||||
dur="1.4s"
|
||||
begin={`${b.delay}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
<div className="num text-[11px] tracking-[0.3em] text-fog uppercase">
|
||||
Deepening markets
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/SlippageCurve.tsx
Normal file
32
apps/web/src/components/SlippageCurve.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { slippageFor } from "@/lib/math";
|
||||
import { fmtUsd } from "@/lib/format";
|
||||
|
||||
const SIZES = [1_000, 5_000, 10_000, 25_000, 50_000, 100_000];
|
||||
|
||||
/** Horizontal bars: estimated slippage per trade size at current depth. */
|
||||
export function SlippageCurve({ liquidityUsd }: { liquidityUsd: number }) {
|
||||
const max = slippageFor(SIZES[SIZES.length - 1], liquidityUsd);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{SIZES.map((size) => {
|
||||
const s = slippageFor(size, liquidityUsd);
|
||||
const width = max > 0 ? (s / max) * 100 : 0;
|
||||
const hot = s > 0.02;
|
||||
return (
|
||||
<div key={size} className="flex items-center gap-3 text-xs">
|
||||
<span className="num w-14 shrink-0 text-right text-fog">{fmtUsd(size)}</span>
|
||||
<svg className="h-3 flex-1" preserveAspectRatio="none" viewBox="0 0 100 10" aria-hidden>
|
||||
<rect x="0" y="3" width="100" height="4" fill="#2a2a2a" />
|
||||
<rect x="0" y="3" width={Math.max(width, 1)} height="4" fill={hot ? "#ff3b00" : "#c8ff00"}>
|
||||
<animate attributeName="width" from="0" to={Math.max(width, 1)} dur="0.7s" fill="freeze" />
|
||||
</rect>
|
||||
</svg>
|
||||
<span className={`num w-14 shrink-0 ${hot ? "text-danger" : "text-mint"}`}>
|
||||
{(s * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/Term.tsx
Normal file
37
apps/web/src/components/Term.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Defined-term gloss: a dotted-underline mechanism word that opens a
|
||||
* plain-language definition card with exactly one risk sentence.
|
||||
* Compliance as apparatus, not smallprint.
|
||||
*/
|
||||
export function Term({
|
||||
def,
|
||||
risk,
|
||||
children,
|
||||
}: {
|
||||
def: string;
|
||||
risk?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="group relative inline-block">
|
||||
<span tabIndex={0} className="term outline-none focus-visible:text-mint">
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none invisible absolute bottom-full left-1/2 z-40 mb-2 w-[280px] -translate-x-1/2 border border-edge-bright bg-card p-3.5 text-left opacity-0 transition-opacity duration-150 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100"
|
||||
>
|
||||
<span className="block text-[12px] normal-case leading-relaxed tracking-normal text-snow">
|
||||
{def}
|
||||
</span>
|
||||
{risk && (
|
||||
<span className="mt-2 block font-mono text-[10px] uppercase leading-relaxed tracking-[0.04em] text-fog-dim">
|
||||
{risk}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/components/TickerTape.tsx
Normal file
34
apps/web/src/components/TickerTape.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useAllMarketData } from "@/hooks/useMarketData";
|
||||
import { getActiveDeployment } from "@/lib/contracts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Static strip of live tokenized stock prices. */
|
||||
export function TickerTape() {
|
||||
const { markets } = getActiveDeployment();
|
||||
const { data } = useAllMarketData(markets);
|
||||
|
||||
const items = markets
|
||||
.map((m) => ({ meta: m, pair: data?.[m.symbol] }))
|
||||
.filter((x) => x.pair);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto border-b border-edge bg-surface py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="mx-auto flex w-max items-center gap-2 px-4 md:justify-center">
|
||||
{items.map(({ meta, pair }) => (
|
||||
<span
|
||||
key={meta.symbol}
|
||||
className="num inline-flex items-center gap-2 px-4 text-xs"
|
||||
>
|
||||
<span className="font-semibold text-snow">{meta.symbol}</span>
|
||||
<span className="text-fog">${pair!.priceUsd.toFixed(2)}</span>
|
||||
<span className={cn(pair!.change24h >= 0 ? "text-mint" : "text-danger")}>
|
||||
{pair!.change24h >= 0 ? "▲" : "▼"} {Math.abs(pair!.change24h).toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/backdrop/DepthField.tsx
Normal file
65
apps/web/src/components/backdrop/DepthField.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Ambient background: layered market-depth contours drifting slowly.
|
||||
* Pure SVG, animated with SMIL so it ships as vector all the way.
|
||||
*/
|
||||
export function DepthField({ flip = false }: { flip?: boolean }) {
|
||||
const waves = [
|
||||
{ d: "M0 320 C 240 260, 420 380, 720 330 S 1200 260, 1440 320", o: 0.5, dur: "26s" },
|
||||
{ d: "M0 360 C 260 300, 480 420, 760 370 S 1180 300, 1440 360", o: 0.35, dur: "32s" },
|
||||
{ d: "M0 400 C 280 340, 520 460, 800 410 S 1160 340, 1440 400", o: 0.22, dur: "38s" },
|
||||
{ d: "M0 440 C 300 380, 560 500, 840 450 S 1140 380, 1440 440", o: 0.12, dur: "44s" },
|
||||
];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 1440 560"
|
||||
preserveAspectRatio="xMidYMax slice"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={flip ? { transform: "scaleY(-1)" } : undefined}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="df-fade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="#c8ff00" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
<mask id="df-mask">
|
||||
<rect width="1440" height="560" fill="url(#df-fade)" />
|
||||
</mask>
|
||||
</defs>
|
||||
<g mask="url(#df-mask)">
|
||||
{waves.map((w, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={w.d}
|
||||
fill="none"
|
||||
stroke="#c8ff00"
|
||||
strokeOpacity={w.o}
|
||||
strokeWidth={i === 0 ? 1.5 : 1}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="0 0; -36 14; 0 0"
|
||||
dur={w.dur}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
))}
|
||||
{/* depth grid ticks */}
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<line
|
||||
key={`t${i}`}
|
||||
x1={i * 62 + 20}
|
||||
y1={520}
|
||||
x2={i * 62 + 20}
|
||||
y2={528}
|
||||
stroke="#c8ff00"
|
||||
strokeOpacity="0.25"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/backdrop/DepthLadder.tsx
Normal file
71
apps/web/src/components/backdrop/DepthLadder.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Hero visual: an order-book depth ladder filling up, pure SVG animation.
|
||||
* Bids in mint grow from the right, asks in white grow from the left,
|
||||
* meeting around the spread line.
|
||||
*/
|
||||
const BIDS = [86, 64, 74, 52, 60, 42, 30];
|
||||
const ASKS = [80, 58, 68, 48, 54, 38, 26];
|
||||
|
||||
export function DepthLadder() {
|
||||
return (
|
||||
<svg viewBox="0 0 420 380" className="h-full w-full" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id="dl-bid" x1="1" y1="0" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#c8ff00" stopOpacity="0.9" />
|
||||
<stop offset="100%" stopColor="#c8ff00" stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
<linearGradient id="dl-ask" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#f2f2f2" stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor="#f2f2f2" stopOpacity="0.08" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* spread line */}
|
||||
<line x1="210" y1="10" x2="210" y2="370" stroke="#3d3d3d" strokeWidth="1" strokeDasharray="3 6" />
|
||||
|
||||
{BIDS.map((w, i) => {
|
||||
const y = 24 + i * 50;
|
||||
const bid = w * 2;
|
||||
const ask = ASKS[i] * 2;
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={210 - bid} y={y} width={bid} height="22" fill="url(#dl-bid)">
|
||||
<animate
|
||||
attributeName="width"
|
||||
values={`${bid * 0.55};${bid};${bid * 0.75};${bid}`}
|
||||
dur={`${5 + i * 0.9}s`}
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x"
|
||||
values={`${210 - bid * 0.55};${210 - bid};${210 - bid * 0.75};${210 - bid}`}
|
||||
dur={`${5 + i * 0.9}s`}
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
|
||||
/>
|
||||
</rect>
|
||||
<rect x="212" y={y + 26} width={ask} height="22" fill="url(#dl-ask)">
|
||||
<animate
|
||||
attributeName="width"
|
||||
values={`${ask};${ask * 0.6};${ask * 0.85};${ask}`}
|
||||
dur={`${6 + i * 0.7}s`}
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1;0.4 0 0.2 1;0.4 0 0.2 1"
|
||||
/>
|
||||
</rect>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* price tick riding the spread */}
|
||||
<circle cx="210" cy="190" r="4" fill="#e9ffb3">
|
||||
<animate attributeName="cy" values="60;320;140;260;60" dur="14s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;0.5;1;0.6;1" dur="14s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/components/backdrop/GlowField.tsx
Normal file
42
apps/web/src/components/backdrop/GlowField.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Soft acid glow blobs with thin technical hairlines over them,
|
||||
* lifted from the reference posters.
|
||||
*/
|
||||
export function GlowField() {
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-40 right-[-10%] h-[480px] w-[480px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(200,255,0,0.22) 0%, rgba(200,255,0,0.07) 45%, transparent 70%)",
|
||||
filter: "blur(40px)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-[-30%] left-[-8%] h-[420px] w-[420px] rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(200,255,0,0.14) 0%, rgba(200,255,0,0.05) 50%, transparent 72%)",
|
||||
filter: "blur(48px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* technical hairlines with tick ends */}
|
||||
<svg className="absolute inset-0 h-full w-full" aria-hidden>
|
||||
<g stroke="#f2f2f2" strokeOpacity="0.18" strokeWidth="1">
|
||||
<line x1="12%" y1="0" x2="12%" y2="62%" />
|
||||
<line x1="4%" y1="34%" x2="58%" y2="34%" />
|
||||
<line x1="46%" y1="18%" x2="46%" y2="100%" />
|
||||
<line x1="30%" y1="78%" x2="96%" y2="78%" />
|
||||
</g>
|
||||
<g fill="#f2f2f2" fillOpacity="0.35">
|
||||
<rect x="calc(12% - 3px)" y="calc(62% - 1px)" width="6" height="2" />
|
||||
<rect x="calc(4% - 1px)" y="calc(34% - 3px)" width="2" height="6" />
|
||||
<rect x="calc(46% - 3px)" y="calc(18% - 1px)" width="6" height="2" />
|
||||
<rect x="calc(96% - 1px)" y="calc(78% - 3px)" width="2" height="6" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/backdrop/GlyphField.tsx
Normal file
52
apps/web/src/components/backdrop/GlyphField.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Marathon-style glyph grid: patches of terminal symbols scattered over the
|
||||
* section. Built from one SVG pattern tile, so it stays cheap to render.
|
||||
*/
|
||||
export function GlyphField({ opacity = 0.4 }: { opacity?: number }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ opacity }}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="glyphs" width="112" height="84" patternUnits="userSpaceOnUse">
|
||||
<g
|
||||
fill="#c8ff00"
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontSize="11"
|
||||
opacity="0.55"
|
||||
>
|
||||
<text x="4" y="12">◎</text>
|
||||
<text x="32" y="12" opacity="0.5">✕</text>
|
||||
<text x="60" y="12">⊡</text>
|
||||
<text x="88" y="12" opacity="0.35">+</text>
|
||||
<text x="18" y="36" opacity="0.4">·</text>
|
||||
<text x="46" y="36">✕</text>
|
||||
<text x="74" y="36" opacity="0.6">◎</text>
|
||||
<text x="102" y="36" opacity="0.3">╲</text>
|
||||
<text x="4" y="60" opacity="0.5">⊡</text>
|
||||
<text x="32" y="60" opacity="0.3">+</text>
|
||||
<text x="60" y="60">◎</text>
|
||||
<text x="88" y="60" opacity="0.45">✳</text>
|
||||
<text x="18" y="80" opacity="0.35">✕</text>
|
||||
<text x="74" y="80" opacity="0.25">·</text>
|
||||
</g>
|
||||
</pattern>
|
||||
<radialGradient id="glyph-patch-a" cx="0.2" cy="0.25" r="0.5">
|
||||
<stop offset="0%" stopColor="#fff" />
|
||||
<stop offset="100%" stopColor="#000" />
|
||||
</radialGradient>
|
||||
<radialGradient id="glyph-patch-b" cx="0.85" cy="0.75" r="0.45">
|
||||
<stop offset="0%" stopColor="#fff" />
|
||||
<stop offset="100%" stopColor="#000" />
|
||||
</radialGradient>
|
||||
<mask id="glyph-mask">
|
||||
<rect width="100%" height="100%" fill="url(#glyph-patch-a)" />
|
||||
<rect width="100%" height="100%" fill="url(#glyph-patch-b)" style={{ mixBlendMode: "screen" }} />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#glyphs)" mask="url(#glyph-mask)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/brand/Logo.tsx
Normal file
39
apps/web/src/components/brand/Logo.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Mark: an "H" built from order-book depth bars.
|
||||
* Left column = bids, right column = asks, crossbar = the spread being filled.
|
||||
*/
|
||||
export function LogoMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
className={cn("h-8 w-8", className)}
|
||||
aria-hidden
|
||||
>
|
||||
<rect x="3" y="4" width="10" height="3" fill="#c8ff00" />
|
||||
<rect x="3" y="10" width="7" height="3" fill="#c8ff00" opacity="0.75" />
|
||||
<rect x="3" y="16" width="9" height="3" fill="#c8ff00" opacity="0.55" />
|
||||
<rect x="3" y="22" width="6" height="3" fill="#c8ff00" opacity="0.35" />
|
||||
<rect x="19" y="4" width="10" height="3" fill="#f2f2f2" opacity="0.9" />
|
||||
<rect x="22" y="10" width="7" height="3" fill="#f2f2f2" opacity="0.6" />
|
||||
<rect x="20" y="16" width="9" height="3" fill="#f2f2f2" opacity="0.45" />
|
||||
<rect x="23" y="22" width="6" height="3" fill="#f2f2f2" opacity="0.3" />
|
||||
<rect x="10" y="13" width="12" height="3" fill="#e9ffb3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-display text-lg font-bold tracking-tight text-snow",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
Hood<span className="text-mint">quidity</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/fx/Scramble.tsx
Normal file
64
apps/web/src/components/fx/Scramble.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { motionEnabled } from "@/lib/motion";
|
||||
|
||||
const GLYPHS =
|
||||
"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789▚▞▮◎";
|
||||
|
||||
/**
|
||||
* Cyberdeck decode: the label periodically dissolves into katakana and
|
||||
* terminal glyphs, then settles back left to right. Decorative labels only,
|
||||
* never live figures.
|
||||
*/
|
||||
export function Scramble({
|
||||
text,
|
||||
interval = 9000,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
interval?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [display, setDisplay] = useState(text);
|
||||
const raf = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!motionEnabled) {
|
||||
setDisplay(text);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
const decode = () => {
|
||||
const start = performance.now();
|
||||
const dur = 950;
|
||||
const tick = (t: number) => {
|
||||
if (cancelled) return;
|
||||
const p = Math.min((t - start) / dur, 1);
|
||||
const settled = Math.floor(p * text.length);
|
||||
let out = text.slice(0, settled);
|
||||
for (let i = settled; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
out +=
|
||||
ch === " "
|
||||
? " "
|
||||
: GLYPHS[(Math.floor(t / 48) + i * 7) % GLYPHS.length];
|
||||
}
|
||||
setDisplay(out);
|
||||
if (p < 1) raf.current = requestAnimationFrame(tick);
|
||||
else setDisplay(text);
|
||||
};
|
||||
raf.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
decode();
|
||||
const jitter = (text.length * 137) % 3000;
|
||||
const timer = setInterval(decode, interval + jitter);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
cancelAnimationFrame(raf.current);
|
||||
};
|
||||
}, [text, interval]);
|
||||
|
||||
return <span className={className}>{display}</span>;
|
||||
}
|
||||
33
apps/web/src/components/fx/SideRail.tsx
Normal file
33
apps/web/src/components/fx/SideRail.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Vertical neon signage on the hero's right rail: kanji street sign over a
|
||||
* mono locator stack and a barcode glyph run. Pure decoration, Marathon rail
|
||||
* grammar with a Kabukicho accent.
|
||||
*/
|
||||
export function SideRail() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-4 top-36 hidden select-none flex-col items-center gap-6 xl:flex"
|
||||
>
|
||||
<span
|
||||
className="neon text-2xl font-semibold tracking-[0.3em]"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
>
|
||||
流動性デスク
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[9px] uppercase tracking-[0.3em] text-fog-dim"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
>
|
||||
Hoodquidity // Depth Desk // RH-L2 4663
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-[0.85] tracking-[0.02em] text-mint/50">
|
||||
▚▚▞▚
|
||||
<br />
|
||||
▞▚▚▞
|
||||
<br />
|
||||
▚▞▞▚
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/icons/XIcon.tsx
Normal file
7
apps/web/src/components/icons/XIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/components/layout/AppShell.tsx
Normal file
19
apps/web/src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { type ReactNode } from "react";
|
||||
import { Header } from "./Header";
|
||||
import { Footer } from "./Footer";
|
||||
import { Preloader } from "@/components/Preloader";
|
||||
import { TickerTape } from "@/components/TickerTape";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grain flex min-h-screen flex-col">
|
||||
<Preloader />
|
||||
<Header />
|
||||
<TickerTape />
|
||||
<main className="flex-1 pb-16 md:pb-0">{children}</main>
|
||||
<Footer />
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/layout/Footer.tsx
Normal file
81
apps/web/src/components/layout/Footer.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { LogoMark, Wordmark } from "@/components/brand/Logo";
|
||||
import { XIcon } from "@/components/icons/XIcon";
|
||||
import { TWITTER_URL } from "@/lib/constants";
|
||||
import { getActiveDeployment, ACTIVE_CHAIN_ID } from "@/lib/contracts";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-edge bg-surface">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 md:px-8">
|
||||
<div className="flex flex-col gap-10 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-sm space-y-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LogoMark />
|
||||
<Wordmark />
|
||||
<span className="ml-1 font-mono text-[10px] tracking-[0.2em] text-fog-dim/70">
|
||||
流動性
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-fog">
|
||||
Liquidity campaigns for Stock Token markets on Robinhood Chain.
|
||||
Deposit stablecoins, receive an LP receipt and earn from deeper
|
||||
markets.
|
||||
</p>
|
||||
<a
|
||||
href={TWITTER_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Hoodquidity on X"
|
||||
className="inline-flex items-center gap-2 text-sm text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Follow on X <span className="font-mono text-[10px] text-fog-dim">[↗]</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-10 text-sm sm:gap-16">
|
||||
<div className="space-y-3">
|
||||
<div className="slabel">App</div>
|
||||
<ul className="space-y-2.5">
|
||||
<li><Link className="text-fog transition-colors hover:text-snow" to="/campaigns">Campaigns</Link></li>
|
||||
<li><Link className="text-fog transition-colors hover:text-snow" to="/markets">Markets</Link></li>
|
||||
<li><Link className="text-fog transition-colors hover:text-snow" to="/positions">Positions</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="slabel">Protocol</div>
|
||||
<ul className="space-y-2.5">
|
||||
<li><span className="cursor-default text-fog-dim">Docs, soon</span></li>
|
||||
<li><Link className="text-fog transition-colors hover:text-snow" to="/partners">Partner portal</Link></li>
|
||||
<li>
|
||||
<a className="text-fog transition-colors hover:text-snow" href={TWITTER_URL} target="_blank" rel="noreferrer">
|
||||
Twitter / X
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-3 border-t border-edge pt-6 text-xs text-fog-dim md:flex-row md:items-center md:justify-between">
|
||||
<span className="num">HOODQUIDITY / {new Date().getFullYear()}</span>
|
||||
<span className="max-w-xl leading-relaxed">
|
||||
LP positions may lose value. Trading fees depend on volume, rewards
|
||||
are subject to campaign terms. Market availability depends on your
|
||||
jurisdiction.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1.5 text-[10px] text-fog-dim md:flex-row md:items-center md:justify-between">
|
||||
<span className="num break-all">
|
||||
VAULT <span className="text-fog-dim">//</span>{" "}
|
||||
<span className="text-fog">{getActiveDeployment().vaults}</span>{" "}
|
||||
<span className="text-fog-dim">// CHAIN {ACTIVE_CHAIN_ID}</span>
|
||||
</span>
|
||||
<span className="num shrink-0">BUILD E-{new Date().getFullYear()}.07 / DESK 00</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/components/layout/Header.tsx
Normal file
86
apps/web/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { NavLink, Link } from "react-router-dom";
|
||||
import { useAppKit, useAppKitAccount, useAppKitNetwork } from "@reown/appkit/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogoMark, Wordmark } from "@/components/brand/Logo";
|
||||
import { XIcon } from "@/components/icons/XIcon";
|
||||
import { TWITTER_URL } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/app", label: "App" },
|
||||
{ to: "/campaigns", label: "Campaigns" },
|
||||
{ to: "/markets", label: "Markets" },
|
||||
{ to: "/positions", label: "Positions" },
|
||||
];
|
||||
|
||||
function ConnectControl() {
|
||||
const { open } = useAppKit();
|
||||
const { address, isConnected } = useAppKitAccount();
|
||||
const { chainId } = useAppKitNetwork();
|
||||
|
||||
if (!isConnected || !address) {
|
||||
return <Button onClick={() => open()}>Connect Wallet</Button>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => open({ view: "Networks" })}
|
||||
className="num cursor-pointer border border-edge px-2.5 py-2 font-mono text-[10px] uppercase tracking-[0.08em] text-fog transition-colors hover:border-mint hover:text-mint"
|
||||
>
|
||||
{chainId === 46_630
|
||||
? "RH Testnet"
|
||||
: chainId === 31_337
|
||||
? "Anvil"
|
||||
: chainId === 421_614
|
||||
? "Arb Sepolia"
|
||||
: `Chain ${chainId ?? "?"}`}
|
||||
</button>
|
||||
<Button size="md" variant="outline" onClick={() => open()}>
|
||||
{address.slice(0, 6)}...{address.slice(-4)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-edge bg-void/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 md:px-8">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<LogoMark />
|
||||
<Wordmark className="hidden sm:inline" />
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-7 md:flex">
|
||||
{NAV.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-snow",
|
||||
isActive && "text-mint hover:text-mint",
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={TWITTER_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Hoodquidity on X"
|
||||
className="text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
<XIcon className="h-4.5 w-4.5" />
|
||||
</a>
|
||||
<ConnectControl />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/layout/MobileNav.tsx
Normal file
71
apps/web/src/components/layout/MobileNav.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
to: "/",
|
||||
label: "Home",
|
||||
icon: (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
|
||||
<path d="M3 10.5 10 4l7 6.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 9.5V16h10V9.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/campaigns",
|
||||
label: "Campaigns",
|
||||
icon: (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="h-5 w-5">
|
||||
<rect x="3" y="4" width="9" height="2.4" />
|
||||
<rect x="3" y="8.8" width="14" height="2.4" />
|
||||
<rect x="3" y="13.6" width="6" height="2.4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/markets",
|
||||
label: "Markets",
|
||||
icon: (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
|
||||
<path d="M3 16 8 10l3 3 6-8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: "/positions",
|
||||
label: "Positions",
|
||||
icon: (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" className="h-5 w-5">
|
||||
<rect x="3.5" y="5" width="13" height="11" />
|
||||
<path d="M3.5 9h13" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Bottom tab bar, mobile only. */
|
||||
export function MobileNav() {
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 border-t border-edge bg-void/90 backdrop-blur-xl md:hidden">
|
||||
<div className="grid grid-cols-4">
|
||||
{ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex flex-col items-center gap-1 py-2.5 text-[10px] font-medium uppercase tracking-wider transition-colors",
|
||||
isActive ? "text-mint" : "text-fog-dim",
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/ui/badge.tsx
Normal file
41
apps/web/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { type HTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1.5 rounded-none px-2.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
mint: "bg-mint-soft text-mint",
|
||||
fog: "bg-edge text-fog",
|
||||
danger: "bg-danger/10 text-danger",
|
||||
lime: "bg-lime/10 text-lime",
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: "fog" },
|
||||
},
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({ className, tone, pulse, children, ...props }: BadgeProps) {
|
||||
// Live states get a STATIC dot; only danger states may flicker
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
badgeVariants({ tone }),
|
||||
pulse && tone === "danger" && "blink-risk",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{pulse && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/ui/button.tsx
Normal file
41
apps/web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-none font-mono font-semibold uppercase tracking-wider transition-all duration-200 focus-visible:outline-2 focus-visible:outline-mint disabled:pointer-events-none disabled:opacity-40",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"btn-sweep bg-mint text-void shadow-[0_0_20px_rgba(200,255,0,0.25)] hover:bg-lime hover:shadow-glow-strong active:scale-[0.98]",
|
||||
outline:
|
||||
"border border-edge-bright bg-transparent text-snow hover:border-mint hover:text-mint",
|
||||
ghost: "text-fog hover:bg-card hover:text-snow",
|
||||
danger:
|
||||
"border border-danger/40 bg-danger/10 text-danger hover:bg-danger/20",
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-[11px]",
|
||||
md: "h-10 px-5 text-xs",
|
||||
lg: "h-12 px-7 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "md" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
14
apps/web/src/components/ui/card.tsx
Normal file
14
apps/web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { type HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ticks rounded-none border border-edge bg-card p-5 transition-colors duration-300",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/components/ui/skeleton.tsx
Normal file
14
apps/web/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { type HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-none bg-gradient-to-r from-edge via-card-hover to-edge",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/components/ui/stat.tsx
Normal file
24
apps/web/src/components/ui/stat.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
accent?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Stat({ label, value, hint, accent, className }: StatProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-fog-dim">
|
||||
{label}
|
||||
</div>
|
||||
<div className={cn("num text-xl font-semibold", accent ? "text-mint" : "text-snow")}>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="text-xs text-fog">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/ds-entry.ts
Normal file
21
apps/web/src/ds-entry.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Design-system surface for design-sync: pure presentational components only.
|
||||
* App-level pieces (router/wagmi-bound screens, panels, layout) are excluded
|
||||
* on purpose — they need live context the design tool doesn't have.
|
||||
*/
|
||||
export { Button } from "./components/ui/button";
|
||||
export { Card } from "./components/ui/card";
|
||||
export { Badge } from "./components/ui/badge";
|
||||
export { Skeleton } from "./components/ui/skeleton";
|
||||
export { Stat } from "./components/ui/stat";
|
||||
export { LogoMark, Wordmark } from "./components/brand/Logo";
|
||||
export { XIcon } from "./components/icons/XIcon";
|
||||
export { DepthProgress } from "./components/DepthProgress";
|
||||
export { HealthRadial } from "./components/HealthRadial";
|
||||
export { SlippageCurve } from "./components/SlippageCurve";
|
||||
export { CountUp } from "./components/CountUp";
|
||||
export { GlyphMarquee } from "./components/GlyphMarquee";
|
||||
export { DepthField } from "./components/backdrop/DepthField";
|
||||
export { DepthLadder } from "./components/backdrop/DepthLadder";
|
||||
export { GlowField } from "./components/backdrop/GlowField";
|
||||
export { GlyphField } from "./components/backdrop/GlyphField";
|
||||
210
apps/web/src/hooks/useCampaigns.ts
Normal file
210
apps/web/src/hooks/useCampaigns.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useAccount, useReadContracts } from "wagmi";
|
||||
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
|
||||
import {
|
||||
ACTIVE_CHAIN_ID,
|
||||
getActiveDeployment,
|
||||
USD_DECIMALS,
|
||||
type MarketMeta,
|
||||
} from "@/lib/contracts";
|
||||
|
||||
export interface CampaignState {
|
||||
meta: MarketMeta;
|
||||
tvlUsd: number;
|
||||
targetDepthUsd: number;
|
||||
rewardBudgetUsd: number;
|
||||
start: number;
|
||||
end: number;
|
||||
boostWindow: number;
|
||||
boostBps: number;
|
||||
durationDays: number;
|
||||
activeLPs: number;
|
||||
status: "upcoming" | "boost" | "active" | "ended";
|
||||
}
|
||||
|
||||
/** Budget still unreleased by the linear emission schedule, in USD. */
|
||||
export function rewardsRemainingUsd(c: CampaignState, nowSec: number): number {
|
||||
if (c.end <= c.start) return 0;
|
||||
const left = Math.min(Math.max((c.end - nowSec) / (c.end - c.start), 0), 1);
|
||||
return c.rewardBudgetUsd * left;
|
||||
}
|
||||
|
||||
const scale = 10 ** USD_DECIMALS;
|
||||
|
||||
export function useCampaigns() {
|
||||
const deployment = getActiveDeployment();
|
||||
|
||||
const { data, isLoading, error } = useReadContracts({
|
||||
contracts: deployment.markets.map((m) => ({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "getCampaign",
|
||||
args: [BigInt(m.campaignId)],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
})),
|
||||
query: { refetchInterval: 15_000 },
|
||||
});
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const campaigns: CampaignState[] | undefined = data?.map((res, i) => {
|
||||
const meta = deployment.markets[i];
|
||||
const c = res.result as
|
||||
| {
|
||||
start: bigint;
|
||||
end: bigint;
|
||||
boostWindow: bigint;
|
||||
boostBps: number;
|
||||
targetDepth: bigint;
|
||||
rewardBudget: bigint;
|
||||
totalDeposits: bigint;
|
||||
activeLPs: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!c) {
|
||||
return {
|
||||
meta,
|
||||
tvlUsd: 0,
|
||||
targetDepthUsd: 0,
|
||||
rewardBudgetUsd: 0,
|
||||
start: 0,
|
||||
end: 0,
|
||||
boostWindow: 0,
|
||||
boostBps: 10_000,
|
||||
durationDays: 0,
|
||||
activeLPs: 0,
|
||||
status: "upcoming" as const,
|
||||
};
|
||||
}
|
||||
const start = Number(c.start);
|
||||
const end = Number(c.end);
|
||||
const boostWindow = Number(c.boostWindow);
|
||||
let status: CampaignState["status"] = "active";
|
||||
if (now < start) status = "upcoming";
|
||||
else if (now < start + boostWindow) status = "boost";
|
||||
else if (now >= end) status = "ended";
|
||||
return {
|
||||
meta,
|
||||
tvlUsd: Number(c.totalDeposits) / scale,
|
||||
targetDepthUsd: Number(c.targetDepth) / scale,
|
||||
rewardBudgetUsd: Number(c.rewardBudget) / scale,
|
||||
start,
|
||||
end,
|
||||
boostWindow,
|
||||
boostBps: Number(c.boostBps),
|
||||
durationDays: (end - start) / 86_400,
|
||||
activeLPs: Number(c.activeLPs),
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
return { campaigns, isLoading, error, deployment };
|
||||
}
|
||||
|
||||
/** Positions across every campaign, for the portfolio board. */
|
||||
export function usePositions() {
|
||||
const { address } = useAccount();
|
||||
const deployment = getActiveDeployment();
|
||||
const zero = "0x0000000000000000000000000000000000000000" as const;
|
||||
|
||||
const { data, isLoading, refetch } = useReadContracts({
|
||||
contracts: deployment.markets.flatMap((m) => [
|
||||
{
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "getPosition",
|
||||
args: [BigInt(m.campaignId), address ?? zero],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
} as const,
|
||||
{
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "pendingRewards",
|
||||
args: [BigInt(m.campaignId), address ?? zero],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
} as const,
|
||||
]),
|
||||
query: { enabled: !!address, refetchInterval: 15_000 },
|
||||
});
|
||||
|
||||
const positions = deployment.markets.map((meta, i) => {
|
||||
const raw = data?.[i * 2]?.result as
|
||||
| {
|
||||
amount: bigint;
|
||||
weighted: bigint;
|
||||
claimed: bigint;
|
||||
stockAmount: bigint;
|
||||
stockCredit: bigint;
|
||||
depositedAt: bigint;
|
||||
lpIndex: number;
|
||||
}
|
||||
| undefined;
|
||||
const pending = data?.[i * 2 + 1]?.result as bigint | undefined;
|
||||
return {
|
||||
meta,
|
||||
amountUsd: raw ? Number(raw.amount) / scale : 0,
|
||||
claimedUsd: raw ? Number(raw.claimed) / scale : 0,
|
||||
pendingUsd: pending != null ? Number(pending) / scale : 0,
|
||||
boosted: raw ? raw.amount > 0n && raw.weighted > raw.amount : false,
|
||||
depositedAt: raw ? Number(raw.depositedAt) : 0,
|
||||
lpIndex: raw ? Number(raw.lpIndex) : 0,
|
||||
stockAmount: raw ? Number(raw.stockAmount) / 1e18 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { positions, isLoading, refetch };
|
||||
}
|
||||
|
||||
export function usePosition(campaignId: number) {
|
||||
const { address } = useAccount();
|
||||
const deployment = getActiveDeployment();
|
||||
|
||||
const { data, isLoading, refetch } = useReadContracts({
|
||||
contracts: [
|
||||
{
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "getPosition",
|
||||
args: [BigInt(campaignId), address ?? "0x0000000000000000000000000000000000000000"],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "pendingRewards",
|
||||
args: [BigInt(campaignId), address ?? "0x0000000000000000000000000000000000000000"],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
],
|
||||
query: { enabled: !!address, refetchInterval: 15_000 },
|
||||
});
|
||||
|
||||
const raw = data?.[0]?.result as
|
||||
| {
|
||||
amount: bigint;
|
||||
weighted: bigint;
|
||||
claimed: bigint;
|
||||
stockAmount: bigint;
|
||||
stockCredit: bigint;
|
||||
depositedAt: bigint;
|
||||
lpIndex: number;
|
||||
}
|
||||
| undefined;
|
||||
const pending = data?.[1]?.result as bigint | undefined;
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
refetch,
|
||||
position: raw
|
||||
? {
|
||||
amountUsd: Number(raw.amount) / scale,
|
||||
weighted: Number(raw.weighted) / scale,
|
||||
claimedUsd: Number(raw.claimed) / scale,
|
||||
depositedAt: Number(raw.depositedAt),
|
||||
lpIndex: Number(raw.lpIndex),
|
||||
stockAmount: Number(raw.stockAmount) / 1e18,
|
||||
stockCreditUsd: Number(raw.stockCredit) / scale,
|
||||
pendingUsd: pending != null ? Number(pending) / scale : 0,
|
||||
boosted: raw.amount > 0n && raw.weighted > raw.amount,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
27
apps/web/src/hooks/useChainStats.ts
Normal file
27
apps/web/src/hooks/useChainStats.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createPublicClient, http, formatGwei } from "viem";
|
||||
import { mainnet } from "viem/chains";
|
||||
import { STATS_RPC_URL } from "@/lib/chain";
|
||||
|
||||
const statsClient = createPublicClient({
|
||||
chain: mainnet,
|
||||
transport: http(STATS_RPC_URL),
|
||||
});
|
||||
|
||||
/** Live L1 stats shown in the status strip. */
|
||||
export function useChainStats() {
|
||||
return useQuery({
|
||||
queryKey: ["chain-stats"],
|
||||
queryFn: async () => {
|
||||
const [blockNumber, gasPrice] = await Promise.all([
|
||||
statsClient.getBlockNumber(),
|
||||
statsClient.getGasPrice(),
|
||||
]);
|
||||
return {
|
||||
blockNumber: Number(blockNumber),
|
||||
gasGwei: Number(formatGwei(gasPrice)),
|
||||
};
|
||||
},
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
36
apps/web/src/hooks/useMarketData.ts
Normal file
36
apps/web/src/hooks/useMarketData.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPair, fetchBasket, type PairData } from "@/lib/dexscreener";
|
||||
import type { MarketMeta } from "@/lib/contracts";
|
||||
|
||||
async function fetchMarket(meta: MarketMeta): Promise<PairData | null> {
|
||||
if (meta.dexscreenerQuery.startsWith("basket:")) {
|
||||
return fetchBasket(meta.dexscreenerQuery.slice("basket:".length).split(","));
|
||||
}
|
||||
return fetchPair(meta.dexscreenerQuery);
|
||||
}
|
||||
|
||||
/** Live price / volume / liquidity for one market, refreshed every 30s. */
|
||||
export function useMarketData(meta: MarketMeta) {
|
||||
return useQuery({
|
||||
queryKey: ["dexscreener", meta.dexscreenerQuery],
|
||||
queryFn: () => fetchMarket(meta),
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 25_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllMarketData(markets: MarketMeta[]) {
|
||||
return useQuery({
|
||||
queryKey: ["dexscreener-all", markets.map((m) => m.dexscreenerQuery).join()],
|
||||
queryFn: async () => {
|
||||
const results = await Promise.all(
|
||||
markets.map((m) => fetchMarket(m).catch(() => null)),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
markets.map((m, i) => [m.symbol, results[i]]),
|
||||
) as Record<string, PairData | null>;
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 25_000,
|
||||
});
|
||||
}
|
||||
400
apps/web/src/index.css
Normal file
400
apps/web/src/index.css
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ---------------------------------- fonts ---------------------------------- */
|
||||
|
||||
@font-face {
|
||||
font-family: "Tanker";
|
||||
src: url("/fonts/Tanker-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cabinet Grotesk";
|
||||
src: url("/fonts/CabinetGrotesk-Extrabold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cabinet Grotesk";
|
||||
src: url("/fonts/CabinetGrotesk-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Switzer";
|
||||
src: url("/fonts/Switzer-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Switzer";
|
||||
src: url("/fonts/Switzer-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Switzer";
|
||||
src: url("/fonts/Switzer-Semibold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Panchang";
|
||||
src: url("/fonts/Panchang-Extrabold.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Panchang";
|
||||
src: url("/fonts/Panchang-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sentient";
|
||||
src: url("/fonts/Sentient-Light.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sentient";
|
||||
src: url("/fonts/Sentient-LightItalic.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fragment Mono";
|
||||
src: url("/fonts/FragmentMono-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("/fonts/JetBrainsMono-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ----------------------------- palette: BEARER ------------------------------
|
||||
The only colors allowed in this codebase. Alpha variants of a token are
|
||||
fine; any other literal fails `pnpm audit:palette`.
|
||||
|
||||
BASE (neutral ramp) void #080808 · surface #111111 · card #161616
|
||||
card-hover #1c1c1c · edge #2a2a2a · edge-bright #3d3d3d
|
||||
TEXT snow #f2f2f2 · fog #919191 · fog-dim #5c5c5c
|
||||
ACID (value present) mint #c8ff00 · mint-dim #8fb000 · lime #e9ffb3 (lore)
|
||||
VOLT (counter side) volt #5200ff (fills) · volt-soft #8a5cff (lines/text)
|
||||
DANGER (risk only) danger #ff3b00, motion-paired via .blink-risk
|
||||
Exempt: white/black stops inside SVG luminance masks (GlyphField).
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
@theme static {
|
||||
/* Neutral black ramp, Marathon-faithful: no warm cast anywhere */
|
||||
--color-void: #080808;
|
||||
--color-surface: #111111;
|
||||
--color-card: #161616;
|
||||
--color-card-hover: #1c1c1c;
|
||||
--color-edge: #2a2a2a;
|
||||
--color-edge-bright: #3d3d3d;
|
||||
/* Acid is semantic: value is present. Marathon C0FE04 x RH CCFF00 */
|
||||
--color-mint: #c8ff00;
|
||||
--color-mint-dim: #8fb000;
|
||||
--color-mint-soft: rgba(200, 255, 0, 0.1);
|
||||
/* Lore tint for display fragments, keeps pure acid for data */
|
||||
--color-lime: #e9ffb3;
|
||||
/* Neutral terminal grays (Marathon foreground-muted family) */
|
||||
--color-fog: #919191;
|
||||
--color-fog-dim: #5c5c5c;
|
||||
--color-snow: #f2f2f2;
|
||||
/* Marathon channels */
|
||||
--color-volt: #5200ff;
|
||||
--color-danger: #ff3b00;
|
||||
--color-volt-soft: #8a5cff;
|
||||
|
||||
--font-display: "Panchang", "Cabinet Grotesk", system-ui, sans-serif;
|
||||
--font-head: "Cabinet Grotesk", system-ui, sans-serif;
|
||||
--font-serif: "Sentient", Georgia, serif;
|
||||
--font-body: "Switzer", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-mono-label: "Fragment Mono", ui-monospace, monospace;
|
||||
|
||||
--shadow-glow: 0 0 28px rgba(200, 255, 0, 0.4);
|
||||
--shadow-glow-strong: 0 0 52px rgba(200, 255, 0, 0.55);
|
||||
|
||||
--animate-marquee: marquee 24s linear infinite;
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------- base ---------------------------------- */
|
||||
|
||||
html {
|
||||
background: var(--color-void);
|
||||
color: var(--color-snow);
|
||||
font-family: var(--font-body);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Two-register rule: only super-type screams. H1 and .super are Panchang
|
||||
caps; H2 section heads drop to sentence-case Cabinet, the Robinhood layer */
|
||||
h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.super {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h3, h4 {
|
||||
font-family: var(--font-head);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Certificate register: the one serif line per page. Never sets a digit */
|
||||
.certificate {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.005em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-void); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--color-mint), var(--color-mint-dim));
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--color-void);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-mint); }
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--color-mint-dim) var(--color-void); }
|
||||
|
||||
::selection { background: var(--color-mint); color: var(--color-void); }
|
||||
|
||||
/* ------------------------------- signatures -------------------------------- */
|
||||
|
||||
/* Film grain overlay: applied once in AppShell */
|
||||
.grain::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
opacity: 0.05;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Bayer-style dither texture, acid on transparent */
|
||||
.dither {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cg fill='%23c8ff00'%3E%3Crect x='0' y='0' width='1' height='1'/%3E%3Crect x='4' y='2' width='1' height='1'/%3E%3Crect x='2' y='4' width='1' height='1'/%3E%3Crect x='6' y='6' width='1' height='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Terminal corner ticks for cards */
|
||||
.ticks {
|
||||
position: relative;
|
||||
}
|
||||
.ticks::before,
|
||||
.ticks::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-color: var(--color-edge-bright);
|
||||
border-style: solid;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.ticks::before {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-width: 1px 0 0 1px;
|
||||
}
|
||||
.ticks::after {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-width: 0 1px 1px 0;
|
||||
}
|
||||
.ticks:hover::before,
|
||||
.ticks:hover::after {
|
||||
border-color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* Armed ticks: acid corners plus a soft tube glow when value is live */
|
||||
.ticks-live {
|
||||
box-shadow:
|
||||
0 0 2px rgba(200, 255, 0, 0.25),
|
||||
0 0 26px rgba(200, 255, 0, 0.09);
|
||||
}
|
||||
.ticks-live::before,
|
||||
.ticks-live::after {
|
||||
border-color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* Neon numerals: key acid figures get the tube treatment */
|
||||
.neon-num {
|
||||
text-shadow:
|
||||
0 0 10px rgba(200, 255, 0, 0.55),
|
||||
0 0 32px rgba(200, 255, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Danger pairing law: the blink flicker is reserved for risk states and the
|
||||
danger hue never appears static in it */
|
||||
@keyframes blink-risk {
|
||||
0%, 100% { opacity: 1; }
|
||||
46%, 54% { opacity: 0.35; }
|
||||
}
|
||||
.blink-risk {
|
||||
animation: blink-risk 1.6s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Defined-term gloss: dotted mechanism word opening a plain-language card */
|
||||
.term {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted var(--color-fog-dim);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Section labels: // MARKETS. Fragment Mono = label voice, never digits */
|
||||
.slabel {
|
||||
font-family: var(--font-mono-label);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
.slabel::before {
|
||||
content: "// ";
|
||||
color: var(--color-fog-dim);
|
||||
}
|
||||
|
||||
/* Instrument-plate caption under charts: FIG. 01 // DEPTH // SURFACE DATA */
|
||||
.fig {
|
||||
font-family: var(--font-mono-label);
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-fog-dim);
|
||||
}
|
||||
|
||||
/* Live footnote apparatus: acid superscript on serif statements */
|
||||
.footnote-sup {
|
||||
font-family: var(--font-mono);
|
||||
font-style: normal;
|
||||
font-size: 0.55em;
|
||||
vertical-align: super;
|
||||
color: var(--color-mint);
|
||||
}
|
||||
|
||||
/* Marathon wipe reveal: masks only, never gates data paint */
|
||||
@keyframes wipe-in {
|
||||
from { clip-path: inset(0 100% 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
.wipe-in {
|
||||
animation: wipe-in 0.7s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Carriage: the acid square that leads a wipe */
|
||||
@keyframes carriage {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
to { transform: translateX(min(38vw, 460px)); opacity: 0; }
|
||||
}
|
||||
.carriage {
|
||||
animation: carriage 0.9s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Tabular numerals for all data */
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dotted leader rows (label ..... value) */
|
||||
.leader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.leader::before {
|
||||
content: "";
|
||||
order: 2;
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted var(--color-edge-bright);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.leader > :first-child { order: 1; }
|
||||
.leader > :last-child { order: 3; }
|
||||
|
||||
/* Light sweep across primary buttons */
|
||||
.btn-sweep {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn-sweep::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-130%) skewX(-18deg);
|
||||
background: linear-gradient(105deg, transparent 42%, rgba(242, 242, 242, 0.5) 50%, transparent 58%);
|
||||
transition: transform 0.55s ease;
|
||||
}
|
||||
.btn-sweep:hover::after {
|
||||
transform: translateX(130%) skewX(-18deg);
|
||||
}
|
||||
|
||||
/* Neon signage: acid text with a soft tube glow, decorative only */
|
||||
.neon {
|
||||
color: var(--color-mint);
|
||||
text-shadow:
|
||||
0 0 14px rgba(200, 255, 0, 0.5),
|
||||
0 0 42px rgba(200, 255, 0, 0.22);
|
||||
}
|
||||
|
||||
.neon-violet {
|
||||
color: var(--color-volt-soft);
|
||||
text-shadow:
|
||||
0 0 14px rgba(82, 0, 255, 0.55),
|
||||
0 0 42px rgba(82, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Vertical rotated side label, MARATHON style */
|
||||
.sidelabel {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-mint);
|
||||
user-select: none;
|
||||
}
|
||||
951
apps/web/src/lib/abi/HoodquidityVaults.ts
Normal file
951
apps/web/src/lib/abi/HoodquidityVaults.ts
Normal file
|
|
@ -0,0 +1,951 @@
|
|||
export const hoodquidityVaultsAbi = [
|
||||
{
|
||||
"type": "constructor",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "owner_",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "BPS",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint16",
|
||||
"internalType": "uint16"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "EARLY_EXIT_FEE_BPS",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint16",
|
||||
"internalType": "uint16"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "PLATFORM_FEE_BPS",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint16",
|
||||
"internalType": "uint16"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "SWEEP_DELAY",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "campaignCount",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "claim",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "createCampaign",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "stable",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "stockToken",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "priceFeed",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "boostWindow",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "boostBps",
|
||||
"type": "uint16",
|
||||
"internalType": "uint16"
|
||||
},
|
||||
{
|
||||
"name": "targetDepth",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "rewardBudget",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "deposit",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "depositDual",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "stockAmount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "stableAmount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "getCampaign",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "tuple",
|
||||
"internalType": "struct HoodquidityVaults.Campaign",
|
||||
"components": [
|
||||
{
|
||||
"name": "stable",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "stockToken",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "priceFeed",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "boostWindow",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "boostBps",
|
||||
"type": "uint16",
|
||||
"internalType": "uint16"
|
||||
},
|
||||
{
|
||||
"name": "depositsPaused",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
},
|
||||
{
|
||||
"name": "activeLPs",
|
||||
"type": "uint32",
|
||||
"internalType": "uint32"
|
||||
},
|
||||
{
|
||||
"name": "lpCount",
|
||||
"type": "uint32",
|
||||
"internalType": "uint32"
|
||||
},
|
||||
{
|
||||
"name": "targetDepth",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "rewardBudget",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "totalEmitted",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "rewardDistributed",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "totalDeposits",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "totalWeighted",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "accRewardPerWeighted",
|
||||
"type": "uint192",
|
||||
"internalType": "uint192"
|
||||
},
|
||||
{
|
||||
"name": "lastUpdate",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "getPosition",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "tuple",
|
||||
"internalType": "struct HoodquidityVaults.Position",
|
||||
"components": [
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "weighted",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "claimed",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "stockAmount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "stockCredit",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "depositedAt",
|
||||
"type": "uint64",
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "lpIndex",
|
||||
"type": "uint32",
|
||||
"internalType": "uint32"
|
||||
},
|
||||
{
|
||||
"name": "rewardDebt",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "owner",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "pause",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "paused",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "pendingRewards",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "quoteStock",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "stockAmount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "renounceOwnership",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "setDepositsPaused",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "paused_",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "setTreasury",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "treasury_",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "sweep",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "transferOwnership",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "newOwner",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "treasury",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "unallocated",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "unpause",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "withdraw",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "CampaignCreated",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "stable",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "stockToken",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "start",
|
||||
"type": "uint64",
|
||||
"indexed": false,
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "end",
|
||||
"type": "uint64",
|
||||
"indexed": false,
|
||||
"internalType": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "targetDepth",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "rewardBudget",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Deposited",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "weighted",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "DepositedStock",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "stockAmount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "stockValue",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "OwnershipTransferred",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "previousOwner",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "newOwner",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Paused",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "account",
|
||||
"type": "address",
|
||||
"indexed": false,
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "PlatformFeeTaken",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Redistributed",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "RewardsClaimed",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Swept",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "TreasuryUpdated",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "treasury",
|
||||
"type": "address",
|
||||
"indexed": false,
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Unpaused",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "account",
|
||||
"type": "address",
|
||||
"indexed": false,
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Withdrawn",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "uint256",
|
||||
"indexed": true,
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "user",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "fee",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
},
|
||||
{
|
||||
"name": "forfeited",
|
||||
"type": "uint128",
|
||||
"indexed": false,
|
||||
"internalType": "uint128"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "BadConfig",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "BadPrice",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "CampaignDepositsPaused",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "CampaignNotActive",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "CampaignNotFound",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "EnforcedPause",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ExpectedPause",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "NothingToClaim",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "NothingToWithdraw",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "OwnableInvalidOwner",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "owner",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "OwnableUnauthorizedAccount",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "account",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ReentrancyGuardReentrantCall",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "SafeERC20FailedOperation",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "token",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "TooEarly",
|
||||
"inputs": []
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ZeroAmount",
|
||||
"inputs": []
|
||||
}
|
||||
] as const;
|
||||
374
apps/web/src/lib/abi/MockERC20.ts
Normal file
374
apps/web/src/lib/abi/MockERC20.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
export const mockERC20Abi = [
|
||||
{
|
||||
"type": "constructor",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "name_",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
},
|
||||
{
|
||||
"name": "symbol_",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
},
|
||||
{
|
||||
"name": "decimals_",
|
||||
"type": "uint8",
|
||||
"internalType": "uint8"
|
||||
},
|
||||
{
|
||||
"name": "faucetAmount_",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "allowance",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "owner",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "spender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "approve",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "spender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "balanceOf",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "account",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "decimals",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint8",
|
||||
"internalType": "uint8"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "faucet",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "faucetAmount",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "mint",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "to",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "amount",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "name",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "symbol",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "totalSupply",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "transfer",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "to",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "transferFrom",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "from",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "bool",
|
||||
"internalType": "bool"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Approval",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "owner",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "spender",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint256",
|
||||
"indexed": false,
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "event",
|
||||
"name": "Transfer",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "from",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"type": "address",
|
||||
"indexed": true,
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint256",
|
||||
"indexed": false,
|
||||
"internalType": "uint256"
|
||||
}
|
||||
],
|
||||
"anonymous": false
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InsufficientAllowance",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "spender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "allowance",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "needed",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InsufficientBalance",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "sender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
},
|
||||
{
|
||||
"name": "balance",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "needed",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InvalidApprover",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "approver",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InvalidReceiver",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "receiver",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InvalidSender",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "sender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "ERC20InvalidSpender",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "spender",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
]
|
||||
}
|
||||
] as const;
|
||||
113
apps/web/src/lib/abi/MockV3Aggregator.ts
Normal file
113
apps/web/src/lib/abi/MockV3Aggregator.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
export const mockV3AggregatorAbi = [
|
||||
{
|
||||
"type": "constructor",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "decimals_",
|
||||
"type": "uint8",
|
||||
"internalType": "uint8"
|
||||
},
|
||||
{
|
||||
"name": "description_",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
},
|
||||
{
|
||||
"name": "initialAnswer",
|
||||
"type": "int256",
|
||||
"internalType": "int256"
|
||||
}
|
||||
],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "decimals",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "uint8",
|
||||
"internalType": "uint8"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "description",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "string",
|
||||
"internalType": "string"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "latestRoundData",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "roundId",
|
||||
"type": "uint80",
|
||||
"internalType": "uint80"
|
||||
},
|
||||
{
|
||||
"name": "answer",
|
||||
"type": "int256",
|
||||
"internalType": "int256"
|
||||
},
|
||||
{
|
||||
"name": "startedAt",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "updatedAt",
|
||||
"type": "uint256",
|
||||
"internalType": "uint256"
|
||||
},
|
||||
{
|
||||
"name": "answeredInRound",
|
||||
"type": "uint80",
|
||||
"internalType": "uint80"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "owner",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "address",
|
||||
"internalType": "address"
|
||||
}
|
||||
],
|
||||
"stateMutability": "view"
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "updateAnswer",
|
||||
"inputs": [
|
||||
{
|
||||
"name": "answer",
|
||||
"type": "int256",
|
||||
"internalType": "int256"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable"
|
||||
},
|
||||
{
|
||||
"type": "error",
|
||||
"name": "NotOwner",
|
||||
"inputs": []
|
||||
}
|
||||
] as const;
|
||||
30
apps/web/src/lib/chain.ts
Normal file
30
apps/web/src/lib/chain.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineChain } from "viem";
|
||||
import { arbitrumSepolia } from "viem/chains";
|
||||
|
||||
/**
|
||||
* Robinhood Chain testnet (Arbitrum Orbit L2). Verified live:
|
||||
* eth_chainId on the RPC returns 0xb626 = 46630.
|
||||
* Faucet: https://faucet.testnet.chain.robinhood.com
|
||||
*/
|
||||
export const robinhoodTestnet = defineChain({
|
||||
id: 46_630,
|
||||
name: "Robinhood Chain Testnet",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
rpcUrls: {
|
||||
default: { http: ["https://rpc.testnet.chain.robinhood.com/rpc"] },
|
||||
},
|
||||
blockExplorers: {
|
||||
default: {
|
||||
name: "Robinhood Explorer",
|
||||
url: "https://explorer.testnet.chain.robinhood.com",
|
||||
},
|
||||
},
|
||||
testnet: true,
|
||||
});
|
||||
|
||||
export const deployChain = arbitrumSepolia;
|
||||
|
||||
/** Public RPC for live chain stats shown in the UI (block number, gas). */
|
||||
export const STATS_RPC_URL = "https://1rpc.io/eth";
|
||||
|
||||
export const DEXSCREENER_API = "https://api.dexscreener.com";
|
||||
5
apps/web/src/lib/constants.ts
Normal file
5
apps/web/src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** Provided by the manager before release */
|
||||
export const TWITTER_URL = "https://x.com/hoodquidity";
|
||||
|
||||
export const APP_NAME = "Hoodquidity";
|
||||
export const TAGLINE = "Add liquidity, earn fees, make markets deeper";
|
||||
96
apps/web/src/lib/contracts.ts
Normal file
96
apps/web/src/lib/contracts.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { Address } from "viem";
|
||||
|
||||
export interface MarketMeta {
|
||||
symbol: string;
|
||||
name: string;
|
||||
campaignId: number;
|
||||
stockToken: Address;
|
||||
/** Chainlink-style feed pricing the stock token, 8 decimals */
|
||||
priceFeed: Address;
|
||||
/** Real reference pair on Dexscreener used for live price/volume */
|
||||
dexscreenerQuery: string;
|
||||
risk: "Low" | "Medium" | "High";
|
||||
}
|
||||
|
||||
interface Deployment {
|
||||
vaults: Address;
|
||||
usd: Address;
|
||||
markets: MarketMeta[];
|
||||
}
|
||||
|
||||
/** anvil (local dev) deployment, chainId 31337 */
|
||||
const local: Deployment = {
|
||||
vaults: "0x68B1D87F95878fE05B998F19b66F4baba5De1aed",
|
||||
usd: "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
|
||||
markets: [
|
||||
{
|
||||
symbol: "NVDA",
|
||||
name: "NVIDIA Stock Token",
|
||||
campaignId: 0,
|
||||
stockToken: "0x59b670e9fA9D0A427751Af201D676719a970857b",
|
||||
priceFeed: "0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
|
||||
dexscreenerQuery: "NVDAx",
|
||||
risk: "Medium",
|
||||
},
|
||||
{
|
||||
symbol: "TSLA",
|
||||
name: "Tesla Stock Token",
|
||||
campaignId: 1,
|
||||
stockToken: "0xa85233C63b9Ee964Add6F2cffe00Fd84eb32338f",
|
||||
priceFeed: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319",
|
||||
dexscreenerQuery: "TSLAx",
|
||||
risk: "Medium",
|
||||
},
|
||||
{
|
||||
symbol: "AAPL",
|
||||
name: "Apple Stock Token",
|
||||
campaignId: 2,
|
||||
stockToken: "0x09635F643e140090A9A8Dcd712eD6285858ceBef",
|
||||
priceFeed: "0xc5a5C42992dECbae36851359345FE25997F5C42d",
|
||||
dexscreenerQuery: "AAPLx",
|
||||
risk: "Low",
|
||||
},
|
||||
{
|
||||
symbol: "SPY",
|
||||
name: "SPDR S&P 500 Token",
|
||||
campaignId: 3,
|
||||
stockToken: "0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E",
|
||||
priceFeed: "0xc3e53F4d16Ae77Db1c982e75a937B9f60FE63690",
|
||||
dexscreenerQuery: "SPYx",
|
||||
risk: "Low",
|
||||
},
|
||||
{
|
||||
symbol: "AIB",
|
||||
name: "AI Basket Token",
|
||||
campaignId: 4,
|
||||
stockToken: "0x9E545E3C0baAB3E08CdfD552C960A1050f373042",
|
||||
priceFeed: "0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9",
|
||||
dexscreenerQuery: "basket:NVDAx,AMDx,PLTRx",
|
||||
risk: "High",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Filled in after the public testnet deploy */
|
||||
const testnet: Deployment | null = null;
|
||||
|
||||
const DEPLOYMENTS: Record<number, Deployment | null> = {
|
||||
31337: local,
|
||||
421614: testnet, // Arbitrum Sepolia
|
||||
};
|
||||
|
||||
/**
|
||||
* The chain the app talks to, independent of what the wallet currently
|
||||
* selects: anvil in dev, the public testnet in the built site.
|
||||
*/
|
||||
export const ACTIVE_CHAIN_ID = import.meta.env.DEV ? 31337 : 421614;
|
||||
|
||||
export function getActiveDeployment(): Deployment {
|
||||
return DEPLOYMENTS[ACTIVE_CHAIN_ID] ?? local;
|
||||
}
|
||||
|
||||
export function getDeployment(chainId: number | undefined): Deployment {
|
||||
return (chainId != null && DEPLOYMENTS[chainId]) || local;
|
||||
}
|
||||
|
||||
export const USD_DECIMALS = 6;
|
||||
71
apps/web/src/lib/dexscreener.ts
Normal file
71
apps/web/src/lib/dexscreener.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { DEXSCREENER_API } from "./chain";
|
||||
|
||||
export interface PairData {
|
||||
priceUsd: number;
|
||||
change24h: number;
|
||||
volume24h: number;
|
||||
liquidityUsd: number;
|
||||
dexId: string;
|
||||
chainId: string;
|
||||
pairSymbol: string;
|
||||
}
|
||||
|
||||
interface RawPair {
|
||||
chainId: string;
|
||||
dexId: string;
|
||||
priceUsd?: string;
|
||||
baseToken: { symbol: string };
|
||||
quoteToken: { symbol: string };
|
||||
volume?: { h24?: number };
|
||||
liquidity?: { usd?: number };
|
||||
priceChange?: { h24?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live market data for a tokenized stock, sourced from the deepest
|
||||
* stable-quoted pair on Dexscreener.
|
||||
*/
|
||||
export async function fetchPair(query: string): Promise<PairData | null> {
|
||||
const res = await fetch(
|
||||
`${DEXSCREENER_API}/latest/dex/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`dexscreener ${res.status}`);
|
||||
const data = (await res.json()) as { pairs?: RawPair[] };
|
||||
const pairs = (data.pairs ?? []).filter(
|
||||
(p) =>
|
||||
p.baseToken.symbol.toUpperCase() === query.toUpperCase() &&
|
||||
p.priceUsd != null,
|
||||
);
|
||||
if (pairs.length === 0) return null;
|
||||
|
||||
const best = pairs.reduce((a, b) =>
|
||||
(a.liquidity?.usd ?? 0) >= (b.liquidity?.usd ?? 0) ? a : b,
|
||||
);
|
||||
return {
|
||||
priceUsd: Number(best.priceUsd),
|
||||
change24h: best.priceChange?.h24 ?? 0,
|
||||
volume24h: best.volume?.h24 ?? 0,
|
||||
liquidityUsd: best.liquidity?.usd ?? 0,
|
||||
dexId: best.dexId,
|
||||
chainId: best.chainId,
|
||||
pairSymbol: `${best.baseToken.symbol}/${best.quoteToken.symbol}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Equal-weight synthetic basket from several underlying tokens. */
|
||||
export async function fetchBasket(queries: string[]): Promise<PairData | null> {
|
||||
const parts = (await Promise.all(queries.map((q) => fetchPair(q).catch(() => null)))).filter(
|
||||
(p): p is PairData => p !== null,
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
const n = parts.length;
|
||||
return {
|
||||
priceUsd: parts.reduce((s, p) => s + p.priceUsd, 0) / n,
|
||||
change24h: parts.reduce((s, p) => s + p.change24h, 0) / n,
|
||||
volume24h: parts.reduce((s, p) => s + p.volume24h, 0),
|
||||
liquidityUsd: parts.reduce((s, p) => s + p.liquidityUsd, 0),
|
||||
dexId: "basket",
|
||||
chainId: "multi",
|
||||
pairSymbol: "AIB/USDC",
|
||||
};
|
||||
}
|
||||
36
apps/web/src/lib/format.ts
Normal file
36
apps/web/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export function fmtUsd(value: number, compact = true): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: compact && Math.abs(value) >= 10_000 ? "compact" : "standard",
|
||||
maximumFractionDigits: Math.abs(value) >= 10_000 ? 1 : 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function fmtPct(value: number, digits = 2): string {
|
||||
return `${(value * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function fmtAprPct(value: number): string {
|
||||
const pct = value * 100;
|
||||
if (pct > 9_999) return ">9,999%";
|
||||
if (pct >= 1_000) return `${Math.round(pct).toLocaleString("en-US")}%`;
|
||||
return `${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function fmtNum(value: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: Math.abs(value) >= 10_000 ? "compact" : "standard",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function fmtCountdown(untilSec: number): string {
|
||||
const s = Math.max(untilSec - Math.floor(Date.now() / 1000), 0);
|
||||
const d = Math.floor(s / 86_400);
|
||||
const h = Math.floor((s % 86_400) / 3_600);
|
||||
const m = Math.floor((s % 3_600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
71
apps/web/src/lib/math.ts
Normal file
71
apps/web/src/lib/math.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Market math shared across screens. Slippage follows the constant-product
|
||||
* approximation: a buy of size X against a pool with liquidity L (both sides
|
||||
* combined, USD) moves the price by roughly X / (L/2 + X).
|
||||
*/
|
||||
|
||||
export function slippageFor(tradeUsd: number, liquidityUsd: number): number {
|
||||
if (liquidityUsd <= 0) return 1;
|
||||
return tradeUsd / (liquidityUsd / 2 + tradeUsd);
|
||||
}
|
||||
|
||||
/** How a deposit into the campaign vault improves slippage for a given trade. */
|
||||
export function slippageImpact(
|
||||
tradeUsd: number,
|
||||
liquidityUsd: number,
|
||||
depositUsd: number,
|
||||
): { before: number; after: number } {
|
||||
return {
|
||||
before: slippageFor(tradeUsd, liquidityUsd),
|
||||
after: slippageFor(tradeUsd, liquidityUsd + depositUsd),
|
||||
};
|
||||
}
|
||||
|
||||
const POOL_FEE = 0.0025; // 0.25% venue fee accrued by LPs
|
||||
|
||||
export function feeApr(volume24h: number, liquidityUsd: number): number {
|
||||
if (liquidityUsd <= 0) return 0;
|
||||
return ((volume24h * POOL_FEE) / liquidityUsd) * 365;
|
||||
}
|
||||
|
||||
export function rewardApr(
|
||||
rewardBudgetUsd: number,
|
||||
durationDays: number,
|
||||
tvlUsd: number,
|
||||
): number {
|
||||
if (durationDays <= 0) return 0;
|
||||
const base = Math.max(tvlUsd, 25_000); // floor so an empty vault shows a sane cap
|
||||
return (rewardBudgetUsd / base) * (365 / durationDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Market Health Score, 0 to 100. Blends depth, turnover, slippage for a
|
||||
* standard $10k trade and campaign progress.
|
||||
*/
|
||||
export function healthScore(input: {
|
||||
liquidityUsd: number;
|
||||
volume24h: number;
|
||||
tvlUsd: number;
|
||||
targetDepthUsd: number;
|
||||
}): number {
|
||||
const depth = Math.min(input.liquidityUsd / 2_000_000, 1); // saturates at $2M
|
||||
const turnover =
|
||||
input.liquidityUsd > 0
|
||||
? Math.min(input.volume24h / input.liquidityUsd, 1.5) / 1.5
|
||||
: 0;
|
||||
const slip10k = 1 - Math.min(slippageFor(10_000, input.liquidityUsd) / 0.05, 1);
|
||||
const progress =
|
||||
input.targetDepthUsd > 0
|
||||
? Math.min(input.tvlUsd / input.targetDepthUsd, 1)
|
||||
: 0;
|
||||
|
||||
const score =
|
||||
depth * 0.35 + slip10k * 0.3 + turnover * 0.2 + progress * 0.15;
|
||||
return Math.round(score * 100);
|
||||
}
|
||||
|
||||
export function healthLabel(score: number): { label: string; tone: "mint" | "violet" | "danger" } {
|
||||
if (score >= 70) return { label: "Healthy", tone: "mint" };
|
||||
if (score >= 40) return { label: "Building", tone: "violet" };
|
||||
return { label: "Needs depth", tone: "danger" };
|
||||
}
|
||||
21
apps/web/src/lib/motion.ts
Normal file
21
apps/web/src/lib/motion.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Entrance animation presets. When the document loads hidden (background tab,
|
||||
* screenshot bots) requestAnimationFrame is throttled and entrance animations
|
||||
* would leave content stuck at opacity 0, so they degrade to static rendering.
|
||||
*/
|
||||
export const motionEnabled =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
|
||||
const animated = motionEnabled;
|
||||
|
||||
export const fadeUp = animated
|
||||
? { initial: { opacity: 0, y: 24 }, animate: { opacity: 1, y: 0 } }
|
||||
: {};
|
||||
|
||||
export const inView = animated
|
||||
? {
|
||||
initial: { opacity: 0, y: 28 },
|
||||
whileInView: { opacity: 1, y: 0 },
|
||||
viewport: { once: true, margin: "-60px" },
|
||||
}
|
||||
: {};
|
||||
22
apps/web/src/lib/points.ts
Normal file
22
apps/web/src/lib/points.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Campaign points, computed client-side from onchain position data.
|
||||
* 10 points per dollar-day, 1.5x for boosted (early window) deposits.
|
||||
* Points feed future programs; they carry no monetary value.
|
||||
*/
|
||||
export const GENESIS_LP_CUTOFF = 100;
|
||||
|
||||
export function campaignPoints(opts: {
|
||||
amountUsd: number;
|
||||
depositedAt: number;
|
||||
boosted: boolean;
|
||||
nowSec?: number;
|
||||
}): number {
|
||||
if (opts.amountUsd <= 0 || opts.depositedAt <= 0) return 0;
|
||||
const now = opts.nowSec ?? Math.floor(Date.now() / 1000);
|
||||
const days = Math.max(now - opts.depositedAt, 0) / 86_400;
|
||||
return opts.amountUsd * days * 10 * (opts.boosted ? 1.5 : 1);
|
||||
}
|
||||
|
||||
export function isGenesisLp(lpIndex: number): boolean {
|
||||
return lpIndex >= 1 && lpIndex <= GENESIS_LP_CUTOFF;
|
||||
}
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
50
apps/web/src/lib/wagmi.ts
Normal file
50
apps/web/src/lib/wagmi.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { createAppKit } from "@reown/appkit/react";
|
||||
import { WagmiAdapter } from "@reown/appkit-adapter-wagmi";
|
||||
import type { AppKitNetwork } from "@reown/appkit/networks";
|
||||
import { anvil } from "viem/chains";
|
||||
import { deployChain, robinhoodTestnet } from "./chain";
|
||||
|
||||
// Reown Cloud project id: replace with the real one before release
|
||||
const projectId = "b56e18d47c72ab683b10814fe9495694";
|
||||
|
||||
const networks = (
|
||||
import.meta.env.DEV
|
||||
? [anvil, robinhoodTestnet, deployChain]
|
||||
: [robinhoodTestnet, deployChain]
|
||||
) as unknown as [AppKitNetwork, ...AppKitNetwork[]];
|
||||
|
||||
const wagmiAdapter = new WagmiAdapter({
|
||||
networks,
|
||||
projectId,
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
createAppKit({
|
||||
adapters: [wagmiAdapter],
|
||||
networks,
|
||||
defaultNetwork: (import.meta.env.DEV
|
||||
? anvil
|
||||
: robinhoodTestnet) as unknown as AppKitNetwork,
|
||||
projectId,
|
||||
metadata: {
|
||||
name: "Hoodquidity",
|
||||
description: "Liquidity campaigns for Stock Token markets on Robinhood Chain",
|
||||
url: typeof window !== "undefined" ? window.location.origin : "https://hoodquidity.xyz",
|
||||
icons: ["/favicon.ico"],
|
||||
},
|
||||
themeMode: "dark",
|
||||
themeVariables: {
|
||||
"--w3m-accent": "#c8ff00",
|
||||
"--w3m-color-mix": "#080808",
|
||||
"--w3m-color-mix-strength": 30,
|
||||
"--w3m-border-radius-master": "0px",
|
||||
"--w3m-font-family": "'JetBrains Mono', ui-monospace, monospace",
|
||||
},
|
||||
features: {
|
||||
analytics: false,
|
||||
email: false,
|
||||
socials: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const wagmiConfig = wagmiAdapter.wagmiConfig;
|
||||
28
apps/web/src/main.tsx
Normal file
28
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { WagmiProvider } from "wagmi";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { wagmiConfig } from "./lib/wagmi";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<WagmiProvider config={wagmiConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
589
apps/web/src/screens/AppBoard.tsx
Normal file
589
apps/web/src/screens/AppBoard.tsx
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parseUnits } from "viem";
|
||||
import {
|
||||
useAccount,
|
||||
useReadContracts,
|
||||
useWaitForTransactionReceipt,
|
||||
useWriteContract,
|
||||
} from "wagmi";
|
||||
import { useAppKit } from "@reown/appkit/react";
|
||||
import { hoodquidityVaultsAbi } from "@/lib/abi/HoodquidityVaults";
|
||||
import { mockERC20Abi } from "@/lib/abi/MockERC20";
|
||||
import { ACTIVE_CHAIN_ID, getActiveDeployment, USD_DECIMALS } from "@/lib/contracts";
|
||||
import { useCampaigns, usePositions, type CampaignState } from "@/hooks/useCampaigns";
|
||||
import { useAllMarketData } from "@/hooks/useMarketData";
|
||||
import { feeApr, rewardApr } from "@/lib/math";
|
||||
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DepthProgress } from "@/components/DepthProgress";
|
||||
import { SlippageCurve } from "@/components/SlippageCurve";
|
||||
|
||||
const RISKS = ["All", "Low", "Medium", "High"] as const;
|
||||
const CHIP_AMOUNTS = [1_000, 5_000, 10_000];
|
||||
|
||||
function statusBadge(c: CampaignState): { label: string; tone: "mint" | "lime" | "fog" } {
|
||||
if (c.status === "boost") return { label: "Boost 1.5x", tone: "lime" };
|
||||
if (c.status === "ended") return { label: "Ended", tone: "fog" };
|
||||
if (c.status === "upcoming") return { label: "Upcoming", tone: "fog" };
|
||||
return { label: "Active", tone: "mint" };
|
||||
}
|
||||
|
||||
export default function AppBoard() {
|
||||
const { address, isConnected } = useAccount();
|
||||
const { open: openConnectModal } = useAppKit();
|
||||
const deployment = getActiveDeployment();
|
||||
const { campaigns, isLoading } = useCampaigns();
|
||||
const { data: pairs } = useAllMarketData(deployment.markets);
|
||||
const { positions, refetch: refetchPositions } = usePositions();
|
||||
|
||||
const [tab, setTab] = useState<"campaigns" | "portfolio">("campaigns");
|
||||
const [risk, setRisk] = useState<(typeof RISKS)[number]>("All");
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [toast, setToast] = useState("");
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const say = (m: string) => {
|
||||
clearTimeout(toastTimer.current);
|
||||
setToast(m);
|
||||
toastTimer.current = setTimeout(() => setToast(""), 3_400);
|
||||
};
|
||||
useEffect(() => () => clearTimeout(toastTimer.current), []);
|
||||
|
||||
const list = (campaigns ?? []).filter(
|
||||
(c) => risk === "All" || c.meta.risk === risk,
|
||||
);
|
||||
const selected =
|
||||
list.find((c) => c.meta.campaignId === selectedId) ??
|
||||
(campaigns ?? []).find((c) => c.meta.campaignId === selectedId) ??
|
||||
list[0];
|
||||
|
||||
const amount = useMemo(() => {
|
||||
const n = Number(input);
|
||||
if (!Number.isFinite(n) || n <= 0) return 0n;
|
||||
try {
|
||||
return parseUnits(input, USD_DECIMALS);
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}, [input]);
|
||||
const amountUsd = Number(input) || 0;
|
||||
|
||||
const { data: walletData, refetch: refetchWallet } = useReadContracts({
|
||||
contracts: [
|
||||
{
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: [address ?? "0x0000000000000000000000000000000000000000"],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
{
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "allowance",
|
||||
args: [address ?? "0x0000000000000000000000000000000000000000", deployment.vaults],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
},
|
||||
],
|
||||
query: { enabled: !!address },
|
||||
});
|
||||
const balance = (walletData?.[0]?.result as bigint | undefined) ?? 0n;
|
||||
const allowance = (walletData?.[1]?.result as bigint | undefined) ?? 0n;
|
||||
const balanceUsd = Number(balance) / 10 ** USD_DECIMALS;
|
||||
|
||||
const { writeContract, data: txHash, isPending, reset } = useWriteContract({
|
||||
mutation: { onError: (e) => say(`tx rejected · ${e.name}`) },
|
||||
});
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
||||
hash: txHash,
|
||||
});
|
||||
const lastAction = useRef("");
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return;
|
||||
reset();
|
||||
refetchWallet();
|
||||
refetchPositions();
|
||||
if (lastAction.current) say(lastAction.current);
|
||||
lastAction.current = "";
|
||||
}, [isSuccess, reset, refetchWallet, refetchPositions]);
|
||||
|
||||
const busy = isPending || isConfirming;
|
||||
const needsFaucet = amount > 0n && balance < amount;
|
||||
const needsApprove = !needsFaucet && amount > 0n && allowance < amount;
|
||||
|
||||
const selPair = selected ? pairs?.[selected.meta.symbol] : undefined;
|
||||
const selFee = selPair ? feeApr(selPair.volume24h, selPair.liquidityUsd) : 0;
|
||||
const selReward = selected
|
||||
? rewardApr(selected.rewardBudgetUsd, selected.durationDays, selected.tvlUsd)
|
||||
: 0;
|
||||
const selApr = selFee + selReward;
|
||||
|
||||
const act = () => {
|
||||
if (!selected) return;
|
||||
if (!isConnected) return openConnectModal?.();
|
||||
if (amount === 0n) return say("enter an amount to deposit");
|
||||
if (needsFaucet) {
|
||||
lastAction.current = "test hUSD minted to wallet";
|
||||
writeContract({
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "faucet",
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else if (needsApprove) {
|
||||
lastAction.current = `approved ${fmtUsd(amountUsd)} hUSD`;
|
||||
writeContract({
|
||||
address: deployment.usd,
|
||||
abi: mockERC20Abi,
|
||||
functionName: "approve",
|
||||
args: [deployment.vaults, amount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
} else {
|
||||
lastAction.current = `deposited ${fmtUsd(amountUsd)} -> ${selected.meta.symbol} / hUSD`;
|
||||
setInput("");
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "deposit",
|
||||
args: [BigInt(selected.meta.campaignId), amount],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const claim = (campaignId: number) => {
|
||||
lastAction.current = "rewards claimed -> wallet";
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "claim",
|
||||
args: [BigInt(campaignId)],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
};
|
||||
|
||||
const withdraw = (campaignId: number, posAmountUsd: number) => {
|
||||
lastAction.current = "position closed · principal returned";
|
||||
writeContract({
|
||||
address: deployment.vaults,
|
||||
abi: hoodquidityVaultsAbi,
|
||||
functionName: "withdraw",
|
||||
args: [BigInt(campaignId), parseUnits(posAmountUsd.toString(), USD_DECIMALS)],
|
||||
chainId: ACTIVE_CHAIN_ID,
|
||||
});
|
||||
};
|
||||
|
||||
const openPositions = positions.filter((p) => p.amountUsd > 0);
|
||||
const totalDeposited = openPositions.reduce((s, p) => s + p.amountUsd, 0);
|
||||
const totalPending = openPositions.reduce((s, p) => s + p.pendingUsd, 0);
|
||||
|
||||
const primaryLabel = !isConnected
|
||||
? "Connect wallet"
|
||||
: amount === 0n
|
||||
? "Enter amount"
|
||||
: needsFaucet
|
||||
? "Get test hUSD"
|
||||
: needsApprove
|
||||
? "Approve hUSD"
|
||||
: "Deposit hUSD";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative mx-auto max-w-7xl px-4 pb-24 pt-10 md:px-8">
|
||||
{/* desk locator */}
|
||||
<div className="mb-6 flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim">
|
||||
<span>Desk 00 // Open campaigns</span>
|
||||
<span className="hidden items-center gap-2 sm:inline-flex">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
|
||||
Live feed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* tab bar */}
|
||||
<div className="mb-8 flex items-center gap-6 border-b border-edge">
|
||||
{(["campaigns", "portfolio"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"-mb-px cursor-pointer border-b-2 bg-transparent px-0.5 pb-3 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors",
|
||||
tab === t
|
||||
? "border-mint text-mint"
|
||||
: "border-transparent text-fog hover:text-snow",
|
||||
)}
|
||||
>
|
||||
{t === "campaigns"
|
||||
? "Campaigns"
|
||||
: `Portfolio${openPositions.length ? ` (${openPositions.length})` : ""}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "campaigns" && (
|
||||
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px]">
|
||||
{/* board */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="slabel">Campaign board</div>
|
||||
<h1 className="mt-2 text-3xl md:text-4xl">Open campaigns</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{RISKS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRisk(r)}
|
||||
className={cn(
|
||||
"cursor-pointer border px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors",
|
||||
risk === r
|
||||
? "border-mint bg-mint text-void"
|
||||
: "border-edge bg-transparent text-fog hover:border-mint hover:text-mint",
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ledger: shared column grid, comparison over cards */}
|
||||
<div className="border border-edge bg-card/60">
|
||||
<div className="hidden grid-cols-[1.5fr_1.1fr_0.55fr_0.6fr_0.55fr] gap-x-5 border-b border-edge px-5 py-2.5 font-mono text-[9.5px] uppercase tracking-[0.14em] text-fog-dim md:grid">
|
||||
<span>Market</span>
|
||||
<span>Depth fill</span>
|
||||
<span className="text-right">Ends</span>
|
||||
<span className="text-right">24h vol</span>
|
||||
<span className="text-right">Est. APR</span>
|
||||
</div>
|
||||
{isLoading &&
|
||||
Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="m-3 h-16" />
|
||||
))}
|
||||
{list.map((c) => {
|
||||
const pair = pairs?.[c.meta.symbol];
|
||||
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
|
||||
const apr =
|
||||
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
|
||||
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
|
||||
const badge = statusBadge(c);
|
||||
const on = selected?.meta.campaignId === c.meta.campaignId;
|
||||
return (
|
||||
<div
|
||||
key={c.meta.campaignId}
|
||||
onClick={() => setSelectedId(c.meta.campaignId)}
|
||||
className={cn(
|
||||
"relative grid cursor-pointer grid-cols-2 items-center gap-x-5 gap-y-3 border-b border-edge px-5 py-4 transition-colors duration-150 last:border-b-0 md:grid-cols-[1.5fr_1.1fr_0.55fr_0.6fr_0.55fr]",
|
||||
on ? "bg-card-hover" : "hover:bg-card-hover/60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 top-0 w-[2px] bg-mint shadow-[0_0_14px_rgba(200,255,0,0.6)] transition-transform duration-150",
|
||||
on ? "scale-y-100" : "scale-y-0",
|
||||
)}
|
||||
/>
|
||||
<div className="col-span-2 flex items-center gap-3 md:col-span-1">
|
||||
<span className="font-display text-base uppercase leading-none">
|
||||
{c.meta.symbol}{" "}
|
||||
<span className="font-mono text-[13px] normal-case tracking-normal text-fog-dim">
|
||||
/ hUSD
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[9px] uppercase tracking-[0.12em]",
|
||||
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
|
||||
)}
|
||||
>
|
||||
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
|
||||
</span>
|
||||
{c.status !== "active" && (
|
||||
<Badge tone={badge.tone} pulse={c.status === "boost"}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<DepthProgress
|
||||
progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0}
|
||||
/>
|
||||
<div className="num mt-1.5 font-mono text-[10px] tracking-[0.04em] text-fog">
|
||||
{fmtUsd(depth)}
|
||||
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="num text-right font-mono text-[11px] text-fog">
|
||||
{fmtCountdown(c.end)}
|
||||
</span>
|
||||
<span className="num text-right font-mono text-[11px] text-fog">
|
||||
{pair ? fmtUsd(pair.volume24h) : "···"}
|
||||
</span>
|
||||
<span className="num text-right text-[15px] font-semibold text-mint">
|
||||
{fmtAprPct(apr)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
|
||||
Estimated APR is not guaranteed. Rewards subject to campaign terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* deposit sidebar */}
|
||||
{selected && (
|
||||
<div className="flex flex-col gap-4 lg:sticky lg:top-24">
|
||||
<div
|
||||
className={cn(
|
||||
"ticks border border-edge-bright bg-card p-6",
|
||||
amountUsd > 0 && "ticks-live",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="slabel">Deposit</span>
|
||||
<Badge
|
||||
tone={statusBadge(selected).tone}
|
||||
pulse={selected.status === "boost"}
|
||||
>
|
||||
{statusBadge(selected).label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 font-display text-2xl uppercase">
|
||||
{selected.meta.symbol}{" "}
|
||||
<span className="font-mono text-lg normal-case tracking-normal text-fog-dim">
|
||||
/ hUSD
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-[13px]">
|
||||
<div className="leader">
|
||||
<span className="text-fog">Fee APR</span>
|
||||
<span className="num">{fmtAprPct(selFee)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Reward APR</span>
|
||||
<span className="num">{fmtAprPct(selReward)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Boost</span>
|
||||
<span className="num">
|
||||
{selected.status === "boost"
|
||||
? `${(selected.boostBps / 10_000).toFixed(1)}x`
|
||||
: "none"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Est. total APR</span>
|
||||
<span className="num font-semibold text-mint">{fmtAprPct(selApr)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Campaign ends</span>
|
||||
<span className="num">{fmtCountdown(selected.end)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-2.5">
|
||||
<div className="flex justify-between font-mono text-[10px] uppercase tracking-[0.08em]">
|
||||
<span className="text-fog">Amount</span>
|
||||
<button
|
||||
className="num cursor-pointer text-fog-dim transition-colors hover:text-mint"
|
||||
onClick={() => setInput(balanceUsd > 0 ? String(balanceUsd) : "")}
|
||||
>
|
||||
Balance: {fmtUsd(balanceUsd)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border border-edge-bright bg-surface py-1 pl-1 pr-3.5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value.replace(/[^0-9.]/g, ""))}
|
||||
placeholder="0.00"
|
||||
inputMode="decimal"
|
||||
className="num min-w-0 flex-1 bg-transparent p-3 text-xl text-snow outline-none placeholder:text-fog-dim"
|
||||
/>
|
||||
<span className="font-mono text-xs tracking-[0.06em] text-fog">hUSD</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{CHIP_AMOUNTS.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setInput(String(v))}
|
||||
className="num flex-1 cursor-pointer border border-edge bg-transparent py-1.5 font-mono text-[11px] text-fog transition-colors hover:border-mint hover:text-mint"
|
||||
>
|
||||
{v.toLocaleString("en-US")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setInput(balanceUsd > 0 ? String(balanceUsd) : "")}
|
||||
className="flex-1 cursor-pointer border border-edge bg-transparent py-1.5 font-mono text-[11px] text-fog transition-colors hover:border-mint hover:text-mint"
|
||||
>
|
||||
MAX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 border-t border-edge pt-4 text-[13px]">
|
||||
<div className="leader">
|
||||
<span className="text-fog">Est. 30d fees</span>
|
||||
<span className="num">{fmtUsd((amountUsd * selFee) / 12)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Est. 30d rewards</span>
|
||||
<span className="num">{fmtUsd((amountUsd * selReward) / 12)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Est. 30d total</span>
|
||||
<span className="num font-semibold text-mint">
|
||||
{fmtUsd((amountUsd * selApr) / 12)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-5 w-full"
|
||||
size="lg"
|
||||
disabled={busy || selected.status === "ended"}
|
||||
onClick={act}
|
||||
>
|
||||
{busy ? "Confirming..." : primaryLabel}
|
||||
</Button>
|
||||
<p className="mt-3 font-mono text-[9.5px] leading-relaxed tracking-[0.04em] text-fog-dim">
|
||||
Single-sided deposit. The Depth Vault forms the LP position for you.
|
||||
Exit any time and keep fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"ticks border border-edge bg-card p-5",
|
||||
amountUsd > 0 && "ticks-live",
|
||||
)}
|
||||
>
|
||||
<div className="slabel mb-3">Execution preview</div>
|
||||
<SlippageCurve
|
||||
liquidityUsd={(selPair?.liquidityUsd ?? 0) + selected.tvlUsd + amountUsd}
|
||||
/>
|
||||
<div className="fig mt-3 border-t border-edge pt-2.5">
|
||||
Fig. 02 // Slippage by trade size //{" "}
|
||||
{amountUsd > 0 ? "Includes your deposit" : "Current book"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "portfolio" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<div className="slabel">Portfolio</div>
|
||||
<h1 className="mt-2 text-3xl md:text-4xl">Your positions</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"ticks grid grid-cols-2 gap-7 border border-edge bg-card p-6 md:grid-cols-4",
|
||||
totalDeposited > 0 && "ticks-live",
|
||||
)}
|
||||
>
|
||||
<Stat label="Total deposited" value={fmtUsd(totalDeposited)} accent />
|
||||
<Stat label="Claimable rewards" value={fmtUsd(totalPending)} />
|
||||
<Stat label="Open positions" value={String(openPositions.length)} />
|
||||
<Stat label="Wallet balance" value={fmtUsd(balanceUsd)} />
|
||||
</div>
|
||||
|
||||
{openPositions.length > 0 ? (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{openPositions.map((p) => {
|
||||
const c = campaigns?.find(
|
||||
(x) => x.meta.campaignId === p.meta.campaignId,
|
||||
);
|
||||
const pair = pairs?.[p.meta.symbol];
|
||||
const apr = c
|
||||
? (pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
|
||||
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={p.meta.campaignId}
|
||||
className="ticks flex flex-wrap items-center justify-between gap-5 border border-edge-bright bg-card px-5 py-5"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-display text-lg uppercase">
|
||||
{p.meta.symbol} <span className="font-mono text-[0.75em] font-normal normal-case tracking-normal text-fog-dim">/ hUSD</span>
|
||||
</span>
|
||||
<span className="num font-mono text-[10px] tracking-[0.06em] text-fog-dim">
|
||||
LP receipt hqLP-{p.meta.campaignId}
|
||||
{c ? ` · ends in ${fmtCountdown(c.end)}` : ""}
|
||||
{p.boosted ? " · 1.5x boosted" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-7 font-mono text-[11px] tracking-[0.05em]">
|
||||
<span className="flex flex-col gap-1 text-fog">
|
||||
Deposited
|
||||
<span className="num text-[15px] text-snow">
|
||||
{fmtUsd(p.amountUsd)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-1 text-fog">
|
||||
Est. APR
|
||||
<span className="num text-[15px] text-snow">{fmtAprPct(apr)}</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-1 text-fog">
|
||||
Rewards accrued
|
||||
<span className="num text-[15px] font-semibold text-mint">
|
||||
{fmtUsd(p.pendingUsd)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || (c?.status !== "ended" && p.pendingUsd === 0)}
|
||||
onClick={() => claim(p.meta.campaignId)}
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => withdraw(p.meta.campaignId, p.amountUsd)}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ticks flex flex-col items-center gap-4 border border-dashed border-edge-bright bg-card px-6 py-16 text-center">
|
||||
<span className="font-display text-2xl uppercase">No positions yet</span>
|
||||
<p className="max-w-sm text-[13px] leading-relaxed text-fog">
|
||||
Pick a campaign, deposit hUSD and your LP receipt will show up here
|
||||
with live earnings.
|
||||
</p>
|
||||
<Button onClick={() => setTab("campaigns")}>Browse campaigns</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-7 left-1/2 z-40 flex -translate-x-1/2 items-center gap-2.5 border border-mint bg-card px-5 py-3 font-mono text-xs tracking-[0.04em] text-snow shadow-[0_0_28px_rgba(200,255,0,0.22)]">
|
||||
<span className="text-mint">></span>
|
||||
<span className="num">{toast}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/screens/CampaignDetail.tsx
Normal file
184
apps/web/src/screens/CampaignDetail.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import { useCampaigns, usePosition } from "@/hooks/useCampaigns";
|
||||
import { useMarketData } from "@/hooks/useMarketData";
|
||||
import { DepositPanel } from "@/components/DepositPanel";
|
||||
import { PositionPanel } from "@/components/PositionPanel";
|
||||
import { DepthProgress } from "@/components/DepthProgress";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { feeApr, rewardApr, slippageFor } from "@/lib/math";
|
||||
import { fmtUsd, fmtAprPct, fmtPct, fmtCountdown } from "@/lib/format";
|
||||
|
||||
export default function CampaignDetail() {
|
||||
const { id } = useParams();
|
||||
const campaignId = Number(id);
|
||||
const { campaigns, isLoading } = useCampaigns();
|
||||
const campaign = campaigns?.find((c) => c.meta.campaignId === campaignId);
|
||||
const { data: pair } = useMarketData(
|
||||
campaign?.meta ?? {
|
||||
symbol: "",
|
||||
name: "",
|
||||
campaignId: 0,
|
||||
stockToken: "0x0000000000000000000000000000000000000000",
|
||||
priceFeed: "0x0000000000000000000000000000000000000000",
|
||||
dexscreenerQuery: "NVDAx",
|
||||
risk: "Low",
|
||||
},
|
||||
);
|
||||
const { position, refetch } = usePosition(campaignId);
|
||||
|
||||
if (isLoading || !campaign) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-3">
|
||||
<Skeleton className="h-96 lg:col-span-2" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const marketDepth = (pair?.liquidityUsd ?? 0) + campaign.tvlUsd;
|
||||
const progress = campaign.targetDepthUsd > 0 ? marketDepth / campaign.targetDepthUsd : 0;
|
||||
const fApr = pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0;
|
||||
const rApr = rewardApr(campaign.rewardBudgetUsd, campaign.durationDays, campaign.tvlUsd);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
|
||||
<div className="mb-6 flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim">
|
||||
<span>
|
||||
Desk 00 // Campaign 0{campaign.meta.campaignId} // {campaign.meta.symbol}
|
||||
</span>
|
||||
<span className="hidden items-center gap-2 sm:inline-flex">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
|
||||
Live feed
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/campaigns" className="font-mono text-xs text-fog transition-colors hover:text-mint">
|
||||
← All campaigns
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||
<h1 className="text-4xl md:text-6xl">
|
||||
{campaign.meta.symbol}
|
||||
<span className="font-mono text-[0.6em] font-normal normal-case tracking-normal text-fog-dim"> / hUSD</span>
|
||||
</h1>
|
||||
<Badge
|
||||
tone={campaign.status === "ended" ? "fog" : campaign.status === "boost" ? "lime" : "mint"}
|
||||
pulse={campaign.status === "active" || campaign.status === "boost"}
|
||||
>
|
||||
{campaign.status === "boost"
|
||||
? "Boost window open"
|
||||
: campaign.status === "active"
|
||||
? "Active"
|
||||
: campaign.status === "upcoming"
|
||||
? "Upcoming"
|
||||
: "Ended"}
|
||||
</Badge>
|
||||
<Badge tone={campaign.meta.risk === "Low" ? "mint" : campaign.meta.risk === "Medium" ? "fog" : "danger"}>
|
||||
{campaign.meta.risk} risk
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-fog">
|
||||
Depth campaign for the {campaign.meta.name} market. Deposits flow into
|
||||
the Depth Vault and count toward the campaign target.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-5 lg:grid-cols-3">
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<Card className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
||||
<Stat
|
||||
label="Token price"
|
||||
value={pair ? `$${pair.priceUsd.toFixed(2)}` : "..."}
|
||||
hint={pair ? `${pair.change24h >= 0 ? "+" : ""}${pair.change24h.toFixed(2)}% 24h` : undefined}
|
||||
/>
|
||||
<Stat label="24h volume" value={pair ? fmtUsd(pair.volume24h) : "..."} />
|
||||
<Stat label="Market depth" value={fmtUsd(marketDepth)} accent />
|
||||
<Stat
|
||||
label="Slippage $10k"
|
||||
value={pair ? fmtPct(slippageFor(10_000, marketDepth)) : "..."}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 border-t border-edge pt-4">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="fig">Fig. 01 // Campaign progress</span>
|
||||
<span className="num text-fog">
|
||||
{fmtUsd(marketDepth)} / {fmtUsd(campaign.targetDepthUsd)} target
|
||||
</span>
|
||||
</div>
|
||||
<DepthProgress progress={progress} segments={48} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="slabel mb-4">Campaign terms</div>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||
<div className="leader">
|
||||
<span className="text-fog">Fee APR estimate</span>
|
||||
<span className="num font-semibold">{fmtAprPct(fApr)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Reward APR estimate</span>
|
||||
<span className="num font-semibold text-mint">{fmtAprPct(rApr)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Reward budget</span>
|
||||
<span className="num font-semibold">{fmtUsd(campaign.rewardBudgetUsd)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Duration</span>
|
||||
<span className="num font-semibold">{Math.round(campaign.durationDays)} days</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Early boost</span>
|
||||
<span className="num font-semibold text-lime">
|
||||
{(campaign.boostBps / 10_000).toFixed(1)}x first {Math.round(campaign.boostWindow / 3_600)}h
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">
|
||||
{campaign.status === "ended" ? "Status" : "Ends in"}
|
||||
</span>
|
||||
<span className="num font-semibold">
|
||||
{campaign.status === "ended" ? "Closed" : fmtCountdown(campaign.end)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Exit model</span>
|
||||
<span className="font-semibold">Hybrid</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Deposit assets</span>
|
||||
<span className="font-semibold">hUSD or dual</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Platform fee</span>
|
||||
<span className="num font-semibold">10% of rewards</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Active LPs</span>
|
||||
<span className="num font-semibold">{campaign.activeLPs}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<div className="slabel mb-4">Depth vault</div>
|
||||
<DepositPanel campaign={campaign} pair={pair} onSettled={refetch} />
|
||||
</Card>
|
||||
{position && position.amountUsd > 0 && (
|
||||
<Card>
|
||||
<PositionPanel campaign={campaign} position={position} onSettled={refetch} />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
apps/web/src/screens/Campaigns.tsx
Normal file
180
apps/web/src/screens/Campaigns.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useCampaigns, type CampaignState } from "@/hooks/useCampaigns";
|
||||
import { useAllMarketData } from "@/hooks/useMarketData";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DepthProgress } from "@/components/DepthProgress";
|
||||
import { feeApr, rewardApr } from "@/lib/math";
|
||||
import { fmtUsd, fmtAprPct, fmtCountdown } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function statusLabel(c: CampaignState): { label: string; tone: "mint" | "lime" | "fog" } | null {
|
||||
if (c.status === "boost") return { label: "Boost 1.5x", tone: "lime" };
|
||||
if (c.status === "ended") return { label: "Ended", tone: "fog" };
|
||||
if (c.status === "upcoming") return { label: "Upcoming", tone: "fog" };
|
||||
return null; // active is the default state, the ledger stays quiet about it
|
||||
}
|
||||
|
||||
export default function Campaigns() {
|
||||
const { campaigns, isLoading, deployment } = useCampaigns();
|
||||
const { data: pairs } = useAllMarketData(deployment.markets);
|
||||
|
||||
const totalTvl = campaigns?.reduce((s, c) => s + c.tvlUsd, 0) ?? 0;
|
||||
const totalRewards = campaigns?.reduce((s, c) => s + c.rewardBudgetUsd, 0) ?? 0;
|
||||
const active = campaigns?.filter((c) => c.status === "active" || c.status === "boost").length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="slabel">Campaigns</div>
|
||||
<h1 className="mt-3 text-4xl md:text-6xl">
|
||||
Markets that need depth
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-fog">
|
||||
Every campaign targets one Stock Token market. Deposit hUSD, receive
|
||||
an LP receipt and earn a share of rewards while the market gets
|
||||
deeper.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-8 border-l border-edge pl-6">
|
||||
<div>
|
||||
<div className="num text-2xl font-semibold text-mint">{fmtUsd(totalTvl)}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Vault TVL</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-2xl font-semibold">{active}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Active</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="num text-2xl font-semibold">{fmtUsd(totalRewards)}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-fog-dim">Rewards pool</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-4 border border-edge bg-surface px-5 py-4">
|
||||
<p className="text-sm text-fog">
|
||||
Running a protocol or market making desk, want your market deeper
|
||||
</p>
|
||||
<Link to="/partners" className="shrink-0">
|
||||
<Button variant="outline" size="sm">
|
||||
Launch a campaign
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ledger: one row per market, shared columns for comparison */}
|
||||
<div className="mt-10 border border-edge bg-card/60">
|
||||
<div className="hidden grid-cols-[1.6fr_0.9fr_1.2fr_0.65fr_0.6fr_0.7fr_0.3fr] gap-x-5 border-b border-edge px-5 py-2.5 font-mono text-[9.5px] uppercase tracking-[0.14em] text-fog-dim lg:grid">
|
||||
<span>Market</span>
|
||||
<span className="text-right">Price / 24h</span>
|
||||
<span>Depth fill</span>
|
||||
<span className="text-right">Vault TVL</span>
|
||||
<span className="text-right">Ends</span>
|
||||
<span className="text-right">Est. APR</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{isLoading || !campaigns
|
||||
? Array.from({ length: 5 }, (_, i) => <Skeleton key={i} className="m-3 h-16" />)
|
||||
: campaigns.map((c, i) => {
|
||||
const pair = pairs?.[c.meta.symbol];
|
||||
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
|
||||
const apr =
|
||||
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
|
||||
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
|
||||
const status = statusLabel(c);
|
||||
return (
|
||||
<Link
|
||||
key={c.meta.campaignId}
|
||||
to={`/campaigns/${c.meta.campaignId}`}
|
||||
className="group relative grid grid-cols-2 items-center gap-x-5 gap-y-3 border-b border-edge px-5 py-5 transition-colors duration-150 last:border-b-0 hover:bg-card-hover lg:grid-cols-[1.6fr_0.9fr_1.2fr_0.65fr_0.6fr_0.7fr_0.3fr]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 top-0 w-[2px] origin-bottom scale-y-0 bg-mint shadow-[0_0_14px_rgba(200,255,0,0.6)] transition-transform duration-200 ease-out group-hover:scale-y-100"
|
||||
/>
|
||||
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="num font-mono text-[10px] text-fog-dim">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span className="font-display text-lg uppercase leading-none">
|
||||
{c.meta.symbol}{" "}
|
||||
<span className="font-mono text-[13px] normal-case tracking-normal text-fog-dim">
|
||||
/ hUSD
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[9px] uppercase tracking-[0.12em]",
|
||||
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
|
||||
)}
|
||||
>
|
||||
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
|
||||
</span>
|
||||
{status && (
|
||||
<Badge tone={status.tone} pulse={c.status === "boost"}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pl-7 text-[11px] text-fog-dim">{c.meta.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="num text-sm text-snow">
|
||||
{pair ? `$${pair.priceUsd.toFixed(2)}` : "···"}
|
||||
</div>
|
||||
{pair && (
|
||||
<div
|
||||
className={cn(
|
||||
"num text-[11px]",
|
||||
pair.change24h >= 0 ? "text-mint" : "text-danger",
|
||||
)}
|
||||
>
|
||||
{pair.change24h >= 0 ? "+" : ""}
|
||||
{pair.change24h.toFixed(2)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DepthProgress
|
||||
progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0}
|
||||
/>
|
||||
<div className="num mt-1.5 font-mono text-[10px] tracking-[0.04em] text-fog">
|
||||
{fmtUsd(depth)}
|
||||
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="num text-right font-mono text-[12px] text-snow">
|
||||
{fmtUsd(c.tvlUsd)}
|
||||
</span>
|
||||
|
||||
<span className="num text-right font-mono text-[12px] text-fog">
|
||||
{c.status === "ended" ? "Closed" : fmtCountdown(c.end)}
|
||||
</span>
|
||||
|
||||
<span className="num text-right text-[15px] font-semibold text-mint">
|
||||
{fmtAprPct(apr)}
|
||||
</span>
|
||||
|
||||
<span className="hidden text-right font-mono text-xs text-fog-dim transition-colors group-hover:text-mint lg:inline">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
|
||||
Estimated APR is not guaranteed. Rewards subject to campaign terms
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
516
apps/web/src/screens/Landing.tsx
Normal file
516
apps/web/src/screens/Landing.tsx
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DepthField } from "@/components/backdrop/DepthField";
|
||||
import { DepthChart } from "@/components/DepthChart";
|
||||
import { DepthProgress } from "@/components/DepthProgress";
|
||||
import { CountUp } from "@/components/CountUp";
|
||||
import { FootnoteMark, FootnoteLine } from "@/components/Footnote";
|
||||
import { Term } from "@/components/Term";
|
||||
import { Scramble } from "@/components/fx/Scramble";
|
||||
import { SideRail } from "@/components/fx/SideRail";
|
||||
import { useCampaigns } from "@/hooks/useCampaigns";
|
||||
import { useAllMarketData } from "@/hooks/useMarketData";
|
||||
import { fmtUsd, fmtAprPct } from "@/lib/format";
|
||||
import { feeApr, rewardApr } from "@/lib/math";
|
||||
import { inView, motionEnabled } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const HERO_LINES = [
|
||||
{ text: "Add liquidity", accent: false },
|
||||
{ text: "Earn fees", accent: true },
|
||||
{ text: "Make markets deeper", accent: false },
|
||||
];
|
||||
|
||||
const THESIS = [
|
||||
{
|
||||
n: "01",
|
||||
title: "For liquidity providers",
|
||||
text: "One-asset entry into the markets that pay for depth. Fees and campaign rewards accrue to your share of the vault, exit keeps the fees.",
|
||||
},
|
||||
{
|
||||
n: "02",
|
||||
title: "For traders",
|
||||
text: "Deeper books mean tighter spreads and less slippage on size. Execution quality is the product, not a promise.",
|
||||
},
|
||||
{
|
||||
n: "03",
|
||||
title: "For Robinhood Chain",
|
||||
text: "Organic depth routed to the pairs that need it, not mercenary TVL chasing emissions. Capital that stays after the incentives.",
|
||||
},
|
||||
];
|
||||
|
||||
function Folio({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.22em] text-fog-dim",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Faint architectural column lines with survey cross marks. */
|
||||
function ArchGrid() {
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 mx-auto max-w-7xl px-4 md:px-8">
|
||||
<div className="relative h-full">
|
||||
{[25, 50, 75].map((p) => (
|
||||
<div key={p}>
|
||||
<div
|
||||
className="absolute inset-y-0 hidden w-px bg-edge/50 lg:block"
|
||||
style={{ left: `${p}%` }}
|
||||
/>
|
||||
{[18, 82].map((y) => (
|
||||
<span
|
||||
key={y}
|
||||
className="absolute hidden -translate-x-1/2 -translate-y-1/2 font-mono text-[11px] text-fog-dim/60 lg:block"
|
||||
style={{ left: `${p}%`, top: `${y}%` }}
|
||||
>
|
||||
+
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Landing() {
|
||||
const { campaigns, deployment } = useCampaigns();
|
||||
const { data: pairs } = useAllMarketData(deployment.markets);
|
||||
|
||||
const vaultTvl = campaigns?.reduce((s, c) => s + c.tvlUsd, 0) ?? 0;
|
||||
const marketDepth =
|
||||
deployment.markets.reduce(
|
||||
(s, m) => s + (pairs?.[m.symbol]?.liquidityUsd ?? 0),
|
||||
0,
|
||||
) + vaultTvl;
|
||||
const activeCampaigns =
|
||||
campaigns?.filter((c) => c.status === "active" || c.status === "boost").length ?? 0;
|
||||
const volume24h = deployment.markets.reduce(
|
||||
(s, m) => s + (pairs?.[m.symbol]?.volume24h ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const boardCampaigns = (campaigns ?? []).slice(0, 3);
|
||||
|
||||
const STATS = [
|
||||
{ label: "Tracked market depth", value: marketDepth, accent: true },
|
||||
{ label: "24h market volume", value: volume24h },
|
||||
{ label: "Vault TVL", value: vaultTvl },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
{/* ---------------------------------- hero --------------------------------- */}
|
||||
<section className="relative border-b border-edge">
|
||||
<ArchGrid />
|
||||
<SideRail />
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(110% 70% at 50% -10%, rgba(200,255,0,0.07), transparent 60%), radial-gradient(60% 50% at 92% 55%, rgba(82,0,255,0.09), transparent 70%), radial-gradient(45% 40% at 4% 90%, rgba(200,255,0,0.05), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* folio bar */}
|
||||
<div className="relative mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 pt-7 md:px-8">
|
||||
<Folio>Hoodquidity / Depth Desk</Folio>
|
||||
<Folio className="hidden sm:inline">Robinhood Chain · L2 4663</Folio>
|
||||
<Folio className="inline-flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
|
||||
Live · Testnet
|
||||
</Folio>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 pt-14 md:px-8 md:pt-20">
|
||||
<div className="grid gap-10 lg:grid-cols-12">
|
||||
{/* headline */}
|
||||
<div className="lg:col-span-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Folio className="text-mint">[ 01 ]</Folio>
|
||||
<div className="h-px w-24 bg-edge" />
|
||||
<Folio>
|
||||
<Scramble text="Liquidity campaigns for Stock Tokens" />
|
||||
</Folio>
|
||||
<Folio className="hidden text-fog-dim/70 md:inline">// 流動性</Folio>
|
||||
</div>
|
||||
<h1
|
||||
className="mt-9 leading-[0.94]"
|
||||
style={{ fontSize: "clamp(2.4rem, 6vw, 5.25rem)" }}
|
||||
>
|
||||
{HERO_LINES.map((line, i) => (
|
||||
<span key={line.text} className="relative block pb-[0.06em]">
|
||||
<span
|
||||
className={cn(
|
||||
"block",
|
||||
line.accent && "neon text-mint",
|
||||
motionEnabled && "wipe-in",
|
||||
)}
|
||||
style={
|
||||
motionEnabled
|
||||
? { animationDelay: `${0.1 + i * 0.14}s` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
{motionEnabled && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="carriage absolute left-0 top-[0.12em] h-[0.72em] w-[0.5em] bg-mint"
|
||||
style={{ animationDelay: `${0.1 + i * 0.14}s` }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* meta column */}
|
||||
<div className="flex flex-col justify-end gap-7 pb-2 lg:col-span-4">
|
||||
<motion.p
|
||||
initial={motionEnabled ? { opacity: 0 } : false}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="certificate max-w-sm text-2xl leading-snug text-snow md:text-[1.7rem]"
|
||||
>
|
||||
Depth is the product
|
||||
<FootnoteMark n={1} live={marketDepth > 0} />
|
||||
</motion.p>
|
||||
<motion.p
|
||||
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.55 }}
|
||||
className="max-w-sm text-[15px] leading-relaxed text-fog"
|
||||
>
|
||||
Pick a market that needs depth, deposit stablecoins and the{" "}
|
||||
<Term
|
||||
def="A campaign vault that takes your stablecoin, forms the LP position for the target market and issues a receipt for your share."
|
||||
risk="Deposits are exposed to market moves. The position may lose value"
|
||||
>
|
||||
Depth Vault
|
||||
</Term>{" "}
|
||||
builds the position. Fees and rewards accrue to you while the
|
||||
market becomes tradable.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.65 }}
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
>
|
||||
<Link to="/app">
|
||||
<Button size="lg">Open the desk</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/markets"
|
||||
className="px-1 font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
Market depth →
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* instrument */}
|
||||
<motion.figure
|
||||
initial={motionEnabled ? { opacity: 0, y: 26 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative mt-16 border border-edge bg-surface/60 backdrop-blur-sm"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-6 top-10 select-none text-[9rem] font-semibold leading-none text-snow/[0.04] md:text-[13rem]"
|
||||
>
|
||||
深度
|
||||
</span>
|
||||
<figcaption className="flex flex-wrap items-baseline justify-between gap-3 border-b border-edge px-5 py-3.5 md:px-7">
|
||||
<span className="slabel">Cumulative book depth</span>
|
||||
<Folio className="inline-flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
|
||||
Live · Both sides
|
||||
</Folio>
|
||||
</figcaption>
|
||||
<div className="h-[300px] md:h-[380px]">
|
||||
<DepthChart totalDepthUsd={marketDepth} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-edge px-5 py-2.5 md:px-7">
|
||||
<span className="fig">Fig. 01 // Cumulative depth // Surface data</span>
|
||||
<FootnoteLine n={1} live={marketDepth > 0}>
|
||||
<span>
|
||||
DESK 00 <span className="text-fog-dim">//</span> TRACKED DEPTH{" "}
|
||||
<span className="text-mint">{fmtUsd(marketDepth)}</span>{" "}
|
||||
<span className="text-fog-dim">//</span> LIVE FEED
|
||||
</span>
|
||||
</FootnoteLine>
|
||||
</div>
|
||||
</motion.figure>
|
||||
|
||||
{/* stats rail */}
|
||||
<div className="grid grid-cols-1 gap-x-8 border-t border-edge sm:grid-cols-3">
|
||||
{STATS.map((s, i) => (
|
||||
<motion.div
|
||||
key={s.label}
|
||||
initial={motionEnabled ? { opacity: 0, y: 14 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.55, delay: 0.75 + i * 0.08 }}
|
||||
className={cn(
|
||||
"flex items-baseline justify-between gap-4 border-b border-edge py-5 sm:border-b-0",
|
||||
i > 0 && "sm:border-l sm:border-edge sm:pl-8",
|
||||
)}
|
||||
>
|
||||
<Folio>{s.label}</Folio>
|
||||
<span
|
||||
className={cn(
|
||||
"num text-2xl font-semibold md:text-3xl",
|
||||
s.accent ? "neon-num text-mint" : "text-snow",
|
||||
)}
|
||||
>
|
||||
<CountUp value={s.value} format={fmtUsd} />
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{/* survey micro-line */}
|
||||
<div className="flex items-center justify-between gap-4 pt-3 font-mono text-[9px] uppercase tracking-[0.2em] text-fog-dim/70">
|
||||
<span>▚▞▚▚ RH-L2 4663 // E-2026.07</span>
|
||||
<span className="hidden sm:inline">市場深度データ // Surface data</span>
|
||||
<span>+ 00 / 05 +</span>
|
||||
</div>
|
||||
|
||||
{/* LIMITS: Robinhood-grade compliance inside the first scroll */}
|
||||
<div className="mt-10 grid gap-x-8 gap-y-2 border border-edge bg-surface/50 px-5 py-4 md:grid-cols-[auto_1fr] md:px-7">
|
||||
<span className="fig pt-0.5 text-danger">Limits</span>
|
||||
<div className="grid gap-x-8 gap-y-1.5 font-mono text-[10px] uppercase tracking-[0.06em] text-fog sm:grid-cols-2">
|
||||
<span>01 // LP positions may lose value</span>
|
||||
<span>02 // Trading fees depend on volume</span>
|
||||
<span>03 // Rewards subject to campaign terms</span>
|
||||
<span>04 // Availability depends on jurisdiction</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-20" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------- campaigns ------------------------------- */}
|
||||
<section className="relative border-b border-edge">
|
||||
<ArchGrid />
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-24 md:px-8 md:py-28">
|
||||
<motion.div {...inView} transition={{ duration: 0.5 }} className="flex items-center gap-4">
|
||||
<Folio className="text-mint">[ 02 ]</Folio>
|
||||
<Folio className="hidden text-fog-dim/70 sm:inline">市場</Folio>
|
||||
<div className="h-px flex-1 bg-edge" />
|
||||
<Folio>{activeCampaigns} open now</Folio>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
{...inView}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
className="mt-9 flex flex-col gap-4 md:flex-row md:items-end md:justify-between"
|
||||
>
|
||||
<h2 style={{ fontSize: "clamp(2rem, 4.6vw, 3.5rem)" }} className="leading-[0.95]">
|
||||
Campaigns collecting depth
|
||||
</h2>
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
All campaigns →
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-14 border-t border-edge">
|
||||
{boardCampaigns.map((c, i) => {
|
||||
const pair = pairs?.[c.meta.symbol];
|
||||
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
|
||||
const apr =
|
||||
(pair ? feeApr(pair.volume24h, pair.liquidityUsd) : 0) +
|
||||
rewardApr(c.rewardBudgetUsd, c.durationDays, c.tvlUsd);
|
||||
return (
|
||||
<motion.div key={c.meta.campaignId} {...inView} transition={{ duration: 0.5, delay: i * 0.07 }}>
|
||||
<Link
|
||||
to={`/campaigns/${c.meta.campaignId}`}
|
||||
className="group relative grid grid-cols-2 items-center gap-x-6 gap-y-4 border-b border-edge py-7 pl-5 pr-2 transition-colors duration-300 hover:bg-card-hover md:grid-cols-12 md:py-8"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 top-0 w-[2px] origin-bottom scale-y-0 bg-mint transition-transform duration-300 ease-out group-hover:scale-y-100"
|
||||
/>
|
||||
<div className="col-span-2 flex items-baseline gap-5 md:col-span-4">
|
||||
<Folio className="text-fog-dim">0{i + 1}</Folio>
|
||||
<span className="font-display text-2xl uppercase leading-none md:text-3xl">
|
||||
{c.meta.symbol}
|
||||
<span className="font-mono text-base normal-case tracking-normal text-fog-dim md:text-lg">
|
||||
{" "}
|
||||
/ hUSD
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4">
|
||||
<div className="flex justify-between pb-2">
|
||||
<Folio>Depth</Folio>
|
||||
<span className="num text-[11px] text-fog">
|
||||
{fmtUsd(depth)}
|
||||
<span className="text-fog-dim"> / {fmtUsd(c.targetDepthUsd)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<DepthProgress progress={c.targetDepthUsd > 0 ? depth / c.targetDepthUsd : 0} />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Folio className="mb-1 block">24h volume</Folio>
|
||||
<span className="num text-sm text-snow">
|
||||
{pair ? fmtUsd(pair.volume24h) : "···"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right md:col-span-2">
|
||||
<Folio className="mb-1 block md:text-right">Est. APR</Folio>
|
||||
<span className="num text-xl font-semibold text-mint md:text-2xl">
|
||||
{fmtAprPct(apr)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 font-mono text-[10px] tracking-[0.05em] text-fog-dim">
|
||||
Estimated APR is not guaranteed. Rewards subject to campaign terms
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --------------------------------- thesis -------------------------------- */}
|
||||
<section className="relative border-b border-edge">
|
||||
<ArchGrid />
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-24 md:px-8 md:py-32">
|
||||
<div className="grid gap-14 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5">
|
||||
<motion.div {...inView} transition={{ duration: 0.5 }} className="flex items-center gap-4">
|
||||
<Folio className="text-mint">[ 03 ]</Folio>
|
||||
<div className="h-px w-24 bg-edge" />
|
||||
<Folio>
|
||||
<Scramble text="Thesis" interval={11000} />
|
||||
</Folio>
|
||||
<Folio className="text-fog-dim/70">// 論点</Folio>
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
{...inView}
|
||||
transition={{ duration: 0.55, delay: 0.05 }}
|
||||
className="super mt-9 leading-[0.94] lg:sticky lg:top-28"
|
||||
style={{ fontSize: "clamp(2rem, 4.2vw, 3.4rem)" }}
|
||||
>
|
||||
Thin books
|
||||
<br />
|
||||
tax every trade
|
||||
</motion.h2>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7">
|
||||
<motion.p
|
||||
{...inView}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="max-w-xl text-[15px] leading-relaxed text-fog"
|
||||
>
|
||||
Every shallow market quietly charges traders through slippage
|
||||
and keeps serious capital out. Hoodquidity turns that gap into
|
||||
an earning mechanic where the same liquidity works for three
|
||||
sides at once, and your{" "}
|
||||
<Term
|
||||
def="The onchain record of your deposit: your share of the vault, accrued rewards, boost status and exit terms."
|
||||
risk="Rewards unlock in full only after the campaign ends"
|
||||
>
|
||||
LP receipt
|
||||
</Term>{" "}
|
||||
tracks exactly what you carry.
|
||||
</motion.p>
|
||||
|
||||
<div className="mt-10">
|
||||
{THESIS.map((t, i) => (
|
||||
<motion.div
|
||||
key={t.n}
|
||||
{...inView}
|
||||
transition={{ duration: 0.5, delay: i * 0.08 }}
|
||||
className="grid grid-cols-[3rem_1fr] gap-6 border-t border-edge py-8 last:border-b"
|
||||
>
|
||||
<span className="num pt-1 font-mono text-sm text-fog-dim">{t.n}</span>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold leading-tight md:text-[1.35rem]">
|
||||
{t.title}
|
||||
</h3>
|
||||
<p className="mt-3 max-w-lg text-sm leading-relaxed text-fog">{t.text}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------- cta ---------------------------------- */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-25">
|
||||
<DepthField flip />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(80% 110% at 50% 120%, rgba(200,255,0,0.10), transparent 60%), radial-gradient(40% 55% at 88% 10%, rgba(82,0,255,0.08), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="neon-violet pointer-events-none absolute right-8 top-14 hidden select-none text-lg tracking-[0.4em] lg:block"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
>
|
||||
深く行け
|
||||
</span>
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-32 md:px-8 md:py-40">
|
||||
<Folio className="text-mint">
|
||||
<Scramble text="Be the liquidity behind tokenized markets" interval={12000} />
|
||||
</Folio>
|
||||
<motion.h2
|
||||
{...inView}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="super mt-7 max-w-5xl leading-[0.9]"
|
||||
style={{ fontSize: "clamp(2.5rem, 6.5vw, 5.25rem)" }}
|
||||
>
|
||||
Provide depth
|
||||
<br />
|
||||
<span className="text-mint">Get paid for it</span>
|
||||
</motion.h2>
|
||||
<motion.div
|
||||
{...inView}
|
||||
transition={{ duration: 0.5, delay: 0.12 }}
|
||||
className="mt-11 flex flex-wrap items-center gap-5"
|
||||
>
|
||||
<Link to="/app">
|
||||
<Button size="lg">Open the desk</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/campaigns"
|
||||
className="font-mono text-[11px] uppercase tracking-[0.14em] text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
Browse campaigns →
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
apps/web/src/screens/Markets.tsx
Normal file
153
apps/web/src/screens/Markets.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useCampaigns } from "@/hooks/useCampaigns";
|
||||
import { useAllMarketData } from "@/hooks/useMarketData";
|
||||
import { useChainStats } from "@/hooks/useChainStats";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HealthRadial } from "@/components/HealthRadial";
|
||||
import { SlippageCurve } from "@/components/SlippageCurve";
|
||||
import { DepthHistory } from "@/components/DepthHistory";
|
||||
import { rewardsRemainingUsd } from "@/hooks/useCampaigns";
|
||||
import { healthScore } from "@/lib/math";
|
||||
import { fmtUsd, fmtNum } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Markets() {
|
||||
const { campaigns, isLoading, deployment } = useCampaigns();
|
||||
const { data: pairs } = useAllMarketData(deployment.markets);
|
||||
const { data: stats } = useChainStats();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-14 md:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="slabel">Markets</div>
|
||||
<h1 className="mt-3 text-4xl md:text-6xl">Market depth dashboard</h1>
|
||||
<p className="mt-3 max-w-xl text-fog">
|
||||
Live depth, volume and slippage across supported Stock Token
|
||||
markets, sourced from public market data.
|
||||
</p>
|
||||
</div>
|
||||
{stats && (
|
||||
<div className="num flex shrink-0 items-center gap-2 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.14em] text-fog-dim">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-mint" />
|
||||
L1 {fmtNum(stats.blockNumber)} // Gas {stats.gasGwei.toFixed(2)} gwei
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* quiet terminal: one frame, hairline sections, no boxes */}
|
||||
<div className="mt-10 border border-edge bg-card/60">
|
||||
{isLoading || !campaigns
|
||||
? Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="m-3 h-44" />)
|
||||
: campaigns.map((c, i) => {
|
||||
const pair = pairs?.[c.meta.symbol];
|
||||
const depth = (pair?.liquidityUsd ?? 0) + c.tvlUsd;
|
||||
const score = healthScore({
|
||||
liquidityUsd: depth,
|
||||
volume24h: pair?.volume24h ?? 0,
|
||||
tvlUsd: c.tvlUsd,
|
||||
targetDepthUsd: c.targetDepthUsd,
|
||||
});
|
||||
return (
|
||||
<section
|
||||
key={c.meta.campaignId}
|
||||
className="border-b border-edge last:border-b-0"
|
||||
>
|
||||
{/* market strip */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-edge/60 px-5 py-3 md:px-7">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="num font-mono text-[10px] text-fog-dim">
|
||||
0{i + 1}
|
||||
</span>
|
||||
<span className="font-display text-xl uppercase leading-none">
|
||||
{c.meta.symbol}{" "}
|
||||
<span className="font-mono text-[14px] normal-case tracking-normal text-fog-dim">
|
||||
/ hUSD
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-[11px] text-fog-dim sm:inline">
|
||||
{c.meta.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[9px] uppercase tracking-[0.12em]",
|
||||
c.meta.risk === "High" ? "text-danger" : "text-fog-dim",
|
||||
)}
|
||||
>
|
||||
{c.meta.risk === "Medium" ? "Med" : c.meta.risk} risk
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/campaigns/${c.meta.campaignId}`}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.1em] text-fog transition-colors hover:text-mint"
|
||||
>
|
||||
Deepen this market →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 px-5 py-5 md:grid-cols-[1fr_1.25fr_auto] md:px-7">
|
||||
{pair ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="leader">
|
||||
<span className="text-fog">Price</span>
|
||||
<span className="num font-semibold">${pair.priceUsd.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">24h change</span>
|
||||
<span className={cn("num font-semibold", pair.change24h >= 0 ? "text-mint" : "text-danger")}>
|
||||
{pair.change24h >= 0 ? "+" : ""}
|
||||
{pair.change24h.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">24h volume</span>
|
||||
<span className="num font-semibold">{fmtUsd(pair.volume24h)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Total depth</span>
|
||||
<span className="num font-semibold text-mint">{fmtUsd(depth)}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Active LPs</span>
|
||||
<span className="num font-semibold">{c.activeLPs}</span>
|
||||
</div>
|
||||
<div className="leader">
|
||||
<span className="text-fog">Rewards remaining</span>
|
||||
<span className="num font-semibold text-lime">
|
||||
{fmtUsd(rewardsRemainingUsd(c, Math.floor(Date.now() / 1000)))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
)}
|
||||
|
||||
<div className="space-y-3 md:border-l md:border-edge md:pl-6">
|
||||
<div className="fig">Fig. 01 // Slippage by trade size</div>
|
||||
<SlippageCurve liquidityUsd={depth} />
|
||||
<div className="pt-1.5">
|
||||
<div className="fig">Fig. 02 // Depth 30d // Estimated</div>
|
||||
<DepthHistory currentUsd={depth} seedKey={c.meta.symbol} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-4 md:flex-col md:items-end md:justify-start md:border-l md:border-edge md:pl-6">
|
||||
<HealthRadial score={score} />
|
||||
<span className="fig md:text-right">
|
||||
Fig. 03
|
||||
<br className="hidden md:block" /> Health
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 font-mono text-[10px] leading-relaxed tracking-[0.05em] text-fog-dim">
|
||||
Depth history is estimated. Slippage figures derive from live pool
|
||||
liquidity and are not execution quotes
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue