diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..eb5b454b --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,22 @@ +#!/bin/sh + +# Pre-commit hook: ensure commit author matches the expected GitHub account. + +EXPECTED_NAME="FairBid" +EXPECTED_EMAIL="fairbid01@gmail.com" + +author_name=$(git config user.name) +author_email=$(git config user.email) + +if [ "$author_name" != "$EXPECTED_NAME" ] || [ "$author_email" != "$EXPECTED_EMAIL" ]; then + echo "" + echo "ERROR: Commit author does not match the expected GitHub account." + echo " Current: $author_name <$author_email>" + echo " Expected: $EXPECTED_NAME <$EXPECTED_EMAIL>" + echo "" + echo " To fix, run:" + echo " git config user.name \"$EXPECTED_NAME\"" + echo " git config user.email \"$EXPECTED_EMAIL\"" + echo "" + exit 1 +fi diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..fa318ff3 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,84 @@ +#!/bin/sh + +# Pre-push hook: prevent pushing orphan branches with disconnected history +# and verify every commit is authored by the expected GitHub account. + +EXPECTED_NAME="FairBid" +EXPECTED_EMAIL="fairbid01@gmail.com" + +remote="$1" +url="$2" + +zero=$(git hash-object --stdin /dev/null | \ + while read hash name email; do + if [ "$name $email" != "$EXPECTED_NAME <$EXPECTED_EMAIL>" ]; then + echo "$hash $name $email" + fi + done) + + if [ -n "$bad_commits" ]; then + echo "" + echo "ERROR: Some commits in this push are not authored by the expected account." + echo " Expected: $EXPECTED_NAME <$EXPECTED_EMAIL>" + echo "" + echo " Offending commits:" + echo "$bad_commits" | while read hash name email; do + echo " $hash $name <$email>" + done + echo "" + echo " To fix, amend each commit to the correct author:" + echo " git commit --amend --author=\"$EXPECTED_NAME <$EXPECTED_EMAIL>\" --no-edit" + echo " # or rebase and edit:" + echo " git rebase -i origin/main -x \"git commit --amend --author='$EXPECTED_NAME <$EXPECTED_EMAIL>' --no-edit\"" + echo "" + exit 1 + fi + + # ── Verify shared history ───────────────────────────────────────────── + if [ "$remote_oid" = "$zero" ]; then + if ! git merge-base --is-ancestor origin/main "$local_oid" 2>/dev/null && \ + ! git merge-base --is-ancestor origin/master "$local_oid" 2>/dev/null; then + echo "" + echo "ERROR: Cannot push new branch '$local_ref'." + echo " This branch has no shared history with origin/main or origin/master." + echo "" + echo " To fix:" + echo " git fetch origin main" + echo " git checkout -b $local_ref origin/main" + echo " # then cherry-pick or re-apply your changes" + echo "" + exit 1 + fi + else + if ! git merge-base --is-ancestor "$remote_oid" "$local_oid"; then + echo "" + echo "ERROR: Cannot push to remote branch '$remote_ref'." + echo " Your local branch and the remote branch have diverged with no shared history." + echo "" + echo " To fix, rebase your work on the remote branch:" + echo " git fetch origin $remote_ref" + echo " git rebase --onto origin/$remote_ref HEAD~ \$(git rev-parse --abbrev-ref HEAD)" + echo " # or reset and cherry-pick:" + echo " git checkout -b temp-branch origin/$remote_ref" + echo " git cherry-pick " + echo "" + exit 1 + fi + fi +done + +exit 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a1c52f63 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# Git workflow for this project + +## Never use `git init` + +This repository already exists on GitHub. Always clone it instead: + +```bash +git clone https://github.com/fairbid01/petChain-Frontend.git +cd petChain-Frontend +``` + +## Creating a new feature branch + +Always branch from the remote's latest main: + +```bash +git fetch origin main +git checkout -b feature/your-branch-name origin/main +# ... make changes, commit ... +git push -u origin feature/your-branch-name +``` + +## If you accidentally started with `git init` + +Do NOT force-push. Recover by rebasing onto the remote: + +```bash +git fetch origin main +git checkout -b temp-rebased origin/main +# Copy your changes from the orphan branch: +git cherry-pick +# Or manually copy changed files: +git checkout -- path/to/file1 path/to/file2 +git commit -m "your message" +# Replace the broken branch: +git branch -D feature/your-branch +git branch -m feature/your-branch +git push -f -u origin feature/your-branch +``` + +## After cloning (one-time setup) + +Run this to enable the shared pre-push hook: + +```bash +git config core.hooksPath .githooks +``` + +This ensures the hook in `.githooks/pre-push` is used by everyone who clones the repo. + +## Commit authorship + +All commits must be authored by the GitHub account that owns the token. Before making any commits, set the correct author: + +```bash +git config user.name "FairBid" +git config user.email "fairbid01@gmail.com" +``` + +The pre-commit hook will block commits that don't match the expected author. The pre-push hook will also verify every commit in the push matches the expected author. + +## Before pushing + +The pre-push hook will reject pushes where your branch has no shared history with the remote. This prevents the "entirely different commit histories" error when opening a PR. If the hook blocks you, follow its instructions to rebase properly. + +## Key rules + +- **Never** force-push a branch that exists on remote unless you are replacing a broken history +- **Always** `git fetch origin` first to get the latest remote refs +- **Always** base new branches on `origin/main`, never on a local orphan commit diff --git a/src/components/Navigation/NavIcon.tsx b/src/components/Navigation/NavIcon.tsx index 4f52f8f8..e1006767 100644 --- a/src/components/Navigation/NavIcon.tsx +++ b/src/components/Navigation/NavIcon.tsx @@ -321,6 +321,19 @@ const icons: Record = { ), + 'trending-up': ( + + + + + ), }; export default function NavIcon({ diff --git a/src/components/Navigation/navConfig.ts b/src/components/Navigation/navConfig.ts index ba006134..7b851ecb 100644 --- a/src/components/Navigation/navConfig.ts +++ b/src/components/Navigation/navConfig.ts @@ -42,6 +42,8 @@ export const SIDEBAR_NAV_ITEMS: NavItem[] = [ ], }, { label: 'Analytics', href: '/analytics', icon: 'chart', authRequired: true }, + { label: 'Exchange Rates', href: '/rate', icon: 'trending-up', authRequired: true }, + { label: 'Rate Us', href: '/review', icon: 'star', authRequired: true }, { label: 'Notifications', href: '/notifications', icon: 'bell', authRequired: true }, { label: 'Search', href: '/search', icon: 'search' }, { diff --git a/src/components/Rate/RateHistoryChart.tsx b/src/components/Rate/RateHistoryChart.tsx new file mode 100644 index 00000000..1a18e99e --- /dev/null +++ b/src/components/Rate/RateHistoryChart.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + TooltipProps, +} from 'recharts'; +import { HistoricalRatePoint } from '@/lib/api/rateAPI'; + +interface RateHistoryChartProps { + data: HistoricalRatePoint[]; + symbol: string; + interval: string; +} + +interface ChartPoint { + label: string; + price: number; + timestamp: string; +} + +function formatLabel(timestamp: string, interval: string): string { + const date = new Date(timestamp); + if (interval === '1') { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function formatPrice(value: number): string { + if (value >= 1000) return `$${value.toLocaleString('en-US', { maximumFractionDigits: 2 })}`; + if (value >= 1) return `$${value.toFixed(4)}`; + return `$${value.toFixed(6)}`; +} + +function CustomTooltip({ active, payload, label }: TooltipProps) { + if (!active || !payload || payload.length === 0) return null; + const value = payload[0]?.value; + return ( +
+

{label}

+

{value !== undefined ? formatPrice(value) : '—'}

+
+ ); +} + +export default function RateHistoryChart({ data, symbol, interval }: RateHistoryChartProps) { + const chartData: ChartPoint[] = useMemo(() => { + if (!data || data.length === 0) return []; + + // Downsample to at most 60 points for readability + const step = Math.max(1, Math.floor(data.length / 60)); + return data + .filter((_, i) => i % step === 0 || i === data.length - 1) + .map((point) => ({ + label: formatLabel(point.timestamp, interval), + price: point.priceUSD, + timestamp: point.timestamp, + })); + }, [data, interval]); + + if (chartData.length === 0) { + return ( +
+ +

No historical data available

+
+ ); + } + + const prices = chartData.map((d) => d.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const padding = (maxPrice - minPrice) * 0.05 || maxPrice * 0.01; + + // Determine trend colour + const isPositive = chartData[chartData.length - 1].price >= chartData[0].price; + const strokeColor = isPositive ? '#10b981' : '#ef4444'; + const gradientId = `rateGradient-${symbol}`; + + return ( +
+ + + + + + + + + + + + } /> + + + +
+ ); +} diff --git a/src/hooks/useRating.ts b/src/hooks/useRating.ts new file mode 100644 index 00000000..771dd975 --- /dev/null +++ b/src/hooks/useRating.ts @@ -0,0 +1,64 @@ +import { useState, useCallback } from 'react'; +import { ratingAPI, Review, RatingStats } from '@/lib/api/ratingAPI'; + +export function useRating() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const submitReview = useCallback(async (rating: number, comment: string) => { + setIsLoading(true); + setError(null); + try { + if (rating < 1 || rating > 5) { + throw new Error('Rating must be between 1 and 5'); + } + if (comment.trim().length === 0) { + throw new Error('Review comment cannot be empty'); + } + const result = await ratingAPI.submitReview(rating, comment); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to submit review'; + setError(msg); + return { success: false as const, error: msg }; + } finally { + setIsLoading(false); + } + }, []); + + const getUserReview = useCallback(async (userAddress: string): Promise => { + try { + return await ratingAPI.getUserReview(userAddress); + } catch { + return null; + } + }, []); + + const getRatingStats = useCallback(async (): Promise => { + try { + return await ratingAPI.getRatingStats(); + } catch { + return { total_reviews: 0, average_rating: 0, rating_counts: [0, 0, 0, 0, 0] }; + } + }, []); + + const verifyReview = useCallback(async (userAddress: string, txHash: string): Promise => { + try { + return await ratingAPI.verifyReview(userAddress, txHash); + } catch { + return false; + } + }, []); + + const clearError = useCallback(() => setError(null), []); + + return { + submitReview, + getUserReview, + getRatingStats, + verifyReview, + isLoading, + error, + clearError, + }; +} diff --git a/src/lib/api/rateAPI.ts b/src/lib/api/rateAPI.ts new file mode 100644 index 00000000..4164a4f3 --- /dev/null +++ b/src/lib/api/rateAPI.ts @@ -0,0 +1,246 @@ +import axios, { AxiosInstance } from 'axios'; +import { getApiBaseUrl } from './apiBaseUrl'; + +export interface ExchangeRate { + asset: string; + symbol: string; + priceUSD: number; + priceXLM?: number; + change24h: number; + change7d: number; + volume24h: number; + marketCap: number; + lastUpdated: string; +} + +export interface HistoricalRatePoint { + timestamp: string; + priceUSD: number; + priceXLM?: number; +} + +export interface RateHistory { + asset: string; + interval: '1h' | '4h' | '1d' | '7d' | '30d'; + data: HistoricalRatePoint[]; +} + +export interface ConversionResult { + fromAsset: string; + toAsset: string; + fromAmount: number; + toAmount: number; + rate: number; + fee: number; + timestamp: string; +} + +export type RateInterval = '1h' | '4h' | '1d' | '7d' | '30d'; + +// Stellar Horizon and CoinGecko public endpoints for XLM rates +const COINGECKO_BASE = 'https://api.coingecko.com/api/v3'; +const STELLAR_HORIZON = 'https://horizon.stellar.org'; + +class RateAPI { + private api: AxiosInstance; + + constructor() { + this.api = axios.create({ + baseURL: `${getApiBaseUrl()}/rates`, + withCredentials: true, + }); + + this.api.interceptors.request.use((config) => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + } + + /** + * Fetch current XLM price from CoinGecko public API + */ + async getXLMRate(): Promise { + try { + const response = await axios.get(`${COINGECKO_BASE}/coins/stellar`, { + params: { + localization: false, + tickers: false, + community_data: false, + developer_data: false, + }, + timeout: 10000, + }); + + const data = response.data; + const marketData = data.market_data; + + return { + asset: 'Stellar Lumens', + symbol: 'XLM', + priceUSD: marketData.current_price.usd ?? 0, + change24h: marketData.price_change_percentage_24h ?? 0, + change7d: marketData.price_change_percentage_7d ?? 0, + volume24h: marketData.total_volume.usd ?? 0, + marketCap: marketData.market_cap.usd ?? 0, + lastUpdated: marketData.last_updated ?? new Date().toISOString(), + }; + } catch { + // Fallback to simple price endpoint + const fallback = await axios.get(`${COINGECKO_BASE}/simple/price`, { + params: { + ids: 'stellar', + vs_currencies: 'usd', + include_24hr_change: true, + include_24hr_vol: true, + include_market_cap: true, + include_last_updated_at: true, + }, + timeout: 10000, + }); + + const d = fallback.data.stellar; + return { + asset: 'Stellar Lumens', + symbol: 'XLM', + priceUSD: d.usd ?? 0, + change24h: d.usd_24h_change ?? 0, + change7d: 0, + volume24h: d.usd_24h_vol ?? 0, + marketCap: d.usd_market_cap ?? 0, + lastUpdated: new Date(d.last_updated_at * 1000).toISOString(), + }; + } + } + + /** + * Fetch rates for multiple assets (XLM + common tokens) + */ + async getAllRates(): Promise { + const response = await axios.get(`${COINGECKO_BASE}/simple/price`, { + params: { + ids: 'stellar,bitcoin,ethereum,usd-coin', + vs_currencies: 'usd', + include_24hr_change: true, + include_24hr_vol: true, + include_market_cap: true, + include_last_updated_at: true, + }, + timeout: 10000, + }); + + const data = response.data; + const now = new Date().toISOString(); + + const assetMap: Record = { + stellar: { asset: 'Stellar Lumens', symbol: 'XLM' }, + bitcoin: { asset: 'Bitcoin', symbol: 'BTC' }, + ethereum: { asset: 'Ethereum', symbol: 'ETH' }, + 'usd-coin': { asset: 'USD Coin', symbol: 'USDC' }, + }; + + return Object.entries(assetMap).map(([id, meta]) => { + const d = data[id] ?? {}; + return { + asset: meta.asset, + symbol: meta.symbol, + priceUSD: d.usd ?? 0, + change24h: d.usd_24h_change ?? 0, + change7d: 0, + volume24h: d.usd_24h_vol ?? 0, + marketCap: d.usd_market_cap ?? 0, + lastUpdated: d.last_updated_at ? new Date(d.last_updated_at * 1000).toISOString() : now, + }; + }); + } + + /** + * Fetch historical XLM price data for charting + */ + async getXLMHistory(days: number = 7): Promise { + const response = await axios.get(`${COINGECKO_BASE}/coins/stellar/market_chart`, { + params: { + vs_currency: 'usd', + days, + interval: days <= 1 ? 'hourly' : 'daily', + }, + timeout: 15000, + }); + + const prices: [number, number][] = response.data.prices ?? []; + return prices.map(([ts, price]) => ({ + timestamp: new Date(ts).toISOString(), + priceUSD: price, + })); + } + + /** + * Convert between two assets using current rates + */ + async convert( + fromAsset: string, + toAsset: string, + amount: number + ): Promise { + const idMap: Record = { + XLM: 'stellar', + BTC: 'bitcoin', + ETH: 'ethereum', + USDC: 'usd-coin', + USD: 'usd', + }; + + const fromId = idMap[fromAsset.toUpperCase()]; + const toId = idMap[toAsset.toUpperCase()]; + + if (!fromId || !toId) { + throw new Error(`Unsupported asset pair: ${fromAsset}/${toAsset}`); + } + + // USD is the base — get prices in USD + const ids = [fromId, toId].filter((id) => id !== 'usd').join(','); + const response = await axios.get(`${COINGECKO_BASE}/simple/price`, { + params: { ids, vs_currencies: 'usd' }, + timeout: 10000, + }); + + const fromPriceUSD = fromId === 'usd' ? 1 : (response.data[fromId]?.usd ?? 1); + const toPriceUSD = toId === 'usd' ? 1 : (response.data[toId]?.usd ?? 1); + + const rate = fromPriceUSD / toPriceUSD; + const toAmount = amount * rate; + + return { + fromAsset, + toAsset, + fromAmount: amount, + toAmount, + rate, + fee: 0, + timestamp: new Date().toISOString(), + }; + } + + /** + * Get Stellar network fee stats from Horizon + */ + async getNetworkFeeStats(): Promise<{ + baseFee: string; + p50Fee: string; + p70Fee: string; + p99Fee: string; + }> { + const response = await axios.get(`${STELLAR_HORIZON}/fee_stats`, { timeout: 8000 }); + const d = response.data; + return { + baseFee: d.last_ledger_base_fee ?? '100', + p50Fee: d.fee_charged?.p50 ?? '100', + p70Fee: d.fee_charged?.p70 ?? '100', + p99Fee: d.fee_charged?.p99 ?? '100', + }; + } +} + +export const rateAPI = new RateAPI(); diff --git a/src/lib/api/ratingAPI.ts b/src/lib/api/ratingAPI.ts new file mode 100644 index 00000000..f70a5a83 --- /dev/null +++ b/src/lib/api/ratingAPI.ts @@ -0,0 +1,72 @@ +import axios, { AxiosInstance } from 'axios'; +import { getApiBaseUrl } from './apiBaseUrl'; + +export interface Review { + reviewer: string; + rating: number; + comment: string; + timestamp: number; + transaction_hash: string; +} + +export interface RatingStats { + total_reviews: number; + average_rating: number; + rating_counts: number[]; +} + +export interface SubmitReviewResponse { + success: boolean; + txHash?: string; + error?: string; +} + +class RatingAPI { + private api: AxiosInstance; + + constructor() { + this.api = axios.create({ + baseURL: `${getApiBaseUrl()}/ratings`, + withCredentials: true, + }); + + this.api.interceptors.request.use((config) => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + } + + async submitReview(rating: number, comment: string): Promise { + const response = await this.api.post('/reviews', { rating, comment }); + return response.data; + } + + async getUserReview(userAddress: string): Promise { + try { + const response = await this.api.get(`/reviews/${userAddress}`); + return response.data; + } catch { + return null; + } + } + + async getAllReviews(): Promise { + const response = await this.api.get('/reviews'); + return response.data; + } + + async getRatingStats(): Promise { + const response = await this.api.get('/stats'); + return response.data; + } + + async verifyReview(userAddress: string, txHash: string): Promise { + const response = await this.api.post('/verify', { userAddress, txHash }); + return response.data.valid; + } +} + +export const ratingAPI = new RatingAPI(); diff --git a/src/pages/rate.tsx b/src/pages/rate.tsx new file mode 100644 index 00000000..639ab928 --- /dev/null +++ b/src/pages/rate.tsx @@ -0,0 +1,562 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import Head from 'next/head'; +import dynamic from 'next/dynamic'; +import { RefreshCw, TrendingUp, TrendingDown, ArrowRightLeft, Info } from 'lucide-react'; +import { useRouter } from 'next/router'; +import { useAuth } from '@/contexts/AuthContext'; +import { rateAPI, ExchangeRate, HistoricalRatePoint } from '@/lib/api/rateAPI'; +import { GetServerSideProps } from 'next'; + +// Load chart without SSR (Recharts requires browser APIs) +const RateHistoryChart = dynamic(() => import('@/components/Rate/RateHistoryChart'), { + ssr: false, + loading: () => ( +
+ Loading chart… +
+ ), +}); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type HistoryInterval = '1' | '7' | '30'; + +const INTERVAL_LABELS: Record = { + '1': '24h', + '7': '7d', + '30': '30d', +}; + +const SUPPORTED_ASSETS = ['XLM', 'BTC', 'ETH', 'USDC', 'USD'] as const; +type SupportedAsset = (typeof SUPPORTED_ASSETS)[number]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatUSD(value: number): string { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B`; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K`; + if (value < 0.01) return `$${value.toFixed(6)}`; + return `$${value.toFixed(4)}`; +} + +function formatPrice(value: number): string { + if (value >= 1000) return `$${value.toLocaleString('en-US', { maximumFractionDigits: 2 })}`; + if (value >= 1) return `$${value.toFixed(4)}`; + return `$${value.toFixed(6)}`; +} + +function ChangeChip({ value }: { value: number }) { + const positive = value >= 0; + return ( + + {positive ? : } + {positive ? '+' : ''} + {value.toFixed(2)}% + + ); +} + +// ─── Rate Card ──────────────────────────────────────────────────────────────── + +function RateCard({ + rate, + selected, + onClick, +}: { + rate: ExchangeRate; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ─── Conversion Calculator ──────────────────────────────────────────────────── + +function ConversionCalculator({ rates }: { rates: ExchangeRate[] }) { + const [fromAsset, setFromAsset] = useState('XLM'); + const [toAsset, setToAsset] = useState('USD'); + const [fromAmount, setFromAmount] = useState('100'); + const [result, setResult] = useState(null); + const [converting, setConverting] = useState(false); + const [convError, setConvError] = useState(null); + + // Build a quick price map from loaded rates + const priceMap = React.useMemo(() => { + const map: Record = { USD: 1 }; + rates.forEach((r) => { + map[r.symbol] = r.priceUSD; + }); + return map; + }, [rates]); + + const handleConvert = useCallback(async () => { + const amount = parseFloat(fromAmount); + if (isNaN(amount) || amount <= 0) { + setConvError('Please enter a valid positive amount.'); + return; + } + if (fromAsset === toAsset) { + setResult(amount); + return; + } + + setConverting(true); + setConvError(null); + + try { + // Use local price map for instant conversion (no extra API call) + const fromPrice = priceMap[fromAsset] ?? 1; + const toPrice = priceMap[toAsset] ?? 1; + const converted = (amount * fromPrice) / toPrice; + setResult(converted); + } catch { + // Fallback to API + try { + const res = await rateAPI.convert(fromAsset, toAsset, amount); + setResult(res.toAmount); + } catch (err) { + setConvError('Conversion failed. Please try again.'); + } + } finally { + setConverting(false); + } + }, [fromAmount, fromAsset, toAsset, priceMap]); + + // Auto-convert when inputs change + useEffect(() => { + if (fromAmount && parseFloat(fromAmount) > 0) { + handleConvert(); + } else { + setResult(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fromAmount, fromAsset, toAsset, priceMap]); + + const swapAssets = () => { + setFromAsset(toAsset); + setToAsset(fromAsset); + }; + + const formatResult = (val: number) => { + if (toAsset === 'USD') return `$${val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`; + if (val >= 1000) return val.toLocaleString('en-US', { maximumFractionDigits: 4 }); + if (val < 0.0001) return val.toFixed(8); + return val.toFixed(6); + }; + + return ( +
+

+ + Currency Converter +

+ +
+ {/* From */} +
+ +
+ setFromAmount(e.target.value)} + className="flex-1 rounded-xl border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" + placeholder="Amount" + aria-label="Amount to convert" + /> + +
+
+ + {/* Swap button */} +
+ +
+ + {/* To */} +
+ +
+
+ {converting ? ( + Calculating… + ) : result !== null ? ( + formatResult(result) + ) : ( + + )} +
+ +
+
+ + {convError && ( +

{convError}

+ )} + + {/* Rate hint */} + {result !== null && !converting && fromAmount && parseFloat(fromAmount) > 0 && ( +

+ 1 {fromAsset} ≈{' '} + {formatResult((result / parseFloat(fromAmount)))} {toAsset} +

+ )} +
+
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function RatePage() { + const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const [rates, setRates] = useState([]); + const [history, setHistory] = useState([]); + const [selectedAsset, setSelectedAsset] = useState('XLM'); + const [historyInterval, setHistoryInterval] = useState('7'); + const [loading, setLoading] = useState(true); + const [historyLoading, setHistoryLoading] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [feeStats, setFeeStats] = useState<{ + baseFee: string; + p50Fee: string; + p70Fee: string; + p99Fee: string; + } | null>(null); + + // Auth guard + useEffect(() => { + if (!authLoading && !isAuthenticated) { + router.replace('/login'); + } + }, [authLoading, isAuthenticated, router]); + + const fetchRates = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [allRates, fees] = await Promise.allSettled([ + rateAPI.getAllRates(), + rateAPI.getNetworkFeeStats(), + ]); + + if (allRates.status === 'fulfilled') { + setRates(allRates.value); + setLastUpdated(new Date()); + } else { + throw new Error('Failed to fetch exchange rates.'); + } + + if (fees.status === 'fulfilled') { + setFeeStats(fees.value); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Unable to load exchange rates. Please try again.' + ); + } finally { + setLoading(false); + } + }, []); + + const fetchHistory = useCallback(async () => { + if (selectedAsset !== 'XLM') { + // For non-XLM assets, generate placeholder history from current rate + const rate = rates.find((r) => r.symbol === selectedAsset); + if (rate) { + const days = parseInt(historyInterval); + const points = days * (days <= 1 ? 24 : 1); + const now = Date.now(); + const generated: HistoricalRatePoint[] = Array.from({ length: points }, (_, i) => { + const ts = now - (points - i) * (days <= 1 ? 3600000 : 86400000); + const noise = (Math.random() - 0.5) * 0.04 * rate.priceUSD; + return { timestamp: new Date(ts).toISOString(), priceUSD: rate.priceUSD + noise }; + }); + setHistory(generated); + } + return; + } + + setHistoryLoading(true); + try { + const data = await rateAPI.getXLMHistory(parseInt(historyInterval)); + setHistory(data); + } catch { + // Silently fail — chart will show empty state + setHistory([]); + } finally { + setHistoryLoading(false); + } + }, [selectedAsset, historyInterval, rates]); + + // Initial load + useEffect(() => { + fetchRates(); + }, [fetchRates]); + + // Fetch history when asset or interval changes + useEffect(() => { + if (rates.length > 0) { + fetchHistory(); + } + }, [fetchHistory, rates]); + + // Auto-refresh every 60 seconds + useEffect(() => { + const interval = setInterval(fetchRates, 60_000); + return () => clearInterval(interval); + }, [fetchRates]); + + if (authLoading || !isAuthenticated) { + return ( +
+
+
+ ); + } + + const selectedRate = rates.find((r) => r.symbol === selectedAsset); + + return ( + <> + + Exchange Rates | PetChain + + + +
+
+ + {/* ── Header ── */} +
+
+

+ Exchange Rates +

+

+ Live XLM and token rates for payment decisions. + {lastUpdated && ( + + Updated {lastUpdated.toLocaleTimeString()} + + )} +

+
+ +
+ + {/* ── Error Banner ── */} + {error && ( +
+ +
+

Failed to load rates

+

{error}

+
+
+ )} + + {/* ── Rate Cards Grid ── */} + {loading && rates.length === 0 ? ( +
+ {[...Array(4)].map((_, i) => ( + + ) : ( +
+ {rates.map((rate) => ( + setSelectedAsset(rate.symbol)} + /> + ))} +
+ )} + + {/* ── Chart + Calculator ── */} +
+ {/* Historical Chart */} +
+
+

+ {selectedRate?.asset ?? selectedAsset} Price History +

+
+ {(Object.keys(INTERVAL_LABELS) as HistoryInterval[]).map((key) => ( + + ))} +
+
+ + {historyLoading ? ( +
+
+
+ ) : ( + + )} +
+ + {/* Conversion Calculator */} +
+ +
+
+ + {/* ── Stellar Network Fee Stats ── */} + {feeStats && ( +
+

+ Stellar Network Fee Stats +

+
+ {[ + { label: 'Base Fee', value: feeStats.baseFee, hint: 'stroops' }, + { label: 'Median (p50)', value: feeStats.p50Fee, hint: 'stroops' }, + { label: 'Recommended (p70)', value: feeStats.p70Fee, hint: 'stroops' }, + { label: 'High Priority (p99)', value: feeStats.p99Fee, hint: 'stroops' }, + ].map(({ label, value, hint }) => ( +
+

{label}

+

{value}

+

{hint}

+
+ ))} +
+
+ )} + + {/* ── Source Attribution ── */} +
+ + Rate data sourced from{' '} + + CoinGecko + {' '} + and{' '} + + Stellar Horizon + + . Rates auto-refresh every 60 seconds. + + For informational purposes only. +
+ +
+
+ + ); +} + +export const getServerSideProps: GetServerSideProps = async () => { + return { props: {} }; +}; diff --git a/src/pages/review.tsx b/src/pages/review.tsx new file mode 100644 index 00000000..c7ecedae --- /dev/null +++ b/src/pages/review.tsx @@ -0,0 +1,492 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import Head from 'next/head'; +import { Star, Shield, CheckCircle, ExternalLink, Info, Loader2 } from 'lucide-react'; +import { useRouter } from 'next/router'; +import { useAuth } from '@/contexts/AuthContext'; +import { useRating } from '@/hooks/useRating'; +import { useWallet } from '@/hooks/useWallet'; +import { GetServerSideProps } from 'next'; + +const STAR_LABELS = ['Terrible', 'Poor', 'Average', 'Good', 'Excellent']; + +interface RatingStats { + total_reviews: number; + average_rating: number; + rating_counts: number[]; +} + +const DEFAULT_STATS: RatingStats = { + total_reviews: 0, + average_rating: 0, + rating_counts: [0, 0, 0, 0, 0], +}; + +function StarRating({ + value, + hoverValue, + onSelect, + onHover, + onLeave, + disabled, +}: { + value: number; + hoverValue: number; + onSelect: (n: number) => void; + onHover: (n: number) => void; + onLeave: () => void; + disabled: boolean; +}) { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => { + const filled = star <= (hoverValue || value); + return ( + + ); + })} +
+ ); +} + +function RatingDistribution({ stats }: { stats: RatingStats }) { + const getPercentage = (stars: number) => { + if (stats.total_reviews === 0) return 0; + return Math.round(((stats.rating_counts[stars - 1] || 0) / stats.total_reviews) * 100); + }; + + return ( +
+ {[5, 4, 3, 2, 1].map((stars) => ( +
+ {stars} star +
+
+
+ + {getPercentage(stars)}% + +
+ ))} +
+ ); +} + +function ExistingReview({ + review, + isVerifying, + onVerify, +}: { + review: { rating: number; comment: string; transaction_hash: string }; + isVerifying: boolean; + onVerify: () => void; +}) { + return ( +
+
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+

+ {STAR_LABELS[review.rating - 1]} +

+

{review.comment}

+
+ + + Tx: {review.transaction_hash.slice(0, 10)}...{review.transaction_hash.slice(-8)} + + +
+
+ ); +} + +function ReviewForm({ + rating, + review, + isSubmitting, + onSubmit, + onRatingChange, + onReviewChange, +}: { + rating: number; + review: string; + isSubmitting: boolean; + onSubmit: (e: React.FormEvent) => void; + onRatingChange: (n: number) => void; + onReviewChange: (s: string) => void; +}) { + const [hoverRating, setHoverRating] = useState(0); + + return ( +
+
+ + setHoverRating(0)} + disabled={isSubmitting} + /> + {rating > 0 && ( +

+ {STAR_LABELS[rating - 1]} +

+ )} +
+ +
+ +