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..d7f84a2 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,27 @@ +from contextlib import contextmanager +from typing import Iterator + +from sqlmodel import Session, SQLModel, create_engine + +from .config import settings +from .seed import seed_initial_data + + +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) + with Session(engine) as session: + seed_initial_data(session) + + +@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/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..9c23c7c --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,144 @@ +"""Utility helpers for seeding the local database with starter data.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Iterable + +from sqlmodel import Session, select + +from .models import Bet, Challenge + + +def seed_initial_data(session: Session) -> None: + """Populate the database with mock challenges and bets when empty.""" + + has_existing_data = session.exec(select(Challenge.id).limit(1)).first() + if has_existing_data: + return + + now = datetime.utcnow() + sample_challenges: Iterable[dict] = [ + { + "title": "Solana Step Challenge", + "description": "Complete 20,000 steps a day for three days and share your proof of activity on-chain.", + "category": "fitness", + "prize_pool": 12.5, + "duration_hours": 72, + "allow_random_stop": False, + "image": "https://images.solanarpc.io/challenges/fitness.jpg", + "treasury_address": "5E7PDc2zCiywqU8qR1s3LhCEr9B2SdS6uGX6NvJ3VtxP", + "creator_public_key": "5E7PDc2zCiywqU8qR1s3LhCEr9B2SdS6uGX6NvJ3VtxP", + "status": "active", + "created_offset_hours": 18, + "bets": [ + { + "bettor_public_key": "7tJ16tCrttzWcSPSXAd9k1BTBE6yEoZk6NMFNP5JjvJY", + "amount": 1.5, + "side": "yes", + "signature": "mock_signature_step_yes", + "destination": "Fdk5qP1s5vLYQJPuZd6kL5Gf7zYvGv1dHYdFDQnYv7xV", + }, + { + "bettor_public_key": "4KJe1ZgNn1o1hYt2Xb2fTmG2o1C8j1Hyv1Y5R5J5LwQL", + "amount": 0.9, + "side": "no", + "signature": "mock_signature_step_no", + "destination": "8fHq4n6UEA6wM2y5UpFuTQjKb8SYpqgqE5zJG1U2kz7r", + }, + ], + }, + { + "title": "On-Chain Buildathon Sprint", + "description": "Ship a Solana DeFi proof-of-concept before Friday and demo it in a public video call.", + "category": "business", + "prize_pool": 25.0, + "duration_hours": 120, + "allow_random_stop": True, + "image": "https://images.solanarpc.io/challenges/learning.jpg", + "treasury_address": "9yoQ1L4kqL2vR5AjPZrXjVLVSDh3GrbVEdEEVd1hnRCx", + "creator_public_key": "9yoQ1L4kqL2vR5AjPZrXjVLVSDh3GrbVEdEEVd1hnRCx", + "status": "voting", + "created_offset_hours": 54, + "bets": [ + { + "bettor_public_key": "3Gx8x1wXb4Xz6s13jCPs3JgXKm2P4dGQdL6wX7HsQm9L", + "amount": 3.2, + "side": "yes", + "signature": "mock_signature_build_yes", + "destination": "6Lt1xA2Ew2U5dL3pWrZV9yRg3SdJkqJLcvLxS5YVTjcg", + }, + { + "bettor_public_key": "7PsVnyL4FX5J3w2Hr9PDv1RJ5oGx2Yp3F1kN5cQ7Qy4C", + "amount": 2.1, + "side": "yes", + "signature": "mock_signature_build_yes_2", + "destination": "2kTYbFbS2Rf9f9xHYjd4xpp5fNJf3uS5fDD2h51ifLP4", + }, + ], + }, + { + "title": "Daily NFT Sketch Marathon", + "description": "Mint a new hand-drawn NFT every evening for a week and share the metadata with the group.", + "category": "creative", + "prize_pool": 6.5, + "duration_hours": 48, + "allow_random_stop": False, + "image": "https://images.solanarpc.io/challenges/creative.jpg", + "treasury_address": "6Q3G6jzP3p7dN6xD3z4f5c67YZq3B5v1h2Jw6cFs5nGk", + "creator_public_key": "6Q3G6jzP3p7dN6xD3z4f5c67YZq3B5v1h2Jw6cFs5nGk", + "status": "completed", + "created_offset_hours": 96, + "bets": [ + { + "bettor_public_key": "5b6Qn8Mc4Lp9wq7cB2fR1vTg8dL2zJx6kN4mH7sK0pVr", + "amount": 0.75, + "side": "yes", + "signature": "mock_signature_nft_yes", + "destination": "F6d3xZq4Vb8jMn2Qp5Rt6Ly7Wj8Cv9Bv1Nm2Qw3Er4Ty", + }, + { + "bettor_public_key": "2xN4qJ5h8Fr6Ts3Bw1Ld7Vs9Hk3Gp5Qw7Ey9Vt1Qm6Lp", + "amount": 1.1, + "side": "no", + "signature": "mock_signature_nft_no", + "destination": "H3kL5pO7sJ9dFg1Hj3Kl5Pn7Sd9Fg1Hj3Kl5Pn7Sd9Fg", + }, + ], + }, + ] + + for entry in sample_challenges: + created_at = now - timedelta(hours=entry.get("created_offset_hours", 0)) + duration_hours = entry["duration_hours"] + + challenge = Challenge( + title=entry["title"], + description=entry["description"], + category=entry["category"], + prize_pool=entry["prize_pool"], + duration_hours=duration_hours, + allow_random_stop=entry["allow_random_stop"], + image=entry.get("image"), + treasury_address=entry["treasury_address"], + creator_public_key=entry["creator_public_key"], + status=entry.get("status", "active"), + created_at=created_at, + deadline=created_at + timedelta(hours=duration_hours), + ) + + session.add(challenge) + session.flush() + + for bet_data in entry.get("bets", []): + bet = Bet( + challenge_id=challenge.id, + bettor_public_key=bet_data["bettor_public_key"], + amount=bet_data["amount"], + side=bet_data["side"], + signature=bet_data["signature"], + destination=bet_data["destination"], + ) + session.add(bet) + + session.commit() 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/App.tsx b/src/App.tsx index a312ea7..dc95d1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ function App() { -
+
} /> } /> 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/EnhancedWalletButton.tsx b/src/components/EnhancedWalletButton.tsx index 51eab57..85e5181 100644 --- a/src/components/EnhancedWalletButton.tsx +++ b/src/components/EnhancedWalletButton.tsx @@ -382,7 +382,7 @@ export function EnhancedWalletButton() { ) : (
- +

{t('wallet.no_extension')}

diff --git a/src/components/LocalWalletLogin.tsx b/src/components/LocalWalletLogin.tsx index 873ed92..075487b 100644 --- a/src/components/LocalWalletLogin.tsx +++ b/src/components/LocalWalletLogin.tsx @@ -154,7 +154,7 @@ export function LocalWalletLogin({ onSuccess, onCancel }: LocalWalletLoginProps)
- +
- + setShowPassword(!showPassword)} - className="absolute right-3 top-3 text-gray-400 hover:text-gray-600" + className="absolute right-3 top-3 text-muted-foreground hover:text-foreground" disabled={isLoading} > {showPassword ? : } @@ -200,7 +200,7 @@ export function LocalWalletLogin({ onSuccess, onCancel }: LocalWalletLoginProps)
- + setShowConfirmPassword(!showConfirmPassword)} - className="absolute right-3 top-3 text-gray-400 hover:text-gray-600" + className="absolute right-3 top-3 text-muted-foreground hover:text-foreground" disabled={isLoading} > {showConfirmPassword ? : } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 6ddbf9a..51d3e13 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { EnhancedWalletButton } from './EnhancedWalletButton'; import { Bell, Menu, Globe, Moon, Sun, BookOpen, Info, MessageCircle } from 'lucide-react'; import { Button } from './ui/button'; +import { cn } from '../lib/utils'; import { DropdownMenu, DropdownMenuContent, @@ -267,7 +268,17 @@ export const Navbar: React.FC = ({ hideBottomNav = false }) => { size="sm" className={isSpecial ? "bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-700 transform hover:scale-105" : ""} > - + {customIcon ? ( = ({ hideBottomNav = false }) => { className="w-4 h-4" /> ) : null} - {label} + + {label} + ))} diff --git a/src/components/SecurityAlert.tsx b/src/components/SecurityAlert.tsx index 273f10a..692751c 100644 --- a/src/components/SecurityAlert.tsx +++ b/src/components/SecurityAlert.tsx @@ -106,7 +106,7 @@ export const SecurityAlert: React.FC = ({ case 'unlock_required': return { - icon: , + icon: , title: t('common.wallet.wallet_locked'), description: t('common.wallet.unlock_required_desc'), actions: ( @@ -127,7 +127,7 @@ export const SecurityAlert: React.FC = ({

{t('wallet.browser_wallet')}

-

+

{publicKey ? formatAddress(publicKey.toString()) : t('wallet.not_connected')}

@@ -228,7 +228,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl

{t('wallet.mobile_wallet')}

-

+

{formatAddress(wcAccounts[0])}

@@ -265,7 +265,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl

{wallet.name}

-

+

{formatAddress(wallet.publicKey)}

{wallet.id === currentWallet?.id && ( @@ -296,7 +296,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl ))} {!connected && !wcConnected && wallets.length === 0 && ( -
+

{t('wallet.no_connected_wallets')}

@@ -318,7 +318,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl )}

{t('wallet.wallet_lock')}

-

+

{isLocked ? t('wallet.wallet_locked') : t('wallet.wallet_unlocked')}

@@ -371,7 +371,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl {t('buttons.update')}
-

+

{t('wallet.current_setting')}: {sessionTimeout || 30} {t('wallet.minutes')}

@@ -406,7 +406,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl

{session.peer.metadata.name}

-

+

{session.peer.metadata.url}

@@ -431,15 +431,15 @@ export const WalletManagement: React.FC = ({ isOpen, onCl
- {t('wallet.current_wallet')}: + {t('wallet.current_wallet')}: {currentWallet.name}
- {t('wallet.session_status')}: + {t('wallet.session_status')}: {t('wallet.active')}
- {t('wallet.auto_lock')}: + {t('wallet.auto_lock')}: {sessionTimeout || 30} {t('wallet.minutes')}
@@ -448,7 +448,7 @@ export const WalletManagement: React.FC = ({ isOpen, onCl )} {sessions.length === 0 && (!currentWallet || isLocked) && ( -
+

{t('wallet.no_active_sessions')}

diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 158b6c4..93957db 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -13,10 +13,10 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", + ghost: "text-foreground hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { 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 ( +