initial commit, create repo backend and frontend

This commit is contained in:
david 2026-02-14 19:56:10 +03:00
commit b883898ed8
57 changed files with 10992 additions and 0 deletions

21
.env.example Normal file
View file

@ -0,0 +1,21 @@
# Flask Configuration
FLASK_ENV=dev
SECRET_KEY=dev-secret-key-change-in-production
JWT_SECRET_KEY=dev-jwt-secret-key-change-in-production
CORS_ORIGINS=*
# Database Configuration (for Docker dev services)
POSTGRES_USER=crafting
POSTGRES_PASSWORD=devpassword
POSTGRES_DB=crafting_shop
# Grafana Configuration
GRAFANA_USER=admin
GRAFANA_PASSWORD=change-this-password-in-production
# Optional: External Services
# REDIS_URL=redis://localhost:6379/0
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@gmail.com
# SMTP_PASSWORD=your-smtp-password

56
.github/workflows/cd.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: CD
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push backend
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: crafting-shop-backend
IMAGE_TAG: ${{ github.sha }}
run: |
cd backend
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Build and push frontend
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: crafting-shop-frontend
IMAGE_TAG: ${{ github.sha }}
run: |
cd frontend
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: crafting-shop-task
service: crafting-shop-service
cluster: crafting-shop-cluster
wait-for-service-stability: true

123
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,123 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Cache pip packages
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
cd backend
python -m pip install --upgrade pip
pip install -r requirements/dev.txt
- name: Lint with flake8
run: |
cd backend
flake8 app tests --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 app tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Run tests
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
SECRET_KEY: test-secret-key
JWT_SECRET_KEY: test-jwt-secret
FLASK_ENV: testing
run: |
cd backend
pytest --cov=app --cov-report=xml --cov-report=term
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./backend/coverage.xml
flags: backend
name: backend-coverage
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Lint
run: |
cd frontend
npm run lint
- name: Run tests
run: |
cd frontend
npm test -- --run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./frontend/coverage/coverage-final.json
flags: frontend
name: frontend-coverage
build:
runs-on: ubuntu-latest
needs: [backend-test, frontend-test]
steps:
- uses: actions/checkout@v3
- name: Build backend
run: |
cd backend
docker build -t crafting-shop-backend:test .
- name: Build frontend
run: |
cd frontend
docker build -t crafting-shop-frontend:test .

82
.gitignore vendored Normal file
View file

@ -0,0 +1,82 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
env/
ENV/
.venv
# Flask
instance/
.webassets-cache
# Environment variables
.env
.env.local
.env.*.local
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Database
*.db
*.sqlite
*.sqlite3
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build output
dist/
build/
# Testing
coverage/
.nyc_output/
.pytest_cache/
.coverage
htmlcov/
# Docker
.dockerignore
# Temporary files
*.tmp
*.temp
.cache/

109
Makefile Normal file
View file

@ -0,0 +1,109 @@
.PHONY: help install dev-services dev-stop-services dev-backend dev-frontend dev build test lint clean up down restart logs
help: ## Show this help message
@echo "Available commands:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies
@echo "Installing backend dependencies..."
cd backend && python -m venv venv
. backend/venv/bin/activate && pip install -r backend/requirements/dev.txt
@echo "Installing frontend dependencies..."
cd frontend && npm install
dev-services: ## Start development services (postgres & redis only)
@echo "Starting development services (postgres & redis)..."
docker compose -f docker-compose.dev.yml up -d
dev-stop-services: ## Stop development services
@echo "Stopping development services..."
docker compose -f docker-compose.dev.yml down
dev-backend: ## Start backend server locally
@echo "Starting backend server..."
cd backend && source venv/bin/activate && flask run
dev-frontend: ## Start frontend server locally
@echo "Starting frontend server..."
cd frontend && npm run dev
dev: ## Start development environment
@echo "Starting development environment..."
docker-compose -f docker-compose.dev.yml up -d
@echo "In one terminal run: make dev-backend"
@echo "In another terminal run: make dev-frontend"
build: ## Build Docker images
@echo "Building Docker images..."
docker compose build
up: ## Start all services
@echo "Starting all services..."
docker compose up -d
down: ## Stop all services
@echo "Stopping all services..."
docker compose down
restart: ## Restart all services
@echo "Restarting all services..."
docker compose restart
logs: ## Show logs from all services
docker compose logs -f
logs-backend: ## Show backend logs
docker compose logs -f backend
logs-frontend: ## Show frontend logs
docker compose logs -f frontend
test: ## Run all tests
@echo "Running backend tests..."
cd backend && . venv/bin/activate && pytest
@echo "Running frontend tests..."
cd frontend && npm test
test-backend: ## Run backend tests only
cd backend && . venv/bin/activate && pytest
test-frontend: ## Run frontend tests only
cd frontend && npm test
lint: ## Run linting
@echo "Linting backend..."
cd backend && . venv/bin/activate && flake8 app tests
@echo "Linting frontend..."
cd frontend && npm run lint
lint-backend: ## Lint backend only
cd backend && . venv/bin/activate && flake8 app tests
lint-frontend: ## Lint frontend only
cd frontend && npm run lint
format: ## Format code
@echo "Formatting backend..."
cd backend && . venv/bin/activate && black app tests && isort app tests
@echo "Formatting frontend..."
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
migrate: ## Run database migrations
cd backend && . venv/bin/activate && flask db upgrade
shell: ## Open Flask shell
cd backend && . venv/bin/activate && flask shell
clean: ## Clean up build artifacts
@echo "Cleaning up..."
cd backend && rm -rf __pycache__ *.pyc .pytest_cache
cd frontend && rm -rf dist node_modules/.cache
prune: ## Remove unused Docker resources
docker system prune -a
backup: ## Backup database
docker exec crafting-shop-postgres pg_dump -U crafting crafting_shop > backup.sql
restore: ## Restore database (usage: make restore FILE=backup.sql)
docker exec -i crafting-shop-postgres psql -U crafting crafting_shop < $(FILE)

390
README.md Normal file
View file

@ -0,0 +1,390 @@
# Crafting Shop - Fullstack E-commerce Application
A production-ready fullstack e-commerce application built with Flask (backend) and React (frontend), featuring user authentication, product management, shopping cart, and order processing.
## 🏗️ Architecture
### Backend
- **Framework**: Flask 3.x (Application Factory Pattern)
- **Language**: Python 3.11
- **ORM**: SQLAlchemy (with Flask-SQLAlchemy)
- **Database**: PostgreSQL
- **Caching**: Redis
- **Authentication**: JWT tokens (Flask-JWT-Extended)
- **API**: RESTful API with Blueprint
- **Migrations**: Flask-Migrate
### Frontend
- **Framework**: React 18
- **Build Tool**: Vite
- **Styling**: Tailwind CSS
- **State Management**: React Context API
- **HTTP Client**: Axios
- **Routing**: React Router
### Infrastructure
- **Containerization**: Docker & Docker Compose
- **Reverse Proxy**: Nginx
- **Monitoring**: Prometheus & Grafana
- **CI/CD**: GitHub Actions
## 📁 Project Structure
```
flask-react-monorepo/
├── backend/
│ ├── app/
│ │ ├── __init__.py # Flask application factory
│ │ ├── config.py # Configuration
│ │ ├── models/ # Database models
│ │ │ ├── user.py
│ │ │ ├── product.py
│ │ │ └── order.py
│ │ ├── routes/ # API routes
│ │ │ ├── api.py
│ │ │ └── health.py
│ │ ├── services/ # Business logic
│ │ └── utils/ # Utilities
│ ├── tests/ # Backend tests
│ ├── requirements/
│ │ ├── base.txt # Production dependencies
│ │ ├── dev.txt # Development dependencies
│ │ └── prod.txt # Production-only dependencies
│ ├── .env.example # Environment variables template
│ ├── Dockerfile # Backend Docker image
│ └── wsgi.py # WSGI entry point
├── frontend/
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── context/ # React Context API
│ │ ├── hooks/ # Custom React hooks
│ │ ├── pages/ # Page components
│ │ ├── services/ # API services
│ │ ├── App.jsx # Root component
│ │ └── main.jsx # Entry point
│ ├── public/ # Static assets
│ ├── tests/ # Frontend tests
│ ├── .env.example # Environment variables template
│ ├── Dockerfile # Frontend Docker image
│ ├── nginx.conf # Nginx configuration
│ ├── package.json # Node dependencies
│ ├── vite.config.js # Vite configuration
│ ├── tailwind.config.js # Tailwind CSS configuration
│ └── postcss.config.js # PostCSS configuration
├── infrastructure/
│ ├── docker-compose/
│ │ ├── docker-compose.dev.yml
│ │ └── docker-compose.prod.yml
│ ├── nginx/
│ │ ├── nginx.conf
│ │ └── sites/
│ ├── monitoring/
│ │ └── prometheus.yml
│ └── scripts/ # Deployment scripts
│ ├── deploy.sh
│ ├── backup.sh
│ └── healthcheck.sh
├── scripts/
│ ├── dev.sh # Development setup script
│ └── test.sh # Test runner script
├── .github/
│ └── workflows/
│ ├── ci.yml # Continuous Integration
│ └── cd.yml # Continuous Deployment
├── docker-compose.yml # Main Docker Compose configuration
├── .env.example # Root environment variables template
├── .gitignore # Git ignore rules
├── Makefile # Common commands
└── README.md # This file
```
## 🚀 Getting Started
### Prerequisites
- Docker and Docker Compose
- Python 3.11+
- Node.js 18+
- Git
### Installation
1. **Clone repository**
```bash
git clone <repository-url>
cd crafting_shop_app
```
2. **Copy environment files**
```bash
cp .env.example .env
cp backend/.env.example backend/.env
```
3. **Update environment variables** (optional for dev, required for production)
Edit `.env` and `backend/.env` with your configuration
4. **Install dependencies**
```bash
make install
```
### Development Mode (Recommended - Local Servers)
This is the best approach for development - runs backend and frontend locally with Docker for database/redis only.
```bash
# 1. Start development services (postgres & redis only)
make dev-services
# 2. Initialize database (first time only)
cd backend
source venv/bin/activate
flask db init
flask db migrate -m "Initial migration"
flask db upgrade
# 3. Start backend server (in terminal 1)
make dev-backend
# 4. Start frontend server (in terminal 2)
make dev-frontend
```
**Access URLs:**
- Frontend: http://localhost:5173
- Backend API: http://localhost:5000
- PostgreSQL: localhost:5432
- Redis: localhost:6379
**Stop services:**
```bash
make dev-stop-services
```
### Production Mode (Docker)
For production deployment using Docker Compose:
```bash
# 1. Build and start all services
make build
make up
# 2. Access application
# Frontend: http://localhost
# Backend API: http://localhost:5000
# Grafana: http://localhost:3001
# Prometheus: http://localhost:9090
```
## 🛠️ Development
### Setup Development Environment
```bash
# Run the setup script
./scripts/dev.sh
# Or manually:
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements/dev.txt
cd ../frontend
npm install
```
### Running Development Servers
```bash
# Using Docker Compose
docker-compose up
# Or run services individually:
cd backend
source venv/bin/activate
flask run
# In another terminal:
cd frontend
npm run dev
```
### Database Management
```bash
# Create migrations
cd backend
source venv/bin/activate
flask db migrate -m "migration message"
# Apply migrations
flask db upgrade
# Open Flask shell
flask shell
```
### Testing
```bash
# Run all tests
make test
# Run backend tests only
make test-backend
# Run frontend tests only
make test-frontend
# Or use the test script
./scripts/test.sh
```
### Code Quality
```bash
# Lint code
make lint
# Format code
make format
```
## 📦 Available Commands
### Development (Local)
```bash
make install # Install all dependencies
make dev-services # Start postgres & redis in Docker
make dev-stop-services # Stop postgres & redis
make dev-backend # Start Flask server locally
make dev-frontend # Start Vite dev server locally
make dev # Start dev environment (services + instructions)
```
### Production (Docker)
```bash
make build # Build Docker images
make up # Start all services in Docker
make down # Stop all services
make restart # Restart services
make logs # View logs
```
### Testing & Quality
```bash
make test # Run all tests
make test-backend # Run backend tests only
make test-frontend # Run frontend tests only
make lint # Run all linters
make lint-backend # Lint backend only
make lint-frontend # Lint frontend only
make format # Format all code
```
### Database & Utilities
```bash
make migrate # Run database migrations
make shell # Open Flask shell
make clean # Clean build artifacts
make prune # Remove unused Docker resources
make backup # Backup database
```
## 🔧 Configuration
### Backend Configuration
The backend uses environment variables defined in `backend/.env`:
- `FLASK_ENV`: Environment (development/production)
- `SECRET_KEY`: Flask secret key
- `JWT_SECRET_KEY`: JWT signing key
- `DATABASE_URL`: PostgreSQL connection string
### Frontend Configuration
The frontend is configured through:
- `vite.config.js`: Vite build configuration
- `tailwind.config.js`: Tailwind CSS configuration
- `.env`: Environment variables (if needed)
### Infrastructure Configuration
- `docker-compose.yml`: Service orchestration
- `infrastructure/monitoring/prometheus.yml`: Metrics scraping
## 🧪 Testing
### Backend Tests
```bash
cd backend
pytest --cov=app --cov-report=html
```
### Frontend Tests
```bash
cd frontend
npm test -- --run --coverage
```
## 🚢 Deployment
### Using Docker Compose
```bash
# Production deployment
docker-compose -f docker-compose.prod.yml up -d
```
### Using CI/CD
The GitHub Actions workflow handles:
1. **CI**: Runs tests on every push/PR
2. **CD**: Deploys to production on main branch push
### Manual Deployment
1. Build and push Docker images
2. Update ECS task definitions
3. Deploy to ECS cluster
## 📊 Monitoring
- **Prometheus**: http://localhost:9090
- Metrics from backend, database, and Redis
- **Grafana**: http://localhost:3001
- Default credentials: admin / admin (change in .env)
- Dashboards for application monitoring
## 🔒 Security
- JWT-based authentication
- Environment variable management
- CORS configuration
- SQL injection prevention (SQLAlchemy ORM)
- XSS prevention (React escaping)
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📝 License
This project is licensed under the MIT License.
## 🙏 Acknowledgments
- Flask and React communities
- Docker and Docker Compose
- Vite for fast development
- Tailwind CSS for styling

7
backend/.env.example Normal file
View file

@ -0,0 +1,7 @@
FLASK_ENV=dev
SECRET_KEY=your-secret-key-here
JWT_SECRET_KEY=your-jwt-secret-key-here
CORS_ORIGINS=*
DEV_DATABASE_URL=postgresql://crafting:devpassword@localhost:5432/crafting_shop
DATABASE_URL=postgresql://user:password@localhost/proddb
TEST_DATABASE_URL=sqlite:///test.db

30
backend/Dockerfile Normal file
View file

@ -0,0 +1,30 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements/ requirements/
RUN pip install --no-cache-dir -r requirements/prod.txt
# Copy application code
COPY . .
# Create non-root user
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health/ || exit 1
# Run with gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "wsgi:app"]

49
backend/app/__init__.py Normal file
View file

@ -0,0 +1,49 @@
from flask import Flask, jsonify
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
import os
# Create extensions but don't initialize them yet
db = SQLAlchemy()
migrate = Migrate()
jwt = JWTManager()
cors = CORS()
def create_app(config_name=None):
"""Application factory pattern"""
app = Flask(__name__)
# Load configuration
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
from app.config import config_by_name
app.config.from_object(config_by_name[config_name])
# Initialize extensions with app
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
cors.init_app(app, resources={r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}})
# Import models (required for migrations)
from app.models import user, product, order
# Register blueprints
from app.routes import api_bp, health_bp
app.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(health_bp)
# Global error handlers
@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Not found'}), 404
@app.errorhandler(500)
def internal_error(error):
return jsonify({'error': 'Internal server error'}), 500
return app

43
backend/app/config.py Normal file
View file

@ -0,0 +1,43 @@
import os
from datetime import timedelta
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or "jwt-secret-key-change-in-production"
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db"
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db"
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb"
# Security headers
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
config_by_name = {
"dev": DevelopmentConfig,
"test": TestingConfig,
"prod": ProductionConfig
}

View file

@ -0,0 +1,5 @@
from app.models.user import User
from app.models.product import Product
from app.models.order import Order, OrderItem
__all__ = ["User", "Product", "Order", "OrderItem"]

View file

@ -0,0 +1,63 @@
from datetime import datetime
from app import db
class Order(db.Model):
"""Order model"""
__tablename__ = "orders"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
status = db.Column(db.String(20), default="pending", index=True)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_address = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = db.relationship("User", back_populates="orders")
items = db.relationship("OrderItem", back_populates="order", lazy="dynamic", cascade="all, delete-orphan")
def to_dict(self):
"""Convert order to dictionary"""
return {
"id": self.id,
"user_id": self.user_id,
"status": self.status,
"total_amount": float(self.total_amount) if self.total_amount else None,
"shipping_address": self.shipping_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"items": [item.to_dict() for item in self.items]
}
def __repr__(self):
return f"<Order {self.id}>"
class OrderItem(db.Model):
"""Order Item model"""
__tablename__ = "order_items"
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
price = db.Column(db.Numeric(10, 2), nullable=False)
# Relationships
order = db.relationship("Order", back_populates="items")
product = db.relationship("Product", back_populates="order_items")
def to_dict(self):
"""Convert order item to dictionary"""
return {
"id": self.id,
"order_id": self.order_id,
"product_id": self.product_id,
"quantity": self.quantity,
"price": float(self.price) if self.price else None
}
def __repr__(self):
return f"<OrderItem {self.id}>"

View file

@ -0,0 +1,37 @@
from datetime import datetime
from app import db
class Product(db.Model):
"""Product model"""
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
description = db.Column(db.Text)
price = db.Column(db.Numeric(10, 2), nullable=False)
stock = db.Column(db.Integer, default=0)
image_url = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
def to_dict(self):
"""Convert product to dictionary"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"price": float(self.price) if self.price else None,
"stock": self.stock,
"image_url": self.image_url,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f"<Product {self.name}>"

View file

@ -0,0 +1,47 @@
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
class User(db.Model):
"""User model"""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
first_name = db.Column(db.String(50))
last_name = db.Column(db.String(50))
is_active = db.Column(db.Boolean, default=True)
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
def set_password(self, password):
"""Hash and set password"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Check if provided password matches hash"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
"""Convert user to dictionary"""
return {
"id": self.id,
"email": self.email,
"username": self.username,
"first_name": self.first_name,
"last_name": self.last_name,
"is_active": self.is_active,
"is_admin": self.is_admin,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f"<User {self.username}>"

View file

@ -0,0 +1,4 @@
from .api import api_bp
from .health import health_bp
__all__ = ["api_bp", "health_bp"]

234
backend/app/routes/api.py Normal file
View file

@ -0,0 +1,234 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token, create_refresh_token
from app import db
from app.models import User, Product, OrderItem, Order
api_bp = Blueprint("api", __name__)
# User Routes
@api_bp.route("/auth/register", methods=["POST"])
def register():
"""Register a new user"""
data = request.get_json()
if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400
if User.query.filter_by(email=data["email"]).first():
return jsonify({"error": "Email already exists"}), 400
user = User(
email=data["email"],
username=data.get("username", data["email"].split("@")[0]),
first_name=data.get("first_name"),
last_name=data.get("last_name")
)
user.set_password(data["password"])
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
@api_bp.route("/auth/login", methods=["POST"])
def login():
"""Login user"""
data = request.get_json()
if not data or not data.get("email") or not data.get("password"):
return jsonify({"error": "Email and password are required"}), 400
user = User.query.filter_by(email=data["email"]).first()
if not user or not user.check_password(data["password"]):
return jsonify({"error": "Invalid credentials"}), 401
if not user.is_active:
return jsonify({"error": "Account is inactive"}), 401
access_token = create_access_token(identity=user.id)
refresh_token = create_refresh_token(identity=user.id)
return jsonify({
"user": user.to_dict(),
"access_token": access_token,
"refresh_token": refresh_token
}), 200
@api_bp.route("/users/me", methods=["GET"])
@jwt_required()
def get_current_user():
"""Get current user"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict()), 200
# Product Routes
@api_bp.route("/products", methods=["GET"])
def get_products():
"""Get all products"""
products = Product.query.filter_by(is_active=True).all()
return jsonify([product.to_dict() for product in products]), 200
@api_bp.route("/products/<int:product_id>", methods=["GET"])
def get_product(product_id):
"""Get a single product"""
product = Product.query.get_or_404(product_id)
return jsonify(product.to_dict()), 200
@api_bp.route("/products", methods=["POST"])
@jwt_required()
def create_product():
"""Create a new product (admin only)"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
data = request.get_json()
product = Product(
name=data["name"],
description=data.get("description"),
price=data["price"],
stock=data.get("stock", 0),
image_url=data.get("image_url"),
category=data.get("category")
)
db.session.add(product)
db.session.commit()
return jsonify(product.to_dict()), 201
@api_bp.route("/products/<int:product_id>", methods=["PUT"])
@jwt_required()
def update_product(product_id):
"""Update a product (admin only)"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = Product.query.get_or_404(product_id)
data = request.get_json()
product.name = data.get("name", product.name)
product.description = data.get("description", product.description)
product.price = data.get("price", product.price)
product.stock = data.get("stock", product.stock)
product.image_url = data.get("image_url", product.image_url)
product.category = data.get("category", product.category)
product.is_active = data.get("is_active", product.is_active)
db.session.commit()
return jsonify(product.to_dict()), 200
@api_bp.route("/products/<int:product_id>", methods=["DELETE"])
@jwt_required()
def delete_product(product_id):
"""Delete a product (admin only)"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user or not user.is_admin:
return jsonify({"error": "Admin access required"}), 403
product = Product.query.get_or_404(product_id)
db.session.delete(product)
db.session.commit()
return jsonify({"message": "Product deleted"}), 200
# Order Routes
@api_bp.route("/orders", methods=["GET"])
@jwt_required()
def get_orders():
"""Get all orders for current user"""
user_id = get_jwt_identity()
orders = Order.query.filter_by(user_id=user_id).all()
return jsonify([order.to_dict() for order in orders]), 200
@api_bp.route("/orders", methods=["POST"])
@jwt_required()
def create_order():
"""Create a new order"""
user_id = get_jwt_identity()
data = request.get_json()
if not data or not data.get("items"):
return jsonify({"error": "Order items are required"}), 400
total_amount = 0
order_items = []
for item_data in data["items"]:
product = Product.query.get(item_data["product_id"])
if not product:
return jsonify({"error": f'Product {item_data["product_id"]} not found'}), 404
if product.stock < item_data["quantity"]:
return jsonify({"error": f'Insufficient stock for {product.name}'}), 400
item_total = product.price * item_data["quantity"]
total_amount += item_total
order_items.append({
"product": product,
"quantity": item_data["quantity"],
"price": product.price
})
order = Order(
user_id=user_id,
total_amount=total_amount,
shipping_address=data.get("shipping_address"),
notes=data.get("notes")
)
db.session.add(order)
db.session.flush()
for item_data in order_items:
order_item = OrderItem(
order_id=order.id,
product_id=item_data["product"].id,
quantity=item_data["quantity"],
price=item_data["price"]
)
item_data["product"].stock -= item_data["quantity"]
db.session.add(order_item)
db.session.commit()
return jsonify(order.to_dict()), 201
@api_bp.route("/orders/<int:order_id>", methods=["GET"])
@jwt_required()
def get_order(order_id):
"""Get a single order"""
user_id = get_jwt_identity()
order = Order.query.get_or_404(order_id)
if order.user_id != user_id:
user = User.query.get(user_id)
if not user or not user.is_admin:
return jsonify({"error": "Access denied"}), 403
return jsonify(order.to_dict()), 200

View file

@ -0,0 +1,22 @@
from flask import Blueprint, jsonify
health_bp = Blueprint('health', __name__)
@health_bp.route('/', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'service': 'crafting-shop-backend'
}), 200
@health_bp.route('/readiness', methods=['GET'])
def readiness_check():
"""Readiness check endpoint"""
# Add database check here if needed
return jsonify({
'status': 'ready',
'service': 'crafting-shop-backend'
}), 200

View file

@ -0,0 +1 @@
"""Business logic services"""

View file

@ -0,0 +1 @@
"""Utility functions and helpers"""

45
backend/config.py Normal file
View file

@ -0,0 +1,45 @@
import os
from datetime import timedelta
class Config:
"""Base configuration"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key-change-in-production'
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
class DevelopmentConfig(Config):
"""Development configuration"""
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
'sqlite:///dev.db'
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
'sqlite:///test.db'
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
"""Production configuration"""
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'postgresql://user:password@localhost/proddb'
# Security headers
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
config_by_name = {
'dev': DevelopmentConfig,
'test': TestingConfig,
'prod': ProductionConfig
}

View file

@ -0,0 +1 @@
Single-database configuration for Flask.

View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
backend/migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,96 @@
"""initial migration of products
Revision ID: dd57c5299d60
Revises:
Create Date: 2026-02-14 15:24:48.690696
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dd57c5299d60'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('stock', sa.Integer(), nullable=True),
sa.Column('image_url', sa.String(length=500), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_products_name'), ['name'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=120), nullable=False),
sa.Column('username', sa.String(length=80), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('first_name', sa.String(length=50), nullable=True),
sa.Column('last_name', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
op.create_table('orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('shipping_address', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_orders_status'), ['status'], unique=False)
op.create_table('order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('order_items')
with op.batch_alter_table('orders', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_orders_status'))
op.drop_table('orders')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_username'))
batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users')
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_products_name'))
op.drop_table('products')
# ### end Alembic commands ###

View file

@ -0,0 +1,9 @@
Flask==3.0.0
Flask-CORS==4.0.0
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
Flask-JWT-Extended==4.5.3
psycopg2-binary==2.9.9
python-dotenv==1.0.0
Werkzeug==3.0.1
SQLAlchemy==2.0.23

View file

@ -0,0 +1,10 @@
-r base.txt
pytest==7.4.3
pytest-flask==1.3.0
pytest-cov==4.1.0
black==23.12.1
flake8==7.0.0
isort==5.13.2
mypy==1.7.1
faker==20.1.0

View file

@ -0,0 +1,4 @@
-r base.txt
gunicorn==21.2.0
sentry-sdk[flask]==1.39.2

8
backend/wsgi.py Normal file
View file

@ -0,0 +1,8 @@
import os
from app import create_app
env = os.environ.get('FLASK_ENV', 'dev')
app = create_app(env)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

33
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,33 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: crafting-shop-postgres-dev
environment:
- POSTGRES_USER=${POSTGRES_USER:-crafting}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
- POSTGRES_DB=${POSTGRES_DB:-crafting_shop}
volumes:
- postgres-dev-data:/var/lib/postgresql/data
networks:
- crafting-shop-network
ports:
- "5432:5432"
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: crafting-shop-redis-dev
networks:
- crafting-shop-network
ports:
- "6379:6379"
restart: unless-stopped
volumes:
postgres-dev-data:
networks:
crafting-shop-network:
driver: bridge

96
docker-compose.yml Normal file
View file

@ -0,0 +1,96 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: crafting-shop-backend
ports:
- "5000:5000"
environment:
- FLASK_ENV=${FLASK_ENV:-prod}
- SECRET_KEY=${SECRET_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
depends_on:
- postgres
- redis
networks:
- crafting-shop-network
volumes:
- backend-data:/app/instance
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: crafting-shop-frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- crafting-shop-network
restart: unless-stopped
postgres:
image: postgres:15-alpine
container_name: crafting-shop-postgres
environment:
- POSTGRES_USER=${POSTGRES_USER:-crafting}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB:-crafting_shop}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- crafting-shop-network
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: crafting-shop-redis
networks:
- crafting-shop-network
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: crafting-shop-prometheus
ports:
- "9090:9090"
volumes:
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
networks:
- crafting-shop-network
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: crafting-shop-grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
volumes:
- grafana-data:/var/lib/grafana
networks:
- crafting-shop-network
restart: unless-stopped
volumes:
postgres-data:
redis-data:
prometheus-data:
grafana-data:
backend-data:
networks:
crafting-shop-network:
driver: bridge

316
docs/usage_rules_backend.md Normal file
View file

@ -0,0 +1,316 @@
# Backend Development Rules for AI/LLM
This document provides guidelines and rules that AI/LLM assistants should follow when writing or modifying backend code for the Crafting Shop application.
## Architecture Principles
### Application Factory Pattern
- **ALWAYS** use the application factory pattern via `create_app()` in `backend/app/__init__.py`
- **NEVER** initialize Flask extensions globally - create them at module level, initialize in `create_app()`
- **NEVER** create multiple SQLAlchemy instances - use the `db` extension from `app/__init__.py`
```python
# ✅ CORRECT
from app import db
class User(db.Model):
pass
# ❌ WRONG
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
```
### Import Patterns
- Import `db` and other extensions from `app` module, not `flask_sqlalchemy` directly
- Import models from the `app.models` package (e.g., `from app.models import User`)
- All routes should import `db` from `app`
```python
# ✅ CORRECT
from app import db
from app.models import User, Product, Order
# ❌ WRONG
from flask_sqlalchemy import SQLAlchemy
from app.models.user import User # Don't import from individual files
```
## Code Style
### Formatting
- Use double quotes for strings and dictionary keys
- Follow PEP 8 style guide
- Use meaningful variable and function names
- Maximum line length: 100 characters
### Type Hints
- Add type hints to function signatures where appropriate
- Use Python 3.11+ type hinting features
## Model Rules
### Database Models
- **ALL** models must import `db` from `app`: `from app import db`
- **ALL** models must inherit from `db.Model`
- **NEVER** create your own `db = SQLAlchemy()` instance
- Use `__tablename__` explicitly
- Use `to_dict()` method for serialization
- Use `__repr__()` for debugging
```python
# ✅ CORRECT
from app import db
from datetime import datetime
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {"id": self.id, "name": self.name}
```
### Relationships
- Use `back_populates` for bidirectional relationships
- Use `lazy="dynamic"` for one-to-many collections when appropriate
- Define foreign keys explicitly with `db.ForeignKey()`
## Route/API Rules
### Blueprint Usage
- **ALL** routes must be defined in blueprints
- Register blueprints in `create_app()` in `backend/app/__init__.py`
- Group related routes by functionality
### Request Handling
- Use `request.get_json()` for JSON payloads
- Always validate input data
- Return proper HTTP status codes
- Return JSON responses with `jsonify()`
```python
# ✅ CORRECT
from flask import Blueprint, request, jsonify
from app import db
api_bp = Blueprint("api", __name__)
@api_bp.route("/products", methods=["POST"])
@jwt_required()
def create_product():
data = request.get_json()
if not data or not data.get("name"):
return jsonify({"error": "Name is required"}), 400
product = Product(name=data["name"])
db.session.add(product)
db.session.commit()
return jsonify(product.to_dict()), 201
```
### Database Operations
- **NEVER** access `db` through `current_app.extensions['sqlalchemy'].db`
- **ALWAYS** import `db` from `app`
- Use `db.session.add()` and `db.session.commit()` for transactions
- Use `db.session.flush()` when you need the ID before commit
### Error Handling
- Handle common errors (404, 400, 401, 403, 500)
- Return JSON error responses with consistent format
- Use Flask error handlers where appropriate
```python
# ✅ CORRECT
@app.errorhandler(404)
def not_found(error):
return jsonify({"error": "Not found"}), 404
```
### Authentication
- Use `@jwt_required()` decorator for protected routes
- Use `get_jwt_identity()` to get current user ID
- Verify user permissions in routes
```python
# ✅ CORRECT
from flask_jwt_extended import jwt_required, get_jwt_identity
@api_bp.route("/orders", methods=["POST"])
@jwt_required()
def create_order():
user_id = get_jwt_identity()
# ... rest of code
```
## Configuration
### Environment Variables
- **ALL** configuration must use environment variables via `os.environ.get()`
- Provide sensible defaults in `app/config.py`
- Use `.env.example` for documentation
### CORS
- Configure CORS in `create_app()` using the `cors` extension
- Use `CORS_ORIGINS` environment variable
```python
# ✅ CORRECT
cors.init_app(app, resources={r"/api/*": {"origins": app.config.get('CORS_ORIGINS', '*')}})
```
## Service Layer Rules
### Business Logic
- Place complex business logic in `backend/app/services/`
- Services should be stateless functions or classes
- Services should import `db` from `app`
```python
# ✅ CORRECT
from app import db
from app.models import Product
def create_product_service(data):
product = Product(**data)
db.session.add(product)
db.session.commit()
return product
```
## Testing Rules
### Test Structure
- Use pytest framework
- Place tests in `backend/tests/`
- Use fixtures for common setup
### Database in Tests
- Use in-memory SQLite for tests
- Clean up database between tests
- Use `pytest.fixture` for database setup
## Security Rules
### Password Handling
- **NEVER** store plain text passwords
- Use `werkzeug.security` for password hashing
- Use `set_password()` and `check_password()` methods
### SQL Injection Prevention
- **ALWAYS** use SQLAlchemy ORM, never raw SQL
- Use parameterized queries if raw SQL is absolutely necessary
### Input Validation
- Validate all user inputs
- Sanitize data before database operations
- Use Flask-WTF for form validation
## File Structure for New Features
When adding new features, follow this structure:
```
backend/app/
├── models/
│ └── feature_name.py # Database models
├── routes/
│ └── feature_name.py # API routes
├── services/
│ └── feature_name.py # Business logic
└── utils/
└── feature_name.py # Utility functions
```
## Common Patterns
### Creating a New Model
```python
from app import db
from datetime import datetime
class NewModel(db.Model):
__tablename__ = "new_model"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"created_at": self.created_at.isoformat() if self.created_at else None
}
```
### Creating New Routes
```python
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app import db
from app.models import NewModel
new_bp = Blueprint("new", __name__)
@new_bp.route("/resources", methods=["GET"])
def get_resources():
resources = NewModel.query.all()
return jsonify([r.to_dict() for r in resources]), 200
@new_bp.route("/resources", methods=["POST"])
@jwt_required()
def create_resource():
data = request.get_json()
resource = NewModel(**data)
db.session.add(resource)
db.session.commit()
return jsonify(resource.to_dict()), 201
```
### Registering New Blueprints
In `backend/app/__init__.py`:
```python
# Import models
from app.models.new_model import NewModel
# Register blueprint
from app.routes.feature_name import new_bp
app.register_blueprint(new_bp, url_prefix="/api/new")
```
## DO NOT DO List
**NEVER** create `db = SQLAlchemy()` in model files
**NEVER** access `db` via `current_app.extensions['sqlalchemy'].db`
**NEVER** initialize Flask extensions before `create_app()` is called
**NEVER** use raw SQL queries without proper escaping
**NEVER** store secrets in code (use environment variables)
**NEVER** return plain Python objects (use `jsonify()`)
**NEVER** use single quotes for dictionary keys
**NEVER** commit transactions without proper error handling
**NEVER** ignore CORS configuration
**NEVER** skip input validation
## Checklist Before Committing
- [ ] All models import `db` from `app`
- [ ] All routes import `db` from `app`
- [ ] No `db = SQLAlchemy()` in model files
- [ ] All routes are in blueprints
- [ ] Proper error handling in place
- [ ] Environment variables used for configuration
- [ ] Type hints added to functions
- [ ] Tests written for new functionality
- [ ] Documentation updated
- [ ] Code follows PEP 8 style guide

View file

@ -0,0 +1,625 @@
# Frontend Development Rules for AI/LLM
This document provides guidelines and rules that AI/LLM assistants should follow when writing or modifying frontend code for the Crafting Shop application.
## Architecture Principles
### State Management
- **ALWAYS** use React Context API for global state management
- **NEVER** use Zustand, Redux, or other state management libraries
- Store context in `frontend/src/context/`
- Use useContext() hook for accessing context
```jsx
// ✅ CORRECT
import { useContext } from "react"
import { AppContext } from "../context/AppContext"
function Component() {
const { user, setUser } = useContext(AppContext)
// ...
}
// ❌ WRONG
import { create } from "zustand"
```
### API Calls
- **ALWAYS** use the custom `useApi` hook for all API calls
- **NEVER** use axios directly in components
- Import useApi from `frontend/src/hooks/useApi.js`
```jsx
// ✅ CORRECT
import useApi from "../hooks/useApi"
function Products() {
const { api } = useApi()
const [products, setProducts] = useState([])
useEffect(() => {
api.get("/api/products")
.then(response => setProducts(response.data))
.catch(error => console.error(error))
}, [api])
}
// ❌ WRONG
import axios from "axios"
function Products() {
useEffect(() => {
axios.get("/api/products") // Don't do this!
}, [])
}
```
## Code Style
### Formatting
- Use double quotes for strings and object properties
- Use functional components with hooks (no class components)
- Use arrow functions for callbacks
- Follow React best practices
- Maximum line length: 100 characters
### Naming Conventions
- Components: PascalCase (e.g., `ProductCard`, `Navbar`)
- Hooks: camelCase with `use` prefix (e.g., `useProducts`, `useAuth`)
- Files: kebab-case (e.g., `product-card.jsx`, `navbar.jsx`)
- Directories: lowercase (e.g., `components/`, `pages/`)
## Component Rules
### Functional Components
- **ALWAYS** use functional components with React hooks
- **NEVER** use class components
- Destructure props at the top of component
```jsx
// ✅ CORRECT
import React from "react"
function ProductCard({ name, price, onClick }) {
return (
<div className="product-card" onClick={onClick}>
<h3>{name}</h3>
<p>${price}</p>
</div>
)
}
export default ProductCard
```
### Props Validation
- Use PropTypes for component props
- Define PropTypes at the bottom of file
- Mark required props
```jsx
import PropTypes from "prop-types"
function ProductCard({ name, price, onClick }) {
// ...
}
ProductCard.propTypes = {
name: PropTypes.string.isRequired,
price: PropTypes.number.isRequired,
onClick: PropTypes.func
}
```
### Conditional Rendering
- Use ternary operators for simple conditions
- Use logical AND for conditional elements
- Keep JSX readable
```jsx
// ✅ CORRECT
{isLoading ? <Loader /> : <Content />}
{error && <ErrorMessage message={error} />}
{user && <WelcomeMessage user={user} />}
```
### Lists Rendering
- **ALWAYS** provide unique `key` prop
- Use `.map()` for transforming arrays to elements
```jsx
// ✅ CORRECT
{products.map(product => (
<ProductCard
key={product.id}
product={product}
/>
))}
```
## Hook Rules
### Custom Hooks
- Create custom hooks for reusable logic
- Name hooks with `use` prefix
- Place hooks in `frontend/src/hooks/`
```jsx
// ✅ CORRECT - Create custom hook
import { useState, useEffect } from "react"
import useApi from "./useApi"
function useProducts() {
const { api } = useApi()
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
api.get("/api/products")
.then(response => setProducts(response.data))
.finally(() => setLoading(false))
}, [api])
return { products, loading }
}
export default useProducts
```
### Effect Rules
- **ALWAYS** include all dependencies in dependency array
- Use proper cleanup in useEffect
- Avoid infinite loops
```jsx
// ✅ CORRECT
useEffect(() => {
const fetchData = async () => {
const response = await api.get("/api/products")
setData(response.data)
}
fetchData()
return () => {
// Cleanup
}
}, [api]) // Include all dependencies
```
## Styling Rules
### Tailwind CSS
- **ALWAYS** use Tailwind CSS for styling
- Use utility classes for styling
- Avoid custom CSS files when possible
- Use `className` prop (not `class`)
```jsx
// ✅ CORRECT
<div className="bg-gray-900 text-white p-4 rounded-lg">
<h1 className="text-2xl font-bold">Welcome</h1>
</div>
// ❌ WRONG
<div class="custom-card"> // Wrong prop name
<h1 class="title">Welcome</h1>
</div>
```
### Dark Mode
- Default to dark mode styling
- Use Tailwind's dark mode utilities if needed
- Maintain consistent dark theme
```jsx
// ✅ CORRECT
<div className="bg-gray-900 text-gray-100">
<p className="text-gray-300">Dark mode text</p>
</div>
```
### Responsive Design
- Use Tailwind responsive prefixes
- Mobile-first approach
- Test on multiple screen sizes
```jsx
// ✅ CORRECT
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Cards */}
</div>
```
## Routing Rules
### React Router
- Use `react-router-dom` for navigation
- Define routes in `frontend/src/main.jsx`
- Use `<Link>` for navigation (not `<a>`)
```jsx
// ✅ CORRECT
import { Link } from "react-router-dom"
function Navbar() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/products">Products</Link>
<Link to="/cart">Cart</Link>
</nav>
)
}
```
### Protected Routes
- Use context to check authentication
- Redirect to login if not authenticated
- Store JWT in context
```jsx
// ✅ CORRECT
import { useContext } from "react"
import { Navigate } from "react-router-dom"
import { AppContext } from "../context/AppContext"
function ProtectedRoute({ children }) {
const { token } = useContext(AppContext)
if (!token) {
return <Navigate to="/login" />
}
return children
}
```
## Form Rules
### Form Handling
- Use controlled components for forms
- Store form state in useState
- Handle onChange events properly
```jsx
// ✅ CORRECT
function LoginForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const handleSubmit = (e) => {
e.preventDefault()
// Handle submission
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="border rounded p-2"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="border rounded p-2"
/>
<button type="submit" className="bg-blue-500 text-white p-2">
Login
</button>
</form>
)
}
```
### Form Validation
- Validate form data before submission
- Show error messages to users
- Disable submit button during submission
```jsx
// ✅ CORRECT
function RegisterForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError("")
// Validation
if (!email || !password) {
setError("Please fill in all fields")
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
return
}
setLoading(true)
try {
// Submit form
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
{error && <div className="text-red-500">{error}</div>}
<button type="submit" disabled={loading} className="...">
{loading ? "Submitting..." : "Register"}
</button>
</form>
)
}
```
## Error Handling Rules
### Error Boundaries
- Use error boundaries for component errors
- Display user-friendly error messages
- Log errors for debugging
### API Errors
- Catch errors from useApi hook
- Display error messages to users
- Use try-catch for async operations
```jsx
// ✅ CORRECT
function Products() {
const { api } = useApi()
const [error, setError] = useState(null)
const loadProducts = async () => {
try {
const response = await api.get("/api/products")
setProducts(response.data)
} catch (err) {
setError("Failed to load products")
console.error(err)
}
}
if (error) {
return <div className="text-red-500">{error}</div>
}
// Render products
}
```
## Performance Rules
### Memoization
- Use `React.memo()` for expensive components
- Use `useMemo()` for expensive calculations
- Use `useCallback()` for stable function references
```jsx
// ✅ CORRECT
import React, { useMemo, useCallback } from "react"
function ExpensiveComponent({ items }) {
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name))
}, [items])
const handleClick = useCallback((id) => {
console.log("Clicked:", id)
}, [])
return (
<div>
{sortedItems.map(item => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</div>
))}
</div>
)
}
export default React.memo(ExpensiveComponent)
```
### Lazy Loading
- Use React.lazy() for code splitting
- Use Suspense for loading states
- Lazy load routes and heavy components
```jsx
// ✅ CORRECT
import { lazy, Suspense } from "react"
const Products = lazy(() => import("./pages/Products"))
const Cart = lazy(() => import("./pages/Cart"))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/products" element={<Products />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Suspense>
)
}
```
## File Structure for New Features
When adding new features, follow this structure:
```
frontend/src/
├── components/
│ └── feature/
│ ├── FeatureList.jsx
│ └── FeatureCard.jsx
├── pages/
│ └── FeaturePage.jsx
├── context/
│ └── FeatureContext.jsx (if needed)
├── hooks/
│ └── useFeature.js (if needed)
└── services/
└── featureService.js (if needed)
```
## Common Patterns
### Creating a New Component
```jsx
import React, { useState } from "react"
import PropTypes from "prop-types"
function NewComponent({ title, onAction }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="bg-gray-800 p-4 rounded">
<h2 className="text-xl font-bold text-white">{title}</h2>
<button
onClick={() => setIsOpen(!isOpen)}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="mt-4">
<p>Content goes here</p>
<button onClick={onAction}>Action</button>
</div>
)}
</div>
)
}
NewComponent.propTypes = {
title: PropTypes.string.isRequired,
onAction: PropTypes.func.isRequired
}
export default NewComponent
```
### Creating a New Page
```jsx
import React, { useEffect } from "react"
import useApi from "../hooks/useApi"
function NewPage() {
const { api } = useApi()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const fetchData = async () => {
try {
const response = await api.get("/api/endpoint")
setData(response.data)
} catch (err) {
setError("Failed to load data")
} finally {
setLoading(false)
}
}
fetchData()
}, [api])
if (loading) {
return <div className="text-white">Loading...</div>
}
if (error) {
return <div className="text-red-500">{error}</div>
}
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold text-white mb-4">
New Page
</h1>
{/* Page content */}
</div>
)
}
export default NewPage
```
### Creating a New Context
```jsx
import React, { createContext, useContext, useState } from "react"
const NewContext = createContext(null)
export function NewContextProvider({ children }) {
const [value, setValue] = useState(null)
return (
<NewContext.Provider value={{ value, setValue }}>
{children}
</NewContext.Provider>
)
}
export function useNewContext() {
const context = useContext(NewContext)
if (!context) {
throw new Error("useNewContext must be used within NewContextProvider")
}
return context
}
```
## DO NOT DO List
**NEVER** use Zustand, Redux, or other state management
**NEVER** use axios directly (always use useApi hook)
**NEVER** use class components
**NEVER** use `class` attribute (use `className`)
**NEVER** forget `key` prop in lists
**NEVER** ignore useEffect dependencies
**NEVER** use `this.state` or `this.props`
**NEVER** create custom CSS files (use Tailwind)
**NEVER** use single quotes for strings
**NEVER** forget error handling
**NEVER** hardcode API URLs
**NEVER** skip PropTypes validation
**NEVER** use `<a>` for navigation (use `<Link>`)
## Checklist Before Committing
- [ ] Component uses functional syntax with hooks
- [ ] All API calls use useApi hook
- [ ] State uses Context API (not Redux/Zustand)
- [ ] All props have PropTypes validation
- [ ] Tailwind classes used for styling
- [ ] Dark mode styling applied
- [ ] Responsive design considered
- [ ] Error handling implemented
- [ ] Loading states added
- [ ] Keys provided for list items
- [ ] useEffect dependencies correct
- [ ] Code follows project conventions
- [ ] Files properly named (kebab-case)

33
frontend/Dockerfile Normal file
View file

@ -0,0 +1,33 @@
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Crafting Shop - Your one-stop shop for crafting supplies" />
<title>Crafting Shop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
frontend/nginx.conf Normal file
View file

@ -0,0 +1,29 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
}

6967
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
frontend/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "crafting-shop-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.5.1",
"vitest": "^1.0.4",
"@vitest/ui": "^1.0.4",
"jsdom": "^23.0.1",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.32",
"autoprefixer": "^10.4.16"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

28
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,28 @@
import { Routes, Route } from 'react-router-dom'
import { Navbar } from './components/Navbar'
import { Home } from './pages/Home'
import { Products } from './pages/Products'
import { Login } from './pages/Login'
import { Register } from './pages/Register'
import { Cart } from './pages/Cart'
import { Orders } from './pages/Orders'
function App() {
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar />
<main className="flex-1 p-8 max-w-7xl mx-auto w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/cart" element={<Cart />} />
<Route path="/orders" element={<Orders />} />
</Routes>
</main>
</div>
)
}
export default App

View file

@ -0,0 +1,70 @@
import { Link } from 'react-router-dom'
import { useApp } from '../context/AppContext.jsx'
export function Navbar() {
const { user } = useApp()
return (
<nav className="bg-gray-800 border-b border-gray-700 shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<Link to="/" className="text-xl font-bold text-white hover:text-blue-400 transition-colors">
Crafting Shop
</Link>
<div className="ml-10 flex items-baseline space-x-4">
<Link
to="/"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Home
</Link>
<Link
to="/products"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Products
</Link>
<Link
to="/cart"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Cart
</Link>
{user && (
<Link
to="/orders"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Orders
</Link>
)}
</div>
</div>
<div className="flex items-center">
{user ? (
<span className="text-gray-300 px-3 py-2">
{user.username}
</span>
) : (
<>
<Link
to="/login"
className="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
>
Login
</Link>
<Link
to="/register"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
>
Register
</Link>
</>
)}
</div>
</div>
</div>
</nav>
)
}

View file

@ -0,0 +1,130 @@
import { createContext, useContext, useState, useEffect } from 'react'
const AppContext = createContext()
export function AppProvider({ children }) {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [cart, setCart] = useState([])
const [loading, setLoading] = useState(true)
// Load user and token from localStorage on mount
useEffect(() => {
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
if (storedToken && storedUser) {
setToken(storedToken)
setUser(JSON.parse(storedUser))
}
const storedCart = localStorage.getItem('cart')
if (storedCart) {
setCart(JSON.parse(storedCart))
}
setLoading(false)
}, [])
// Save to localStorage whenever user, token, or cart changes
useEffect(() => {
if (user) {
localStorage.setItem('user', JSON.stringify(user))
} else {
localStorage.removeItem('user')
}
}, [user])
useEffect(() => {
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
}, [token])
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart))
}, [cart])
const login = (userData, authToken) => {
setUser(userData)
setToken(authToken)
}
const logout = () => {
setUser(null)
setToken(null)
setCart([])
}
const addToCart = (product) => {
setCart((prevCart) => {
const existingItem = prevCart.find((item) => item.id === product.id)
if (existingItem) {
return prevCart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
return [...prevCart, { ...product, quantity: 1 }]
})
}
const removeFromCart = (productId) => {
setCart((prevCart) => prevCart.filter((item) => item.id !== productId))
}
const updateCartQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeFromCart(productId)
return
}
setCart((prevCart) =>
prevCart.map((item) =>
item.id === productId ? { ...item, quantity } : item
)
)
}
const clearCart = () => {
setCart([])
}
const cartTotal = cart.reduce(
(total, item) => total + item.price * item.quantity,
0
)
const cartItemCount = cart.reduce((total, item) => total + item.quantity, 0)
return (
<AppContext.Provider
value={{
user,
token,
cart,
loading,
login,
logout,
addToCart,
removeFromCart,
updateCartQuantity,
clearCart,
cartTotal,
cartItemCount,
}}
>
{children}
</AppContext.Provider>
)
}
export function useApp() {
const context = useContext(AppContext)
if (!context) {
throw new Error('useApp must be used within an AppProvider')
}
return context
}

View file

@ -0,0 +1,88 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Add token to requests if available
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Handle response errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export function useApi() {
return {
// Auth
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password })
return response.data
},
register: async (userData) => {
const response = await api.post('/auth/register', userData)
return response.data
},
getCurrentUser: async () => {
const response = await api.get('/users/me')
return response.data
},
// Products
getProducts: async () => {
const response = await api.get('/products')
return response.data
},
getProduct: async (id) => {
const response = await api.get(`/products/${id}`)
return response.data
},
createProduct: async (productData) => {
const response = await api.post('/products', productData)
return response.data
},
updateProduct: async (id, productData) => {
const response = await api.put(`/products/${id}`, productData)
return response.data
},
deleteProduct: async (id) => {
const response = await api.delete(`/products/${id}`)
return response.data
},
// Orders
getOrders: async () => {
const response = await api.get('/orders')
return response.data
},
getOrder: async (id) => {
const response = await api.get(`/orders/${id}`)
return response.data
},
createOrder: async (orderData) => {
const response = await api.post('/orders', orderData)
return response.data
},
}
}

3
frontend/src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

16
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { AppProvider } from './context/AppContext.jsx'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AppProvider>
<App />
</AppProvider>
</BrowserRouter>
</React.StrictMode>,
)

123
frontend/src/pages/Cart.jsx Normal file
View file

@ -0,0 +1,123 @@
import { useNavigate } from 'react-router-dom'
import { useApp } from '../context/AppContext.jsx'
import { useApi } from '../hooks/useApi.js'
export function Cart() {
const navigate = useNavigate()
const { cart, removeFromCart, updateCartQuantity, clearCart, cartTotal } = useApp()
const { createOrder } = useApi()
const handleCheckout = async () => {
if (cart.length === 0) return
const shippingAddress = prompt('Enter shipping address:')
if (!shippingAddress) return
try {
await createOrder({
items: cart.map((item) => ({
product_id: item.id,
quantity: item.quantity,
})),
shipping_address: shippingAddress,
})
clearCart()
navigate('/orders')
} catch (error) {
alert('Failed to create order. Please try again.')
}
}
if (cart.length === 0) {
return (
<div className="text-center py-12">
<h1 className="text-3xl font-bold text-white mb-4">Shopping Cart</h1>
<p className="text-gray-400 mb-8">Your cart is empty</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
)
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">Shopping Cart</h1>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
{cart.map((item) => (
<div
key={item.id}
className="p-4 border-b border-gray-700 last:border-b-0 flex items-center gap-4"
>
{item.image_url && (
<img
src={item.image_url}
alt={item.name}
className="w-20 h-20 object-cover rounded"
/>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-white">{item.name}</h3>
<p className="text-blue-400 font-bold">${item.price}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateCartQuantity(item.id, item.quantity - 1)}
className="bg-gray-700 hover:bg-gray-600 text-white w-8 h-8 rounded transition-colors"
>
-
</button>
<span className="text-white w-8 text-center">{item.quantity}</span>
<button
onClick={() => updateCartQuantity(item.id, item.quantity + 1)}
className="bg-gray-700 hover:bg-gray-600 text-white w-8 h-8 rounded transition-colors"
>
+
</button>
</div>
<p className="text-white font-bold min-w-[100px] text-right">
${(item.price * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
className="text-red-400 hover:text-red-300 p-2 transition-colors"
aria-label="Remove item"
>
</button>
</div>
))}
</div>
<div className="mt-6 flex justify-between items-center">
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${cartTotal.toFixed(2)}</span>
</div>
<div className="flex gap-4">
<button
onClick={clearCart}
className="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Clear Cart
</button>
<button
onClick={handleCheckout}
className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Checkout
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
import { Link } from 'react-router-dom'
export function Home() {
return (
<div className="space-y-12">
<div className="text-center py-12">
<h1 className="text-5xl font-bold text-white mb-4">
Welcome to Crafting Shop
</h1>
<p className="text-xl text-gray-300 mb-8">
Your one-stop shop for premium crafting supplies
</p>
<Link
to="/products"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-lg text-lg font-medium transition-colors inline-block"
>
Browse Products
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-xl font-semibold text-white mb-2">Quality Products</h3>
<p className="text-gray-400">Premium crafting supplies for all your projects</p>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-xl font-semibold text-white mb-2">Fast Delivery</h3>
<p className="text-gray-400">Quick and reliable shipping to your doorstep</p>
</div>
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 className="text-xl font-semibold text-white mb-2">Secure Payments</h3>
<p className="text-gray-400">Safe and secure payment processing</p>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,88 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useApp } from '../context/AppContext.jsx'
import { useApi } from '../hooks/useApi.js'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const { login } = useApp()
const { login: loginApi } = useApi()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const response = await loginApi(email, password)
login(response.user, response.access_token)
navigate('/')
} catch (err) {
setError(err.response?.data?.error || 'Login failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-6 text-center text-gray-400">
Don't have an account?{' '}
<Link to="/register" className="text-blue-400 hover:text-blue-300">
Register
</Link>
</p>
</div>
)
}

View file

@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useApp } from '../context/AppContext.jsx'
import { useApi } from '../hooks/useApi.js'
export function Orders() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
const { user } = useApp()
const { getOrders } = useApi()
useEffect(() => {
if (!user) {
navigate('/login')
return
}
fetchOrders()
}, [user, navigate])
const fetchOrders = async () => {
try {
const data = await getOrders()
setOrders(data)
} catch (error) {
console.error('Error fetching orders:', error)
} finally {
setLoading(false)
}
}
const getStatusColor = (status) => {
const colors = {
pending: 'bg-yellow-900 text-yellow-200 border-yellow-700',
processing: 'bg-blue-900 text-blue-200 border-blue-700',
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
delivered: 'bg-green-900 text-green-200 border-green-700',
cancelled: 'bg-red-900 text-red-200 border-red-700',
}
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700'
}
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading orders...</div>
</div>
)
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
{orders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 mb-8">You have no orders yet</p>
<button
onClick={() => navigate('/products')}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
>
Browse Products
</button>
</div>
) : (
<div className="space-y-6">
{orders.map((order) => (
<div
key={order.id}
className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden"
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-white">
Order #{order.id}
</h3>
<p className="text-sm text-gray-400">
{new Date(order.created_at).toLocaleDateString()}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium border ${getStatusColor(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<div className="p-4">
{order.items.map((item) => (
<div
key={item.id}
className="flex justify-between items-center py-2 border-b border-gray-700 last:border-b-0"
>
<div>
<p className="text-white font-medium">Product #{item.product_id}</p>
<p className="text-sm text-gray-400">
Quantity: {item.quantity}
</p>
</div>
<p className="text-white font-bold">
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-400">
{order.shipping_address && (
<span>Ship to: {order.shipping_address}</span>
)}
</div>
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">
${parseFloat(order.total_amount).toFixed(2)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
import { useApp } from '../context/AppContext.jsx'
import { useApi } from '../hooks/useApi.js'
export function Products() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
const { addToCart } = useApp()
const { getProducts } = useApi()
useEffect(() => {
fetchProducts()
}, [])
const fetchProducts = async () => {
try {
const data = await getProducts()
setProducts(data)
} catch (error) {
console.error('Error fetching products:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading products...</div>
</div>
)
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{products.map((product) => (
<div
key={product.id}
className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
>
{product.image_url && (
<img
src={product.image_url}
alt={product.name}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">
{product.name}
</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">
{product.description}
</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-blue-400">
${product.price}
</span>
<span className="text-sm text-gray-400">
Stock: {product.stock}
</span>
</div>
<button
onClick={() => addToCart(product)}
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors"
>
Add to Cart
</button>
</div>
</div>
))}
</div>
{products.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No products available</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,177 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useApi } from '../hooks/useApi.js'
export function Register() {
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: '',
first_name: '',
last_name: '',
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const { register } = useApi()
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match')
return
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
try {
await register({
email: formData.email,
username: formData.username,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
})
navigate('/login')
} catch (err) {
setError(err.response?.data?.error || 'Registration failed. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold text-white mb-8 text-center">Register</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-300 mb-2">
First Name
</label>
<input
id="first_name"
name="first_name"
type="text"
value={formData.first_name}
onChange={handleChange}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-300 mb-2">
Last Name
</label>
<input
id="last_name"
name="last_name"
type="text"
value={formData.last_name}
onChange={handleChange}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
id="username"
name="username"
type="text"
value={formData.username}
onChange={handleChange}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
required
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Registering...' : 'Register'}
</button>
</form>
<p className="mt-6 text-center text-gray-400">
Already have an account?{' '}
<Link to="/login" className="text-blue-400 hover:text-blue-300">
Login
</Link>
</p>
</div>
)
}

View file

@ -0,0 +1,48 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useStore = create(
persist(
(set) => ({
user: null,
token: null,
cart: [],
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
logout: () => set({ user: null, token: null, cart: [] }),
addToCart: (product) =>
set((state) => {
const existingItem = state.cart.find((item) => item.id === product.id)
if (existingItem) {
return {
cart: state.cart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
),
}
}
return { cart: [...state.cart, { ...product, quantity: 1 }] }
}),
removeFromCart: (productId) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== productId),
})),
updateCartQuantity: (productId, quantity) =>
set((state) => ({
cart: state.cart.map((item) =>
item.id === productId ? { ...item, quantity } : item
),
})),
clearCart: () => set({ cart: [] }),
}),
{
name: 'crafting-shop-storage',
}
)
)

View file

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

29
frontend/vite.config.js Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
},
})

View file

@ -0,0 +1,21 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'backend'
static_configs:
- targets: ['backend:5000']
metrics_path: '/metrics'
- job_name: 'postgres'
static_configs:
- targets: ['postgres:9187']
- job_name: 'redis'
static_configs:
- targets: ['redis:6379']
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']

43
scripts/dev.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
# Development setup script
set -e
echo "🚀 Starting development environment..."
# Check if .env file exists
if [ ! -f .env ]; then
echo "⚠️ .env file not found. Copying from .env.example..."
cp .env.example .env
echo "✅ Created .env file. Please update it with your configuration."
fi
# Check if venv exists
if [ ! -d "backend/venv" ]; then
echo "📦 Creating Python virtual environment..."
cd backend
python -m venv venv
cd ..
fi
# Install backend dependencies
echo "📦 Installing backend dependencies..."
cd backend
source venv/bin/activate
pip install -r requirements/dev.txt
cd ..
# Install frontend dependencies
echo "📦 Installing frontend dependencies..."
cd frontend
npm install
cd ..
echo "✅ Development environment setup complete!"
echo ""
echo "To start the development servers:"
echo " make dev"
echo ""
echo "Or use Docker Compose:"
echo " docker-compose up -d"

28
scripts/test.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/bash
# Test script for both backend and frontend
set -e
echo "🧪 Running tests..."
# Backend tests
echo ""
echo "📦 Testing backend..."
cd backend
source venv/bin/activate
pytest -v --cov=app --cov-report=html --cov-report=term
cd ..
# Frontend tests
echo ""
echo "📦 Testing frontend..."
cd frontend
npm test -- --run --coverage
cd ..
echo ""
echo "✅ All tests completed!"
echo ""
echo "Backend coverage report: backend/htmlcov/index.html"
echo "Frontend coverage report: frontend/coverage/index.html"