diff --git a/btc-portfolio/backend/Dockerfile b/btc-portfolio/backend/Dockerfile
new file mode 100644
index 0000000..7f2d0b9
--- /dev/null
+++ b/btc-portfolio/backend/Dockerfile
@@ -0,0 +1,12 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN mkdir -p /app/data
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/btc-portfolio/backend/app/__init__.py b/btc-portfolio/backend/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/btc-portfolio/backend/app/auth.py b/btc-portfolio/backend/app/auth.py
new file mode 100644
index 0000000..7d1993e
--- /dev/null
+++ b/btc-portfolio/backend/app/auth.py
@@ -0,0 +1,28 @@
+from datetime import datetime, timedelta
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+SECRET_KEY = "change-me-in-production-use-a-long-random-string"
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def hash_password(password: str) -> str:
+ return pwd_context.hash(password)
+
+
+def verify_password(plain: str, hashed: str) -> bool:
+ return pwd_context.verify(plain, hashed)
+
+
+def create_access_token(data: dict) -> str:
+ to_encode = data.copy()
+ expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+ to_encode["exp"] = expire
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def decode_token(token: str) -> dict:
+ return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
diff --git a/btc-portfolio/backend/app/database.py b/btc-portfolio/backend/app/database.py
new file mode 100644
index 0000000..10e1e1e
--- /dev/null
+++ b/btc-portfolio/backend/app/database.py
@@ -0,0 +1,22 @@
+import os
+from sqlalchemy import create_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./btc_portfolio.db")
+
+engine = create_engine(
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
+)
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/btc-portfolio/backend/app/dependencies.py b/btc-portfolio/backend/app/dependencies.py
new file mode 100644
index 0000000..edcebf0
--- /dev/null
+++ b/btc-portfolio/backend/app/dependencies.py
@@ -0,0 +1,29 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from jose import JWTError
+from sqlalchemy.orm import Session
+
+from .auth import decode_token
+from .database import get_db
+from . import models
+
+bearer_scheme = HTTPBearer()
+
+
+def get_current_user(
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
+ db: Session = Depends(get_db),
+) -> models.User:
+ token = credentials.credentials
+ try:
+ payload = decode_token(token)
+ username: str = payload.get("sub")
+ if username is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+ except JWTError:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+
+ user = db.query(models.User).filter(models.User.username == username).first()
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
+ return user
diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py
new file mode 100644
index 0000000..bafeb7e
--- /dev/null
+++ b/btc-portfolio/backend/app/main.py
@@ -0,0 +1,26 @@
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from .database import engine, Base
+from .routes import users, purchases, stats
+
+Base.metadata.create_all(bind=engine)
+
+app = FastAPI(title="BTC Portfolio API")
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["http://localhost:3000"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+app.include_router(users.router)
+app.include_router(purchases.router)
+app.include_router(stats.router)
+
+
+@app.get("/")
+def root():
+ return {"message": "BTC Portfolio API"}
diff --git a/btc-portfolio/backend/app/models.py b/btc-portfolio/backend/app/models.py
new file mode 100644
index 0000000..22fa09a
--- /dev/null
+++ b/btc-portfolio/backend/app/models.py
@@ -0,0 +1,26 @@
+from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
+from sqlalchemy.orm import relationship
+from datetime import datetime
+from .database import Base
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ username = Column(String, unique=True, index=True, nullable=False)
+ password = Column(String, nullable=False)
+
+ purchases = relationship("Purchase", back_populates="owner", cascade="all, delete")
+
+
+class Purchase(Base):
+ __tablename__ = "purchases"
+
+ id = Column(Integer, primary_key=True, index=True)
+ amount_eur = Column(Float, nullable=False)
+ price_eur = Column(Float, nullable=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+
+ owner = relationship("User", back_populates="purchases")
diff --git a/btc-portfolio/backend/app/routes/__init__.py b/btc-portfolio/backend/app/routes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py
new file mode 100644
index 0000000..58ebecf
--- /dev/null
+++ b/btc-portfolio/backend/app/routes/purchases.py
@@ -0,0 +1,67 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from pydantic import BaseModel
+from typing import List
+from datetime import datetime
+
+from ..database import get_db
+from .. import models
+from ..dependencies import get_current_user
+
+router = APIRouter()
+
+
+class PurchaseCreate(BaseModel):
+ amount_eur: float
+ price_eur: float
+
+
+class PurchaseOut(BaseModel):
+ id: int
+ amount_eur: float
+ price_eur: float
+ created_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+@router.get("/purchases", response_model=List[PurchaseOut])
+def list_purchases(
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ return db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
+
+
+@router.post("/purchases", response_model=PurchaseOut, status_code=status.HTTP_201_CREATED)
+def add_purchase(
+ purchase_in: PurchaseCreate,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ purchase = models.Purchase(
+ amount_eur=purchase_in.amount_eur,
+ price_eur=purchase_in.price_eur,
+ user_id=current_user.id,
+ )
+ db.add(purchase)
+ db.commit()
+ db.refresh(purchase)
+ return purchase
+
+
+@router.delete("/purchases/{purchase_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_purchase(
+ purchase_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ purchase = db.query(models.Purchase).filter(
+ models.Purchase.id == purchase_id,
+ models.Purchase.user_id == current_user.id,
+ ).first()
+ if not purchase:
+ raise HTTPException(status_code=404, detail="Purchase not found")
+ db.delete(purchase)
+ db.commit()
diff --git a/btc-portfolio/backend/app/routes/stats.py b/btc-portfolio/backend/app/routes/stats.py
new file mode 100644
index 0000000..b619099
--- /dev/null
+++ b/btc-portfolio/backend/app/routes/stats.py
@@ -0,0 +1,33 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from .. import models
+from ..dependencies import get_current_user
+from ..services.btc import get_btc_price_eur
+
+router = APIRouter()
+
+
+@router.get("/stats")
+def get_stats(
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+):
+ purchases = db.query(models.Purchase).filter(models.Purchase.user_id == current_user.id).all()
+
+ total_invested = sum(p.amount_eur for p in purchases)
+ total_btc = sum(p.amount_eur / p.price_eur for p in purchases) if purchases else 0.0
+ average_price = total_invested / total_btc if total_btc > 0 else 0.0
+ current_price = get_btc_price_eur()
+ portfolio_value = total_btc * current_price
+ profit_loss = portfolio_value - total_invested
+
+ return {
+ "total_invested": round(total_invested, 2),
+ "total_btc": round(total_btc, 8),
+ "average_price": round(average_price, 2),
+ "current_price": round(current_price, 2),
+ "portfolio_value": round(portfolio_value, 2),
+ "profit_loss": round(profit_loss, 2),
+ }
diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py
new file mode 100644
index 0000000..970210f
--- /dev/null
+++ b/btc-portfolio/backend/app/routes/users.py
@@ -0,0 +1,42 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from pydantic import BaseModel
+
+from ..database import get_db
+from .. import models
+from ..auth import hash_password, verify_password, create_access_token
+
+router = APIRouter()
+
+
+class UserCreate(BaseModel):
+ username: str
+ password: str
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str
+
+
+@router.post("/register", status_code=status.HTTP_201_CREATED)
+def register(user_in: UserCreate, db: Session = Depends(get_db)):
+ existing = db.query(models.User).filter(models.User.username == user_in.username).first()
+ if existing:
+ raise HTTPException(status_code=400, detail="Username already taken")
+ user = models.User(
+ username=user_in.username,
+ password=hash_password(user_in.password),
+ )
+ db.add(user)
+ db.commit()
+ return {"message": "User created"}
+
+
+@router.post("/login", response_model=Token)
+def login(user_in: UserCreate, db: Session = Depends(get_db)):
+ user = db.query(models.User).filter(models.User.username == user_in.username).first()
+ if not user or not verify_password(user_in.password, user.password):
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ token = create_access_token({"sub": user.username})
+ return {"access_token": token, "token_type": "bearer"}
diff --git a/btc-portfolio/backend/app/services/__init__.py b/btc-portfolio/backend/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py
new file mode 100644
index 0000000..e20acdb
--- /dev/null
+++ b/btc-portfolio/backend/app/services/btc.py
@@ -0,0 +1,14 @@
+import requests
+
+
+def get_btc_price_eur() -> float:
+ try:
+ resp = requests.get(
+ "https://api.coingecko.com/api/v3/simple/price",
+ params={"ids": "bitcoin", "vs_currencies": "eur"},
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return float(resp.json()["bitcoin"]["eur"])
+ except Exception:
+ return 0.0
diff --git a/btc-portfolio/backend/requirements.txt b/btc-portfolio/backend/requirements.txt
new file mode 100644
index 0000000..9c39138
--- /dev/null
+++ b/btc-portfolio/backend/requirements.txt
@@ -0,0 +1,8 @@
+fastapi
+uvicorn[standard]
+sqlalchemy
+passlib[bcrypt]
+bcrypt==3.2.2
+python-jose[cryptography]
+requests
+python-multipart
diff --git a/btc-portfolio/data/btc_portfolio.db b/btc-portfolio/data/btc_portfolio.db
new file mode 100644
index 0000000..6d9ecdf
Binary files /dev/null and b/btc-portfolio/data/btc_portfolio.db differ
diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml
new file mode 100644
index 0000000..ef03644
--- /dev/null
+++ b/btc-portfolio/docker-compose.yml
@@ -0,0 +1,20 @@
+services:
+ backend:
+ build: ./backend
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./data:/app/data
+ environment:
+ - DATABASE_URL=sqlite:////app/data/btc_portfolio.db
+ restart: unless-stopped
+
+ frontend:
+ build: ./frontend
+ ports:
+ - "3000:3000"
+ environment:
+ - REACT_APP_API_URL=http://localhost:8000
+ depends_on:
+ - backend
+ restart: unless-stopped
diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile
new file mode 100644
index 0000000..4975171
--- /dev/null
+++ b/btc-portfolio/frontend/Dockerfile
@@ -0,0 +1,16 @@
+FROM node:18-alpine AS build
+
+WORKDIR /app
+COPY package.json ./
+RUN npm install
+COPY . .
+ARG REACT_APP_API_URL=http://localhost:8000
+ENV REACT_APP_API_URL=$REACT_APP_API_URL
+RUN npm run build
+
+FROM node:18-alpine
+RUN npm install -g serve
+WORKDIR /app
+COPY --from=build /app/build ./build
+EXPOSE 3000
+CMD ["serve", "-s", "build", "-l", "3000"]
diff --git a/btc-portfolio/frontend/package.json b/btc-portfolio/frontend/package.json
new file mode 100644
index 0000000..77acbe6
--- /dev/null
+++ b/btc-portfolio/frontend/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "btc-portfolio-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.22.0",
+ "react-scripts": "5.0.1",
+ "chart.js": "^4.4.0",
+ "react-chartjs-2": "^5.2.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build"
+ },
+ "browserslist": {
+ "production": [">0.2%", "not dead", "not op_mini all"],
+ "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
+ }
+}
diff --git a/btc-portfolio/frontend/public/index.html b/btc-portfolio/frontend/public/index.html
new file mode 100644
index 0000000..e2bab94
--- /dev/null
+++ b/btc-portfolio/frontend/public/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ BTC Portfolio
+
+
+
+
+
+
diff --git a/btc-portfolio/frontend/src/App.js b/btc-portfolio/frontend/src/App.js
new file mode 100644
index 0000000..0774d39
--- /dev/null
+++ b/btc-portfolio/frontend/src/App.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import Login from './pages/Login';
+import Register from './pages/Register';
+import Dashboard from './pages/Dashboard';
+
+function PrivateRoute({ children }) {
+ return localStorage.getItem('token') ? children : ;
+}
+
+export default function App() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js
new file mode 100644
index 0000000..6b1e04b
--- /dev/null
+++ b/btc-portfolio/frontend/src/components/AddPurchase.js
@@ -0,0 +1,76 @@
+import React, { useState } from 'react';
+
+const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+
+const styles = {
+ card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
+ title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
+ row: { display: 'flex', gap: '0.75rem', flexWrap: 'wrap' },
+ input: { flex: 1, minWidth: '140px', padding: '0.65rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
+ button: { padding: '0.65rem 1.5rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, cursor: 'pointer' },
+ error: { color: '#ff6b6b', marginTop: '0.5rem', fontSize: '0.9rem' },
+};
+
+export default function AddPurchase({ onAdded }) {
+ const [amountEur, setAmountEur] = useState('');
+ const [priceEur, setPriceEur] = useState('');
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ const token = localStorage.getItem('token');
+ try {
+ const res = await fetch(`${API}/purchases`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ amount_eur: parseFloat(amountEur),
+ price_eur: parseFloat(priceEur),
+ }),
+ });
+ if (!res.ok) {
+ setError('Failed to add purchase');
+ return;
+ }
+ setAmountEur('');
+ setPriceEur('');
+ onAdded();
+ } catch {
+ setError('Network error');
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js
new file mode 100644
index 0000000..a0e3e8d
--- /dev/null
+++ b/btc-portfolio/frontend/src/components/PortfolioChart.js
@@ -0,0 +1,100 @@
+import React, { useRef } from 'react';
+import {
+ Chart as ChartJS,
+ LineElement,
+ PointElement,
+ LinearScale,
+ CategoryScale,
+ Tooltip,
+ Legend,
+ Filler,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+
+ChartJS.register(LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler);
+
+const styles = {
+ card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
+ title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
+ saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
+};
+
+export default function PortfolioChart({ purchases, stats }) {
+ const chartRef = useRef(null);
+
+ if (!purchases || purchases.length === 0) return null;
+
+ // Build cumulative data points sorted by date
+ const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+
+ let cumInvested = 0;
+ let cumBtc = 0;
+ const labels = [];
+ const portfolioValues = [];
+ const investedValues = [];
+
+ sorted.forEach((p, i) => {
+ cumInvested += p.amount_eur;
+ cumBtc += p.amount_eur / p.price_eur;
+ const currentVal = cumBtc * (stats?.current_price || p.price_eur);
+ labels.push(new Date(p.created_at).toLocaleDateString());
+ portfolioValues.push(parseFloat(currentVal.toFixed(2)));
+ investedValues.push(parseFloat(cumInvested.toFixed(2)));
+ });
+
+ const currentPrice = stats?.current_price || 0;
+ const breakEvenLine = labels.map(() => stats?.average_price || 0);
+
+ const data = {
+ labels,
+ datasets: [
+ {
+ label: 'Portfolio Value (€)',
+ data: portfolioValues,
+ borderColor: '#f7931a',
+ backgroundColor: 'rgba(247,147,26,0.1)',
+ fill: true,
+ tension: 0.3,
+ },
+ {
+ label: 'Total Invested (€)',
+ data: investedValues,
+ borderColor: '#4fc3f7',
+ backgroundColor: 'transparent',
+ borderDash: [6, 3],
+ tension: 0.3,
+ },
+ ],
+ };
+
+ const options = {
+ responsive: true,
+ plugins: {
+ legend: { labels: { color: '#aaa' } },
+ tooltip: { mode: 'index', intersect: false },
+ },
+ scales: {
+ x: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
+ y: { ticks: { color: '#666' }, grid: { color: '#2a2a2a' } },
+ },
+ };
+
+ const handleSave = () => {
+ const chart = chartRef.current;
+ if (!chart) return;
+ const url = chart.toBase64Image();
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'btc-portfolio.png';
+ a.click();
+ };
+
+ return (
+
+
Portfolio Chart
+
+
+
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/components/PurchaseList.js b/btc-portfolio/frontend/src/components/PurchaseList.js
new file mode 100644
index 0000000..2c82705
--- /dev/null
+++ b/btc-portfolio/frontend/src/components/PurchaseList.js
@@ -0,0 +1,58 @@
+import React from 'react';
+
+const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+
+const styles = {
+ card: { background: '#1a1a1a', padding: '1.5rem', borderRadius: '12px', border: '1px solid #333', marginBottom: '1.5rem' },
+ title: { fontSize: '1.1rem', fontWeight: 700, marginBottom: '1rem', color: '#f7931a' },
+ table: { width: '100%', borderCollapse: 'collapse' },
+ th: { textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '1px solid #333', color: '#888', fontSize: '0.85rem' },
+ td: { padding: '0.6rem 0.75rem', borderBottom: '1px solid #222', fontSize: '0.95rem' },
+ deleteBtn: { background: 'none', border: '1px solid #555', color: '#ff6b6b', borderRadius: '6px', padding: '0.25rem 0.6rem', cursor: 'pointer', fontSize: '0.85rem' },
+ empty: { color: '#555', textAlign: 'center', padding: '1rem' },
+};
+
+export default function PurchaseList({ purchases, onDeleted }) {
+ const handleDelete = async (id) => {
+ const token = localStorage.getItem('token');
+ await fetch(`${API}/purchases/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ onDeleted();
+ };
+
+ return (
+
+
Purchases
+ {purchases.length === 0 ? (
+
No purchases yet.
+ ) : (
+
+
+
+ | Date |
+ Amount (€) |
+ Price (€/BTC) |
+ BTC |
+ |
+
+
+
+ {purchases.map(p => (
+
+ | {new Date(p.created_at).toLocaleDateString()} |
+ €{p.amount_eur.toLocaleString()} |
+ €{p.price_eur.toLocaleString()} |
+ {(p.amount_eur / p.price_eur).toFixed(6)} |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/index.js b/btc-portfolio/frontend/src/index.js
new file mode 100644
index 0000000..217069b
--- /dev/null
+++ b/btc-portfolio/frontend/src/index.js
@@ -0,0 +1,6 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+root.render();
diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js
new file mode 100644
index 0000000..cd13a85
--- /dev/null
+++ b/btc-portfolio/frontend/src/pages/Dashboard.js
@@ -0,0 +1,99 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import AddPurchase from '../components/AddPurchase';
+import PurchaseList from '../components/PurchaseList';
+import PortfolioChart from '../components/PortfolioChart';
+
+const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+
+const styles = {
+ app: { maxWidth: '900px', margin: '0 auto', padding: '1.5rem' },
+ header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' },
+ logo: { fontSize: '1.4rem', fontWeight: 700, color: '#f7931a' },
+ logoutBtn: { background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '8px', padding: '0.4rem 1rem', cursor: 'pointer' },
+ statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem', marginBottom: '1.5rem' },
+ statCard: { background: '#1a1a1a', padding: '1rem', borderRadius: '12px', border: '1px solid #333' },
+ statLabel: { color: '#888', fontSize: '0.8rem', marginBottom: '0.3rem' },
+ statValue: { fontSize: '1.2rem', fontWeight: 700 },
+ positive: { color: '#6bff8e' },
+ negative: { color: '#ff6b6b' },
+ neutral: { color: '#f7931a' },
+};
+
+function StatCard({ label, value, highlight }) {
+ return (
+
+ );
+}
+
+export default function Dashboard() {
+ const [stats, setStats] = useState(null);
+ const [purchases, setPurchases] = useState([]);
+ const navigate = useNavigate();
+
+ const authHeaders = () => ({
+ Authorization: `Bearer ${localStorage.getItem('token')}`,
+ });
+
+ const fetchData = useCallback(async () => {
+ try {
+ const [statsRes, purchasesRes] = await Promise.all([
+ fetch(`${API}/stats`, { headers: authHeaders() }),
+ fetch(`${API}/purchases`, { headers: authHeaders() }),
+ ]);
+ if (statsRes.status === 401) {
+ localStorage.removeItem('token');
+ navigate('/login');
+ return;
+ }
+ setStats(await statsRes.json());
+ setPurchases(await purchasesRes.json());
+ } catch {
+ // silently fail — network may be unavailable
+ }
+ }, [navigate]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ const handleLogout = () => {
+ localStorage.removeItem('token');
+ navigate('/login');
+ };
+
+ const plHighlight = stats
+ ? stats.profit_loss >= 0 ? 'positive' : 'negative'
+ : 'neutral';
+
+ return (
+
+
+
₿ BTC Portfolio
+
+
+
+ {stats && (
+
+
+
+
+
+
+ = 0 ? '+' : ''}€${stats.profit_loss.toLocaleString()}`}
+ highlight={plHighlight}
+ />
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/pages/Login.js b/btc-portfolio/frontend/src/pages/Login.js
new file mode 100644
index 0000000..25f4cd9
--- /dev/null
+++ b/btc-portfolio/frontend/src/pages/Login.js
@@ -0,0 +1,58 @@
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+
+const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+
+const styles = {
+ container: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' },
+ card: { background: '#1a1a1a', padding: '2rem', borderRadius: '12px', width: '360px', border: '1px solid #333' },
+ title: { fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', color: '#f7931a' },
+ input: { width: '100%', padding: '0.75rem', marginBottom: '1rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
+ button: { width: '100%', padding: '0.75rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, fontSize: '1rem', cursor: 'pointer' },
+ error: { color: '#ff6b6b', marginBottom: '1rem', fontSize: '0.9rem' },
+ link: { display: 'block', textAlign: 'center', marginTop: '1rem', color: '#888', textDecoration: 'none' },
+};
+
+export default function Login() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ try {
+ const res = await fetch(`${API}/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ setError(data.detail || 'Login failed');
+ return;
+ }
+ const data = await res.json();
+ localStorage.setItem('token', data.access_token);
+ navigate('/');
+ } catch {
+ setError('Network error');
+ }
+ };
+
+ return (
+
+
+
₿ BTC Portfolio
+
+
No account? Register
+
+
+ );
+}
diff --git a/btc-portfolio/frontend/src/pages/Register.js b/btc-portfolio/frontend/src/pages/Register.js
new file mode 100644
index 0000000..803ff07
--- /dev/null
+++ b/btc-portfolio/frontend/src/pages/Register.js
@@ -0,0 +1,61 @@
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+
+const API = process.env.REACT_APP_API_URL || 'http://localhost:8000';
+
+const styles = {
+ container: { display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' },
+ card: { background: '#1a1a1a', padding: '2rem', borderRadius: '12px', width: '360px', border: '1px solid #333' },
+ title: { fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', color: '#f7931a' },
+ input: { width: '100%', padding: '0.75rem', marginBottom: '1rem', background: '#2a2a2a', border: '1px solid #444', borderRadius: '8px', color: '#e0e0e0', fontSize: '1rem' },
+ button: { width: '100%', padding: '0.75rem', background: '#f7931a', color: '#000', border: 'none', borderRadius: '8px', fontWeight: 700, fontSize: '1rem', cursor: 'pointer' },
+ error: { color: '#ff6b6b', marginBottom: '1rem', fontSize: '0.9rem' },
+ success: { color: '#6bff8e', marginBottom: '1rem', fontSize: '0.9rem' },
+ link: { display: 'block', textAlign: 'center', marginTop: '1rem', color: '#888', textDecoration: 'none' },
+};
+
+export default function Register() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setSuccess('');
+ try {
+ const res = await fetch(`${API}/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!res.ok) {
+ const data = await res.json();
+ setError(data.detail || 'Registration failed');
+ return;
+ }
+ setSuccess('Account created! Redirecting...');
+ setTimeout(() => navigate('/login'), 1500);
+ } catch {
+ setError('Network error');
+ }
+ };
+
+ return (
+
+
+
₿ Create Account
+
+
Already have an account? Login
+
+
+ );
+}