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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue