Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions frontend/src/components/fndry/FNDRYPriceWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React from 'react';
import { TrendingUp, TrendingDown, Minus, RefreshCw, Loader2 } from 'lucide-react';
import { useFNDRYPrice } from '../../hooks/useFNDRYPrice';
import { SparklineChart } from '../ui/SparklineChart';

/* ─── Helpers ─── */

function formatUsd(price: number): string {
if (price >= 1) return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
if (price >= 0.01) return `$${price.toFixed(4)}`;
// Micro-cap prices
return `$${price.toFixed(6)}`;
}

function formatCompact(num: number): string {
if (num >= 1_000_000_000) return `$${(num / 1_000_000_000).toFixed(1)}B`;
if (num >= 1_000_000) return `$${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `$${(num / 1_000).toFixed(1)}K`;
return `$${num.toFixed(0)}`;
}

function formatPercent(pct: number): string {
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(2)}%`;
}

/* ─── Widget Variants ─── */

interface FNDRYPriceWidgetProps {
/** FNDRY token address override (defaults to hardcoded address). */
tokenAddress?: string;
/** Widget size variant. */
variant?: 'compact' | 'default' | 'full';
/** Additional className for the wrapper. */
className?: string;
}

export function FNDRYPriceWidget({
tokenAddress,
variant = 'default',
className = '',
}: FNDRYPriceWidgetProps) {
const { data, loading, error, refetch } = useFNDRYPrice(tokenAddress);

if (error) {
return (
<div className={`rounded-xl border border-red-400/30 bg-forge-900 p-4 ${className}`}>
<div className="flex items-center gap-2 text-red-400 text-sm">
<TrendingDown className="w-4 h-4" />
<span>Price unavailable</span>
<button onClick={refetch} className="ml-auto p-1 hover:text-red-300 transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}

if (loading || !data) {
return (
<div className={`rounded-xl border border-border bg-forge-900 p-4 animate-pulse ${className}`}>
<div className="h-4 w-20 bg-forge-700 rounded mb-2" />
<div className="h-8 w-28 bg-forge-700 rounded mb-3" />
<div className="h-3 w-16 bg-forge-700 rounded" />
</div>
);
}

const isPositive = data.priceChange24h > 0;
const isNeutral = data.priceChange24h === 0;
const changeColor = isPositive ? 'text-emerald' : isNeutral ? 'text-text-muted' : 'text-red-400';
const ChangeIcon = isPositive ? TrendingUp : isNeutral ? Minus : TrendingDown;
const sparkColor = isPositive ? '#00E676' : '#EF4444';

if (variant === 'compact') {
return (
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-border bg-forge-900 ${className}`}>
<span className="font-mono text-sm font-semibold text-text-primary">
{formatUsd(data.priceUsd)}
</span>
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${changeColor}`}>
<ChangeIcon className="w-3 h-3" />
{formatPercent(data.priceChange24h)}
</span>
</div>
);
}

if (variant === 'full') {
return (
<div className={`rounded-xl border border-border bg-forge-900 p-5 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-secondary">$FNDRY</span>
<span className="text-[10px] font-mono text-text-muted uppercase bg-forge-800 px-1.5 py-0.5 rounded">
Solana
</span>
</div>
<button onClick={refetch} className="p-1 text-text-muted hover:text-text-secondary transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>

{/* Price + Change */}
<div className="flex items-end gap-3 mb-4">
<span className="font-mono text-2xl font-bold text-text-primary">
{formatUsd(data.priceUsd)}
</span>
<span className={`inline-flex items-center gap-1 text-sm font-medium mb-0.5 ${changeColor}`}>
<ChangeIcon className="w-4 h-4" />
{formatPercent(data.priceChange24h)}
</span>
</div>

{/* Sparkline */}
<div className="mb-4">
<SparklineChart
data={data.sparkline}
width={280}
height={60}
strokeColor={sparkColor}
strokeWidth={2}
/>
</div>

{/* Stats grid */}
<div className="grid grid-cols-3 gap-3 text-center">
<div className="px-2 py-2 rounded-lg bg-forge-800">
<div className="text-[10px] uppercase text-text-muted mb-0.5">24h Volume</div>
<div className="font-mono text-sm text-text-primary">{formatCompact(data.volume24h)}</div>
</div>
<div className="px-2 py-2 rounded-lg bg-forge-800">
<div className="text-[10px] uppercase text-text-muted mb-0.5">Liquidity</div>
<div className="font-mono text-sm text-text-primary">{formatCompact(data.liquidity)}</div>
</div>
<div className="px-2 py-2 rounded-lg bg-forge-800">
<div className="text-[10px] uppercase text-text-muted mb-0.5">FDV</div>
<div className="font-mono text-sm text-text-primary">{formatCompact(data.fdv)}</div>
</div>
</div>
</div>
);
}

// Default variant
return (
<div className={`rounded-xl border border-border bg-forge-900 p-4 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-text-secondary">$FNDRY</span>
<button onClick={refetch} className="p-1 text-text-muted hover:text-text-secondary transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>

{/* Price row */}
<div className="flex items-center gap-3 mb-3">
<span className="font-mono text-xl font-bold text-text-primary">
{formatUsd(data.priceUsd)}
</span>
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${changeColor}`}>
<ChangeIcon className="w-3.5 h-3.5" />
{formatPercent(data.priceChange24h)}
</span>
</div>

{/* Sparkline */}
<SparklineChart
data={data.sparkline}
width={200}
height={36}
strokeColor={sparkColor}
/>
</div>
);
}
86 changes: 86 additions & 0 deletions frontend/src/components/ui/SparklineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';

interface SparklineChartProps {
data: number[];
width?: number;
height?: number;
strokeColor?: string;
fillColor?: string;
strokeWidth?: number;
}

/**
* Minimal SVG sparkline — no charting library needed.
* Draws a polyline from the data points with an optional gradient fill.
*/
export function SparklineChart({
data,
width = 120,
height = 40,
strokeColor = '#00E676',
fillColor,
strokeWidth = 1.5,
}: SparklineChartProps) {
if (data.length < 2) {
return (
<svg width={width} height={height} className="opacity-50">
<line x1="0" y1={height / 2} x2={width} y2={height / 2}
stroke={strokeColor} strokeWidth={strokeWidth} strokeDasharray="2,4" />
</svg>
);
}

const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1; // avoid div-by-zero
const padY = 2; // px padding top/bottom

// Map data to SVG coordinates
const points = data.map((val, i) => {
const x = (i / (data.length - 1)) * width;
const y = padY + ((max - val) / range) * (height - padY * 2);
return `${x},${y}`;
});

const polylinePoints = points.join(' ');
const fillId = `sparkline-fill-${React.useId()}`;
const gradientFill = fillColor ?? `url(#${fillId})`;

return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible">
{/* Gradient fill under the line */}
<defs>
<linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.2" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
</linearGradient>
</defs>

{/* Fill area */}
<polygon
points={`0,${height} ${polylinePoints} ${width},${height}`}
fill={gradientFill}
/>

{/* Line */}
<polyline
points={polylinePoints}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>

{/* Current price dot */}
{data.length > 0 && (
<circle
cx={width}
cy={parseFloat(points[points.length - 1].split(',')[1])}
r="2.5"
fill={strokeColor}
/>
)}
</svg>
);
}
103 changes: 103 additions & 0 deletions frontend/src/hooks/useFNDRYPrice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { TokenPriceData, DexScreenerPair } from '../types/dexscreener';

/* ─── Config ─── */

// FNDRY token address on Solana (replace with actual address once deployed)
const FNDRY_TOKEN_ADDRESS = 'FNDRY_TOKEN_ADDRESS_PLACEHOLDER';
const DEXSCREENER_API = 'https://api.dexscreener.com/latest/dex/tokens';
const REFRESH_INTERVAL_MS = 30_000; // 30s refresh
const SPARKLINE_POINTS = 24; // 24 data points for sparkline

/* ─── Hook ─── */

export function useFNDRYPrice(tokenAddress?: string) {
const [data, setData] = useState<TokenPriceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const sparklineRef = useRef<number[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval>>();
const mountedRef = useRef(true);

const address = tokenAddress ?? FNDRY_TOKEN_ADDRESS;

const fetchPrice = useCallback(async () => {
try {
const res = await fetch(`${DEXSCREENER_API}/${address}`);
if (!res.ok) throw new Error(`DexScreener API returned ${res.status}`);

const json = await res.json();
const pairs: DexScreenerPair[] = json.pairs ?? [];

// Find the best pair (highest liquidity)
const bestPair = pairs
.filter(p => p.chainId === 'solana')
.sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0))[0];

if (!bestPair) {
// Fallback: use any pair if no Solana pair exists
const fallback = pairs.sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0))[0];
if (!fallback) {
if (mountedRef.current) setError('No trading pair found for FNDRY');
return;
}
processPair(fallback);
} else {
processPair(bestPair);
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err.message : 'Failed to fetch price');
}
} finally {
if (mountedRef.current) setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address]);

const processPair = (pair: DexScreenerPair) => {
const price = parseFloat(pair.priceUsd || '0');
const change24h = pair.priceChange?.h24 ?? 0;
const volume24h = pair.volume?.h24 ?? 0;
const liquidity = pair.liquidity?.usd ?? 0;
const fdv = pair.fdv ?? 0;

// Update sparkline (append current price, keep last N points)
sparklineRef.current = [
...sparklineRef.current.slice(-(SPARKLINE_POINTS - 1)),
price,
];

if (mountedRef.current) {
setData({
priceUsd: price,
priceChange24h: change24h,
volume24h,
liquidity,
fdv,
sparkline: [...sparklineRef.current],
lastUpdated: Date.now(),
});
setError(null);
}
};

useEffect(() => {
mountedRef.current = true;
setLoading(true);
fetchPrice();
timerRef.current = setInterval(fetchPrice, REFRESH_INTERVAL_MS);

return () => {
mountedRef.current = false;
if (timerRef.current) clearInterval(timerRef.current);
};
}, [fetchPrice]);

const refetch = useCallback(() => {
setLoading(true);
fetchPrice();
}, [fetchPrice]);

return { data, loading, error, refetch };
}
22 changes: 13 additions & 9 deletions frontend/src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ import { ActivityFeed } from '../components/home/ActivityFeed';
import { HowItWorksCondensed } from '../components/home/HowItWorksCondensed';
import { FeaturedBounties } from '../components/home/FeaturedBounties';
import { WhySolFoundry } from '../components/home/WhySolFoundry';
import { FNDRYPriceWidget } from '../components/fndry/FNDRYPriceWidget';

export function HomePage() {
return (
<PageLayout noFooter={false}>
<HeroSection />
<ActivityFeed />
<HowItWorksCondensed />
<FeaturedBounties />
<WhySolFoundry />
</PageLayout>
);
return (
<PageLayout noFooter={false}>
<HeroSection />
<div className="max-w-7xl mx-auto px-4 -mt-8 mb-8">
<FNDRYPriceWidget variant="default" />
</div>
<ActivityFeed />
<HowItWorksCondensed />
<FeaturedBounties />
<WhySolFoundry />
</PageLayout>
);
}
Loading