initial commit, create repo backend and frontend
This commit is contained in:
commit
b883898ed8
57 changed files with 10992 additions and 0 deletions
21
.env.example
Normal file
21
.env.example
Normal 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
56
.github/workflows/cd.yml
vendored
Normal 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
123
.github/workflows/ci.yml
vendored
Normal 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
82
.gitignore
vendored
Normal 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
109
Makefile
Normal 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
390
README.md
Normal 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
7
backend/.env.example
Normal 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
30
backend/Dockerfile
Normal 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
49
backend/app/__init__.py
Normal 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
43
backend/app/config.py
Normal 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
|
||||
}
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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"]
|
||||
63
backend/app/models/order.py
Normal file
63
backend/app/models/order.py
Normal 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}>"
|
||||
37
backend/app/models/product.py
Normal file
37
backend/app/models/product.py
Normal 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}>"
|
||||
47
backend/app/models/user.py
Normal file
47
backend/app/models/user.py
Normal 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}>"
|
||||
4
backend/app/routes/__init__.py
Normal file
4
backend/app/routes/__init__.py
Normal 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
234
backend/app/routes/api.py
Normal 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
|
||||
22
backend/app/routes/health.py
Normal file
22
backend/app/routes/health.py
Normal 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
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Business logic services"""
|
||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Utility functions and helpers"""
|
||||
45
backend/config.py
Normal file
45
backend/config.py
Normal 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
|
||||
}
|
||||
1
backend/migrations/README
Normal file
1
backend/migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
||||
50
backend/migrations/alembic.ini
Normal file
50
backend/migrations/alembic.ini
Normal 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
113
backend/migrations/env.py
Normal 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()
|
||||
24
backend/migrations/script.py.mako
Normal file
24
backend/migrations/script.py.mako
Normal 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"}
|
||||
|
|
@ -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 ###
|
||||
9
backend/requirements/base.txt
Normal file
9
backend/requirements/base.txt
Normal 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
|
||||
10
backend/requirements/dev.txt
Normal file
10
backend/requirements/dev.txt
Normal 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
|
||||
4
backend/requirements/prod.txt
Normal file
4
backend/requirements/prod.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-r base.txt
|
||||
|
||||
gunicorn==21.2.0
|
||||
sentry-sdk[flask]==1.39.2
|
||||
8
backend/wsgi.py
Normal file
8
backend/wsgi.py
Normal 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
33
docker-compose.dev.yml
Normal 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
96
docker-compose.yml
Normal 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
316
docs/usage_rules_backend.md
Normal 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
|
||||
625
docs/usage_rules_frontend.md
Normal file
625
docs/usage_rules_frontend.md
Normal 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
33
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
29
frontend/nginx.conf
Normal 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
6967
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
28
frontend/src/App.jsx
Normal file
28
frontend/src/App.jsx
Normal 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
|
||||
70
frontend/src/components/Navbar.jsx
Normal file
70
frontend/src/components/Navbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
frontend/src/context/AppContext.jsx
Normal file
130
frontend/src/context/AppContext.jsx
Normal 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
|
||||
}
|
||||
88
frontend/src/hooks/useApi.js
Normal file
88
frontend/src/hooks/useApi.js
Normal 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
3
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal 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
123
frontend/src/pages/Cart.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/pages/Home.jsx
Normal file
37
frontend/src/pages/Home.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
frontend/src/pages/Login.jsx
Normal file
88
frontend/src/pages/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
frontend/src/pages/Orders.jsx
Normal file
128
frontend/src/pages/Orders.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
frontend/src/pages/Products.jsx
Normal file
82
frontend/src/pages/Products.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
frontend/src/pages/Register.jsx
Normal file
177
frontend/src/pages/Register.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/store/useStore.js
Normal file
48
frontend/src/store/useStore.js
Normal 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',
|
||||
}
|
||||
)
|
||||
)
|
||||
12
frontend/tailwind.config.js
Normal file
12
frontend/tailwind.config.js
Normal 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
29
frontend/vite.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
21
infrastructure/monitoring/prometheus.yml
Normal file
21
infrastructure/monitoring/prometheus.yml
Normal 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
43
scripts/dev.sh
Executable 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
28
scripts/test.sh
Executable 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"
|
||||
Loading…
Reference in a new issue