@@ -168,7 +184,9 @@ export function Navbar() {
{/* Mobile hamburger */}
@@ -182,19 +200,48 @@ export function Navbar() {
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
- className="md:hidden overflow-hidden bg-forge-900 border-b border-border"
+ className="overflow-hidden border-b border-border bg-forge-900 md:hidden"
>
-
+
+ {stats && (
+
+
+ {stats.open_bounties} open
+
+ )}
{NAV_LINKS.map((link) => (
setMenuOpen(false)}
- className="px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-forge-850 transition-colors duration-150"
+ className={`rounded-lg px-4 py-2.5 text-sm font-medium transition-colors duration-150 ${
+ isActive(link.to)
+ ? 'bg-forge-850 text-text-primary'
+ : 'text-text-secondary hover:bg-forge-850 hover:text-text-primary'
+ }`}
>
{link.label}
))}
+
+ {isAuthenticated && user && (
+ <>
+
+
setMenuOpen(false)}
+ className="rounded-lg px-4 py-2.5 text-sm font-medium text-text-secondary transition-colors duration-150 hover:bg-forge-850 hover:text-text-primary"
+ >
+ Profile
+
+
+ >
+ )}
)}
diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts
new file mode 100644
index 000000000..2e8e429cb
--- /dev/null
+++ b/frontend/src/lib/animations.ts
@@ -0,0 +1,94 @@
+import type { Variants } from 'framer-motion';
+
+export const fadeIn: Variants = {
+ initial: { opacity: 0, y: 16 },
+ animate: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.35, ease: 'easeOut' },
+ },
+ exit: {
+ opacity: 0,
+ y: 8,
+ transition: { duration: 0.2, ease: 'easeIn' },
+ },
+};
+
+export const pageTransition: Variants = {
+ initial: { opacity: 0, y: 20 },
+ animate: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.4, ease: 'easeOut' },
+ },
+ exit: {
+ opacity: 0,
+ y: 12,
+ transition: { duration: 0.2, ease: 'easeIn' },
+ },
+};
+
+export const cardHover: Variants = {
+ rest: {
+ y: 0,
+ scale: 1,
+ transition: { duration: 0.2, ease: 'easeOut' },
+ },
+ hover: {
+ y: -4,
+ scale: 1.01,
+ transition: { duration: 0.2, ease: 'easeOut' },
+ },
+ tap: {
+ scale: 0.99,
+ transition: { duration: 0.12, ease: 'easeOut' },
+ },
+};
+
+export const buttonHover: Variants = {
+ rest: {
+ scale: 1,
+ transition: { duration: 0.15, ease: 'easeOut' },
+ },
+ hover: {
+ scale: 1.02,
+ transition: { duration: 0.15, ease: 'easeOut' },
+ },
+ tap: {
+ scale: 0.98,
+ transition: { duration: 0.1, ease: 'easeOut' },
+ },
+};
+
+export const staggerContainer: Variants = {
+ initial: {},
+ animate: {
+ transition: {
+ staggerChildren: 0.06,
+ delayChildren: 0.04,
+ },
+ },
+};
+
+export const staggerItem: Variants = {
+ initial: { opacity: 0, y: 10 },
+ animate: {
+ opacity: 1,
+ y: 0,
+ transition: { duration: 0.2, ease: 'easeOut' },
+ },
+};
+
+export const slideInRight: Variants = {
+ initial: { opacity: 0, x: 20 },
+ animate: {
+ opacity: 1,
+ x: 0,
+ transition: { duration: 0.25, ease: 'easeOut' },
+ },
+ exit: {
+ opacity: 0,
+ x: -20,
+ transition: { duration: 0.2, ease: 'easeIn' },
+ },
+};
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 000000000..b131edec9
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,86 @@
+const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+
+export const LANG_COLORS: Record
= {
+ typescript: '#3178C6',
+ javascript: '#F7DF1E',
+ python: '#3776AB',
+ rust: '#CE422B',
+ go: '#00ADD8',
+ solidity: '#8A92B2',
+ react: '#61DAFB',
+ nextjs: '#F0F0F5',
+ tailwind: '#38BDF8',
+};
+
+function toDate(value?: string | number | Date | null): Date | null {
+ if (!value) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function formatRelative(target: Date): string {
+ const now = Date.now();
+ const diffMs = target.getTime() - now;
+ const diffSeconds = Math.round(diffMs / 1000);
+ const absSeconds = Math.abs(diffSeconds);
+
+ if (absSeconds < 60) return rtf.format(diffSeconds, 'second');
+
+ const diffMinutes = Math.round(diffSeconds / 60);
+ if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute');
+
+ const diffHours = Math.round(diffMinutes / 60);
+ if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour');
+
+ const diffDays = Math.round(diffHours / 24);
+ if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day');
+
+ const diffMonths = Math.round(diffDays / 30);
+ if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month');
+
+ const diffYears = Math.round(diffMonths / 12);
+ return rtf.format(diffYears, 'year');
+}
+
+export function timeAgo(value?: string | number | Date | null): string {
+ const date = toDate(value);
+ if (!date) return 'unknown';
+ return formatRelative(date);
+}
+
+export function timeLeft(value?: string | number | Date | null): string {
+ const date = toDate(value);
+ if (!date) return 'no deadline';
+
+ const diffMs = date.getTime() - Date.now();
+ if (diffMs <= 0) return 'ended';
+
+ const diffMinutes = Math.floor(diffMs / 60000);
+ const days = Math.floor(diffMinutes / 1440);
+ const hours = Math.floor((diffMinutes % 1440) / 60);
+ const minutes = diffMinutes % 60;
+
+ if (days > 0) return `${days}d ${hours}h left`;
+ if (hours > 0) return `${hours}h ${minutes}m left`;
+ return `${Math.max(minutes, 1)}m left`;
+}
+
+export function formatCurrency(amount?: number | string | null, token?: string | null): string {
+ const numericAmount = typeof amount === 'string' ? Number(amount) : amount ?? 0;
+ const safeAmount = Number.isFinite(numericAmount) ? numericAmount : 0;
+ const upperToken = token?.trim()?.toUpperCase() || 'USD';
+
+ if (upperToken === 'USDC' || upperToken === 'USD') {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: safeAmount >= 100 ? 0 : 2,
+ }).format(safeAmount);
+ }
+
+ const formatted = new Intl.NumberFormat('en-US', {
+ maximumFractionDigits: safeAmount >= 100 ? 0 : 2,
+ }).format(safeAmount);
+
+ return `${formatted} ${upperToken}`;
+}