From feb849b62169cc8dce70246a6644c25d138be9e1 Mon Sep 17 00:00:00 2001 From: Martin <60740992+aster2333@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:06:37 +0800 Subject: [PATCH 1/3] feat: add backend API and solana challenge flows --- README.md | 117 +- backend/README.md | 34 + backend/__init__.py | 0 backend/app/__init__.py | 0 backend/app/config.py | 25 + backend/app/database.py | 24 + backend/app/main.py | 29 + backend/app/models.py | 69 + backend/app/routers/__init__.py | 3 + backend/app/routers/bets.py | 56 + backend/app/routers/challenges.py | 88 ++ backend/app/routers/health.py | 17 + backend/app/solana.py | 67 + backend/requirements.txt | 6 + src/components/ChallengeCard.tsx | 277 ++-- src/components/ui/textarea.tsx | 23 + src/lib/api.ts | 68 + src/lib/i18n.ts | 2 +- .../i18next-browser-languagedetector-shim.ts | 64 + src/lib/i18next-shim.ts | 114 ++ src/lib/react-i18next-shim.ts | 40 + src/locales/en/challenge.json | 15 +- src/locales/en/common.json | 7 + src/locales/en/home.json | 84 +- src/locales/zh/challenge.json | 15 +- src/locales/zh/common.json | 7 + src/locales/zh/home.json | 88 +- src/pages/ChallengeDetail.tsx | 1111 ++++------------- src/pages/CreateChallenge.tsx | 598 ++++----- src/pages/Home.tsx | 732 +++-------- src/store/useStore.ts | 6 +- tsconfig.json | 9 + types/api.ts | 55 + 33 files changed, 1641 insertions(+), 2209 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/__init__.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/bets.py create mode 100644 backend/app/routers/challenges.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/solana.py create mode 100644 backend/requirements.txt create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/lib/api.ts create mode 100644 src/lib/i18next-browser-languagedetector-shim.ts create mode 100644 src/lib/i18next-shim.ts create mode 100644 src/lib/react-i18next-shim.ts create mode 100644 types/api.ts diff --git a/README.md b/README.md index 7515059..526e8f9 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,66 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) +# Challenge Market + +Solana-native challenge market with a Vite + React frontend and FastAPI backend. Users can create challenges, back them with SOL on Devnet, and record verified bet transactions. + +## Project structure + +``` +. +├── backend/ # FastAPI application and SQLite persistence +├── src/ # React application source code +├── public/ +└── types/ # Shared TypeScript DTOs +``` + +## Prerequisites + +- Node.js 20+ +- Python 3.11+ +- Solana wallet (Phantom, Solflare, Backpack…) funded on Devnet + +## Environment variables + +Create a `.env` file (or export environment variables) with: + +```bash +# Frontend +VITE_API_BASE_URL=http://localhost:8000 +VITE_TREASURY_ADDRESS= +``` + +Optional backend overrides can be found in [`backend/README.md`](backend/README.md). + +## Running locally + +### Backend + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - extends: [ - // other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) +The API will start on `http://127.0.0.1:8000`. + +### Frontend + +```bash +npm install +npm run dev ``` + +Open `http://localhost:5173` and connect a Solana wallet on Devnet. Challenge creation calls the backend API, and placing a bet sends a SOL transfer via the connected wallet. The backend verifies the signature before recording the bet. + +## Testing bets on Devnet + +1. Request Devnet SOL from the [Solana faucet](https://faucet.solana.com/). +2. Create a challenge with a treasury address you control. +3. Use the “Buy YES/NO” buttons to send SOL. The signature is verified against the configured RPC before it is saved. + +## Production considerations + +- Configure a persistent database (e.g., Postgres) instead of SQLite. +- Securely manage escrow keypairs if the backend is responsible for challenge treasuries. +- Add rate limiting and authentication to the API before mainnet deployment. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..d8f7e1a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,34 @@ +# Challenge Market Backend + +FastAPI service that persists challenge metadata, records Solana bet transactions, and verifies transfer signatures on Devnet. + +## Quick start + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +The API listens on `http://127.0.0.1:8000` by default. Configure the base URL on the frontend with `VITE_API_BASE_URL`. + +## Environment variables + +| Variable | Description | Default | +| --- | --- | --- | +| `CHALLENGE_MARKET_DATABASE_URL` | SQL database URL | `sqlite:///./challenge_market.db` | +| `CHALLENGE_MARKET_SOLANA_RPC` | Solana RPC endpoint | `https://api.devnet.solana.com` | +| `CHALLENGE_MARKET_ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins | `http://localhost:5173,http://127.0.0.1:5173` | + +## API overview + +- `GET /health` – Healthcheck with Solana RPC status. +- `GET /api/challenges` – List all challenges with aggregated bet totals. +- `POST /api/challenges` – Create a new challenge. +- `GET /api/challenges/{id}` – Retrieve a single challenge and its bets. +- `POST /api/challenges/{id}/bets` – Record a bet after verifying the on-chain transfer. +- `GET /api/challenges/{id}/bets` – List bets for a challenge. + +All bet submissions are verified against the configured Solana RPC to ensure the transfer signature exists and matches the treasury address. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..e78b180 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,25 @@ +import os +from functools import lru_cache +from typing import List + + +class Settings: + """Application configuration with environment fallbacks.""" + + api_prefix: str = "/api" + + def __init__(self) -> None: + self.database_url: str = os.getenv("CHALLENGE_MARKET_DATABASE_URL", "sqlite:///./challenge_market.db") + self.solana_rpc_url: str = os.getenv("CHALLENGE_MARKET_SOLANA_RPC", "https://api.devnet.solana.com") + self.allowed_origins: List[str] = ( + os.getenv("CHALLENGE_MARKET_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173") + .split(",") + ) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..e62a463 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,24 @@ +from contextlib import contextmanager +from typing import Iterator + +from sqlmodel import Session, SQLModel, create_engine + +from .config import settings + + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=connect_args, echo=False) + + +def init_db() -> None: + """Create database tables.""" + SQLModel.metadata.create_all(engine) + + +@contextmanager +def get_session() -> Iterator[Session]: + session = Session(engine) + try: + yield session + finally: + session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..95d571d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings +from .database import init_db +from .routers import bets, challenges, health + + +def create_app() -> FastAPI: + init_db() + + app = FastAPI(title="Challenge Market API", version="0.1.0") + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(health.router) + app.include_router(challenges.router, prefix=settings.api_prefix) + app.include_router(bets.router, prefix=settings.api_prefix) + + return app + + +app = create_app() diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..6f4fd8b --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,69 @@ +from datetime import datetime, timedelta +from typing import Optional +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel + + +class ChallengeBase(SQLModel): + title: str + description: str + category: str + prize_pool: float = Field(default=0, ge=0) + duration_hours: int = Field(default=24, ge=1) + allow_random_stop: bool = False + image: Optional[str] = None + treasury_address: str + + +class Challenge(ChallengeBase, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True, index=True) + creator_public_key: str + created_at: datetime = Field(default_factory=datetime.utcnow) + deadline: datetime = Field(default_factory=lambda: datetime.utcnow() + timedelta(hours=24)) + status: str = Field(default="active") + + +class ChallengeCreate(ChallengeBase): + creator_public_key: str + + +class ChallengeRead(ChallengeBase): + id: UUID + creator_public_key: str + created_at: datetime + deadline: datetime + status: str + bet_count: int = 0 + total_bet_amount: float = 0 + yes_amount: float = 0 + no_amount: float = 0 + + +class BetBase(SQLModel): + challenge_id: UUID = Field(foreign_key="challenge.id") + bettor_public_key: str + amount: float = Field(ge=0) + side: str = Field(regex="^(yes|no)$") + signature: str + destination: str + + +class Bet(BetBase, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True, index=True) + recorded_at: datetime = Field(default_factory=datetime.utcnow) + status: str = Field(default="confirmed") + + +class BetCreate(BetBase): + pass + + +class BetRead(BetBase): + id: UUID + recorded_at: datetime + status: str + + +class ChallengeDetail(ChallengeRead): + bets: list[BetRead] = [] diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..91055fa --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,3 @@ +from . import bets, challenges, health + +__all__ = ["bets", "challenges", "health"] diff --git a/backend/app/routers/bets.py b/backend/app/routers/bets.py new file mode 100644 index 0000000..15a899d --- /dev/null +++ b/backend/app/routers/bets.py @@ -0,0 +1,56 @@ +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ..database import get_session +from ..models import Bet, BetCreate, BetRead, Challenge +from ..solana import solana_client + +router = APIRouter(prefix="/challenges", tags=["bets"]) + + +@router.post("/{challenge_id}/bets", response_model=BetRead, status_code=201) +def create_bet(challenge_id: UUID, payload: BetCreate, session: Session = Depends(get_session)) -> BetRead: + challenge = session.get(Challenge, challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + if payload.destination != challenge.treasury_address: + raise HTTPException(status_code=400, detail="Destination does not match challenge treasury") + + if payload.amount <= 0: + raise HTTPException(status_code=400, detail="Amount must be positive") + + if not solana_client.verify_transfer( + payload.signature, + destination=payload.destination, + min_amount=payload.amount, + ): + raise HTTPException(status_code=400, detail="Unable to verify transaction on Solana") + + bet = Bet( + challenge_id=challenge_id, + bettor_public_key=payload.bettor_public_key, + amount=payload.amount, + side=payload.side, + signature=payload.signature, + destination=payload.destination, + ) + + session.add(bet) + session.commit() + session.refresh(bet) + + return BetRead.model_validate(bet) + + +@router.get("/{challenge_id}/bets", response_model=List[BetRead]) +def list_bets(challenge_id: UUID, session: Session = Depends(get_session)) -> List[BetRead]: + challenge = session.get(Challenge, challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + bets = session.exec(select(Bet).where(Bet.challenge_id == challenge_id).order_by(Bet.recorded_at.desc())).all() + return [BetRead.model_validate(bet) for bet in bets] diff --git a/backend/app/routers/challenges.py b/backend/app/routers/challenges.py new file mode 100644 index 0000000..e7e7418 --- /dev/null +++ b/backend/app/routers/challenges.py @@ -0,0 +1,88 @@ +from datetime import datetime, timedelta +from typing import List +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from ..database import get_session +from ..models import Bet, BetRead, Challenge, ChallengeCreate, ChallengeDetail, ChallengeRead + +router = APIRouter(prefix="/challenges", tags=["challenges"]) + + +def _map_challenge(challenge: Challenge, session: Session) -> ChallengeRead: + bets = session.exec(select(Bet).where(Bet.challenge_id == challenge.id)).all() + total_bet_amount = sum(bet.amount for bet in bets) + yes_amount = sum(bet.amount for bet in bets if bet.side == "yes") + no_amount = sum(bet.amount for bet in bets if bet.side == "no") + + return ChallengeRead( + id=challenge.id, + title=challenge.title, + description=challenge.description, + category=challenge.category, + prize_pool=challenge.prize_pool, + duration_hours=challenge.duration_hours, + allow_random_stop=challenge.allow_random_stop, + image=challenge.image, + treasury_address=challenge.treasury_address, + creator_public_key=challenge.creator_public_key, + created_at=challenge.created_at, + deadline=challenge.deadline, + status=challenge.status, + bet_count=len(bets), + total_bet_amount=total_bet_amount, + yes_amount=yes_amount, + no_amount=no_amount, + ) + + +@router.get("/", response_model=List[ChallengeRead]) +def list_challenges(session: Session = Depends(get_session)) -> List[ChallengeRead]: + challenges = session.exec(select(Challenge).order_by(Challenge.created_at.desc())).all() + return [_map_challenge(challenge, session) for challenge in challenges] + + +@router.post("/", response_model=ChallengeRead, status_code=201) +def create_challenge(payload: ChallengeCreate, session: Session = Depends(get_session)) -> ChallengeRead: + now = datetime.utcnow() + deadline = now + timedelta(hours=payload.duration_hours) + + challenge = Challenge( + **payload.model_dump(), + created_at=now, + deadline=deadline, + ) + + session.add(challenge) + session.commit() + session.refresh(challenge) + + return _map_challenge(challenge, session) + + +@router.get("/{challenge_id}", response_model=ChallengeDetail) +def get_challenge(challenge_id: UUID, session: Session = Depends(get_session)) -> ChallengeDetail: + challenge = session.get(Challenge, challenge_id) + if not challenge: + raise HTTPException(status_code=404, detail="Challenge not found") + + challenge_read = _map_challenge(challenge, session) + bets = session.exec(select(Bet).where(Bet.challenge_id == challenge_id).order_by(Bet.recorded_at.desc())).all() + bet_reads = [ + BetRead( + id=bet.id, + challenge_id=bet.challenge_id, + bettor_public_key=bet.bettor_public_key, + amount=bet.amount, + side=bet.side, + signature=bet.signature, + destination=bet.destination, + recorded_at=bet.recorded_at, + status=bet.status, + ) + for bet in bets + ] + + return ChallengeDetail(**challenge_read.model_dump(), bets=bet_reads) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..3830d42 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from ..solana import solana_client + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def healthcheck() -> dict[str, str]: + try: + # Lightweight ping to ensure RPC reachable + version = solana_client._client.get_version() + rpc_status = "ok" if version.get("result") else "degraded" + except Exception: + rpc_status = "unreachable" + + return {"status": "ok", "solanaRpc": rpc_status} diff --git a/backend/app/solana.py b/backend/app/solana.py new file mode 100644 index 0000000..b36f228 --- /dev/null +++ b/backend/app/solana.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Optional + +from solana.rpc.api import Client +from solana.rpc.commitment import Confirmed + +from .config import settings + + +class SolanaClient: + """Lightweight wrapper around the Solana JSON RPC client.""" + + def __init__(self, endpoint: Optional[str] = None) -> None: + self.endpoint = endpoint or settings.solana_rpc_url + self._client = Client(self.endpoint) + + def verify_transfer(self, signature: str, *, destination: Optional[str] = None, min_amount: Optional[float] = None) -> bool: + """Verify that a transfer transaction reached the destination with at least the specified amount of SOL.""" + response = self._client.get_transaction(signature, commitment=Confirmed, encoding="jsonParsed") + + result = response.get("result") + if not result: + return False + + meta = result.get("meta") + if not meta or meta.get("err"): + return False + + transaction = result.get("transaction") + if not transaction: + return False + + message = transaction.get("message", {}) + instructions = message.get("instructions", []) + + found_transfer = False + lamports = 0 + dest = None + + for instruction in instructions: + parsed = instruction.get("parsed") + if not parsed: + continue + + if parsed.get("type") != "transfer": + continue + + info = parsed.get("info", {}) + dest = info.get("destination") + lamports = int(info.get("lamports", 0)) + found_transfer = True + break + + if not found_transfer: + return False + + if destination and dest != destination: + return False + + if min_amount is not None and lamports < int(min_amount * 1_000_000_000): + return False + + return True + + +solana_client = SolanaClient() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2eb6f5d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlmodel==0.0.22 +pydantic==2.10.5 +solana==0.35.1 +python-dotenv==1.0.1 diff --git a/src/components/ChallengeCard.tsx b/src/components/ChallengeCard.tsx index 7a8d0f1..5e79f01 100644 --- a/src/components/ChallengeCard.tsx +++ b/src/components/ChallengeCard.tsx @@ -1,203 +1,118 @@ -import React from 'react'; +import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Users } from 'lucide-react'; +import { Clock, Coins, Users } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Challenge } from '../store/useStore'; + +import { useStore, type Challenge } from '../store/useStore'; import { Button } from './ui/button'; -import image1 from '../assets/1.jpg'; -import image2 from '../assets/2.jpg'; -import image3 from '../assets/3.jpg'; interface ChallengeCardProps { challenge: Challenge; } -export const ChallengeCard: React.FC = ({ challenge }) => { - const { t } = useTranslation('common'); +const defaultImages = [ + 'https://images.solanarpc.io/challenges/running.jpg', + 'https://images.solanarpc.io/challenges/fitness.jpg', + 'https://images.solanarpc.io/challenges/learning.jpg', +]; + +const formatAmount = (amount: number): string => { + if (amount >= 1_000_000) { + return `${(amount / 1_000_000).toFixed(1)}M`; + } + if (amount >= 1_000) { + return `${(amount / 1_000).toFixed(1)}K`; + } + return amount.toFixed(2); +}; + +export const ChallengeCard = ({ challenge }: ChallengeCardProps) => { const navigate = useNavigate(); - - // 基于挑战ID选择固定的封面图片 - const images = [image1, image2, image3]; - const imageIndex = challenge.id ? parseInt(challenge.id) % images.length : 0; - const selectedImage = images[imageIndex]; - - // 使用挑战数据中的投票数据,如果没有则使用默认值 - const yesVotes = challenge.yesVotes || Math.floor(Math.random() * 100) + 50; - const delayVotes = challenge.delayVotes || Math.floor(Math.random() * 80) + 30; - const totalVotes = yesVotes + delayVotes; - - // 计算百分比 - const yesPercentage = totalVotes > 0 ? (yesVotes / totalVotes) * 100 : 50; - const delayPercentage = totalVotes > 0 ? (delayVotes / totalVotes) * 100 : 50; - - // 真实的挑战标题示例 - const challengeTitles = [ - t('challenge_card.titles.develop_dapp'), - t('challenge_card.titles.heartbeat_challenge'), - t('challenge_card.titles.no_sleep_challenge'), - t('challenge_card.titles.running_challenge'), - t('challenge_card.titles.coding_marathon'), - t('challenge_card.titles.fitness_challenge'), - t('challenge_card.titles.learn_skill'), - t('challenge_card.titles.creative_design'), - t('challenge_card.titles.music_creation'), - t('challenge_card.titles.cooking_challenge') - ]; - - // 真实的挑战描述示例 - const challengeDescriptions = [ - t('challenge_card.descriptions.develop_dapp'), - t('challenge_card.descriptions.heartbeat_challenge'), - t('challenge_card.descriptions.no_sleep_challenge'), - t('challenge_card.descriptions.running_challenge'), - t('challenge_card.descriptions.coding_marathon') - ]; - - // 获取挑战标题和描述 - const getRandomTitle = () => { - const index = challenge.id ? parseInt(challenge.id) % challengeTitles.length : 0; - return challengeTitles[index]; - }; - - const getRandomDescription = () => { - const index = challenge.id ? parseInt(challenge.id) % challengeDescriptions.length : 0; - return challengeDescriptions[index]; - }; + const { t } = useTranslation('common'); + const { isWalletConnected } = useStore(); - const formatTimeRemaining = (endTime: Date) => { - const now = new Date(); - const diff = endTime.getTime() - now.getTime(); - - if (diff <= 0) return t('challenge_card.status.ended'); - + const coverImage = useMemo(() => { + if (challenge.image) { + return challenge.image; + } + const index = Math.abs(challenge.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % defaultImages.length; + return defaultImages[index]; + }, [challenge.id, challenge.image]); + + const timeRemaining = useMemo(() => { + const diff = challenge.endTime.getTime() - Date.now(); + if (diff <= 0) { + return 'Ended'; + } const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - - if (hours > 24) { + if (hours >= 24) { const days = Math.floor(hours / 24); - const remainingHours = hours % 24; - return `${days}d ${remainingHours}h`; + return `${days}d ${hours % 24}h`; } - return `${hours}h ${minutes}m`; - }; - - // 按钮点击事件处理函数 - const handleBuyYes = (e: React.MouseEvent) => { - e.stopPropagation(); // 阻止事件冒泡,防止触发卡片点击 - navigate(`/challenge/${challenge.id}?action=buy&side=yes`); - }; - - const handleBuyDelay = (e: React.MouseEvent) => { - e.stopPropagation(); // 阻止事件冒泡,防止触发卡片点击 - navigate(`/challenge/${challenge.id}?action=buy&side=no`); - }; - - const handleChallengeReward = (e: React.MouseEvent) => { - e.stopPropagation(); // 阻止事件冒泡,防止触发卡片点击 - navigate(`/challenge/${challenge.id}`); - }; - - // 卡片点击事件处理函数 - const handleCardClick = () => { - navigate(`/challenge/${challenge.id}`); - }; - - // 金额格式化函数 - const formatAmount = (amount: number): string => { - if (amount >= 1000000) { - const millions = amount / 1000000; - return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; - } else if (amount >= 1000) { - const thousands = amount / 1000; - return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; - } else { - return amount % 1 === 0 ? `${amount}` : `${amount.toFixed(1)}`; - } - }; + }, [challenge.endTime]); return ( -
- {/* 主要内容区域 */} -
- {/* 左侧图片 */} - Challenge cover - - {/* 中间内容 */} -
-

- {getRandomTitle()} -

-

- {getRandomDescription()} -

+
+ + +
+ {!isWalletConnected && ( +

+ {t('messages.connect_to_bet')} +

+ )}
-
- - {/* 进度条 */} -
-
-
-
-
-
- - {/* 底部信息 */} -
-
- ATH ${formatAmount(challenge.poolAmount * 100 * 1.5)} -
-
- - {challenge.participantCount} -
-
- - {/* 三个按钮 */} -
- - - -
- + + ); -}; \ No newline at end of file +}; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000..f74cb15 --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +export const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +