Merge pull request 'add pytest' (#2) from add_pytests_integration into main
Reviewed-on: http://localhost:3000/david/flask_react_monorepo_template/pulls/2
This commit is contained in:
commit
861160566c
69 changed files with 2852 additions and 1285 deletions
69
.github/workflows/backend.yml
vendored
Normal file
69
.github/workflows/backend.yml
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
name: Backend CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- '.github/workflows/backend.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- '.github/workflows/backend.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-test:
|
||||||
|
runs-on: [docker]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: nikolaik/python-nodejs:python3.12-nodejs24-alpine
|
||||||
|
options: --volume forgejo-pip-cache:/tmp/pip-cache
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
run: |
|
||||||
|
python --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt
|
||||||
|
|
||||||
|
- name: Debug cache
|
||||||
|
run: |
|
||||||
|
echo "Listing PIP cache files:"
|
||||||
|
pip cache dir
|
||||||
|
ls -la /tmp/pip-cache 2>/dev/null || echo "Cache dir empty or missing"
|
||||||
|
|
||||||
|
- name: Lint with flake8
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
flake8 app tests --count --max-complexity=10 --max-line-length=127 --statistics --show-source
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://test:test@postgres: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
|
||||||
|
|
||||||
96
.github/workflows/cd.yml
vendored
96
.github/workflows/cd.yml
vendored
|
|
@ -1,56 +1,56 @@
|
||||||
name: CD
|
# name: CD
|
||||||
|
|
||||||
on:
|
# on:
|
||||||
push:
|
# push:
|
||||||
branches: [ main ]
|
# branches: [ main ]
|
||||||
workflow_dispatch:
|
# workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
# jobs:
|
||||||
deploy:
|
# deploy:
|
||||||
runs-on: [docker]
|
# runs-on: [docker]
|
||||||
|
|
||||||
steps:
|
# steps:
|
||||||
- uses: actions/checkout@v3
|
# - uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
# - name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v2
|
# uses: aws-actions/configure-aws-credentials@v2
|
||||||
with:
|
# with:
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
# aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
aws-region: ${{ secrets.AWS_REGION }}
|
# aws-region: ${{ secrets.AWS_REGION }}
|
||||||
|
|
||||||
- name: Login to Amazon ECR
|
# - name: Login to Amazon ECR
|
||||||
id: login-ecr
|
# id: login-ecr
|
||||||
uses: aws-actions/amazon-ecr-login@v1
|
# uses: aws-actions/amazon-ecr-login@v1
|
||||||
|
|
||||||
- name: Build and push backend
|
# - name: Build and push backend
|
||||||
env:
|
# env:
|
||||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||||
ECR_REPOSITORY: crafting-shop-backend
|
# ECR_REPOSITORY: crafting-shop-backend
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
# IMAGE_TAG: ${{ github.sha }}
|
||||||
run: |
|
# run: |
|
||||||
cd backend
|
# cd backend
|
||||||
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
|
# docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
|
||||||
docker push $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 tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
||||||
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
# docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
||||||
|
|
||||||
- name: Build and push frontend
|
# - name: Build and push frontend
|
||||||
env:
|
# env:
|
||||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||||
ECR_REPOSITORY: crafting-shop-frontend
|
# ECR_REPOSITORY: crafting-shop-frontend
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
# IMAGE_TAG: ${{ github.sha }}
|
||||||
run: |
|
# run: |
|
||||||
cd frontend
|
# cd frontend
|
||||||
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
|
# docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
|
||||||
docker push $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 tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
||||||
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
# docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
|
||||||
|
|
||||||
- name: Deploy to ECS
|
# - name: Deploy to ECS
|
||||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
# uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
||||||
with:
|
# with:
|
||||||
task-definition: crafting-shop-task
|
# task-definition: crafting-shop-task
|
||||||
service: crafting-shop-service
|
# service: crafting-shop-service
|
||||||
cluster: crafting-shop-cluster
|
# cluster: crafting-shop-cluster
|
||||||
wait-for-service-stability: true
|
# wait-for-service-stability: true
|
||||||
123
.github/workflows/ci.yml
vendored
123
.github/workflows/ci.yml
vendored
|
|
@ -1,123 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, develop ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-test:
|
|
||||||
runs-on: [docker]
|
|
||||||
|
|
||||||
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: [docker]
|
|
||||||
|
|
||||||
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: [docker]
|
|
||||||
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 .
|
|
||||||
39
.github/workflows/frontend.yml
vendored
Normal file
39
.github/workflows/frontend.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
name: Frontend CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.github/workflows/frontend.yml'
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, develop ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.github/workflows/frontend.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend-test:
|
||||||
|
runs-on: [docker]
|
||||||
|
|
||||||
|
# Note: Using the container here ensures the cache volume works reliably
|
||||||
|
container:
|
||||||
|
image: nikolaik/python-nodejs:python3.12-nodejs24-alpine
|
||||||
|
options: --volume forgejo-npm-cache:/tmp/npm-cache
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Configure NPM cache
|
||||||
|
run: npm config set cache /tmp/npm-cache --global
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm config get cache
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
65
.pre-commit-config.yaml
Normal file
65
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
exclude: ^.+\.md$
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: ['--maxkb=1000']
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: debug-statements
|
||||||
|
language: python
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.12.1
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3.11
|
||||||
|
args: ['--line-length=100']
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.13.2
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
args: ['--profile=black', '--line-length=100']
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 7.0.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
args: ['--max-line-length=100', '--extend-ignore=E203,W503']
|
||||||
|
additional_dependencies: [
|
||||||
|
flake8-docstrings,
|
||||||
|
flake8-bugbear,
|
||||||
|
flake8-comprehensions,
|
||||||
|
]
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: pytest
|
||||||
|
name: pytest
|
||||||
|
entry: pytest
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
args: ['backend/', '-v', '--tb=short']
|
||||||
|
always_run: true
|
||||||
|
|
||||||
|
- id: type-check
|
||||||
|
name: mypy type check
|
||||||
|
entry: mypy
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
args: ['backend/app/']
|
||||||
|
always_run: false
|
||||||
|
|
||||||
|
- id: security-check
|
||||||
|
name: bandit security check
|
||||||
|
entry: bandit
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
args: ['-r', 'backend/app/', '-ll']
|
||||||
|
always_run: false
|
||||||
60
Makefile
60
Makefile
|
|
@ -67,6 +67,48 @@ test: ## Run all tests
|
||||||
test-backend: ## Run backend tests only
|
test-backend: ## Run backend tests only
|
||||||
cd backend && . venv/bin/activate && pytest
|
cd backend && . venv/bin/activate && pytest
|
||||||
|
|
||||||
|
test-backend-cov: ## Run backend tests with coverage
|
||||||
|
cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html --cov-report=term
|
||||||
|
|
||||||
|
test-backend-verbose: ## Run backend tests with verbose output
|
||||||
|
cd backend && . venv/bin/activate && pytest -v
|
||||||
|
|
||||||
|
test-backend-unit: ## Run backend unit tests only
|
||||||
|
cd backend && . venv/bin/activate && pytest -m unit
|
||||||
|
|
||||||
|
test-backend-integration: ## Run backend integration tests only
|
||||||
|
cd backend && . venv/bin/activate && pytest -m integration
|
||||||
|
|
||||||
|
test-backend-auth: ## Run backend authentication tests only
|
||||||
|
cd backend && . venv/bin/activate && pytest -m auth
|
||||||
|
|
||||||
|
test-backend-product: ## Run backend product tests only
|
||||||
|
cd backend && . venv/bin/activate && pytest -m product
|
||||||
|
|
||||||
|
test-backend-order: ## Run backend order tests only
|
||||||
|
cd backend && . venv/bin/activate && pytest -m order
|
||||||
|
|
||||||
|
test-backend-watch: ## Run backend tests in watch mode (auto-rerun on changes)
|
||||||
|
cd backend && . venv/bin/activate && pip install pytest-watch && pytest-watch
|
||||||
|
|
||||||
|
test-backend-parallel: ## Run backend tests in parallel (faster)
|
||||||
|
cd backend && . venv/bin/activate && pip install pytest-xdist && pytest -n auto
|
||||||
|
|
||||||
|
test-backend-coverage-report: ## Open backend coverage report in browser
|
||||||
|
cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html && python -m webbrowser htmlcov/index.html
|
||||||
|
|
||||||
|
test-backend-failed: ## Re-run only failed backend tests
|
||||||
|
cd backend && . venv/bin/activate && pytest --lf
|
||||||
|
|
||||||
|
test-backend-last-failed: ## Run the tests that failed in the last run
|
||||||
|
cd backend && . venv/bin/activate && pytest --lf
|
||||||
|
|
||||||
|
test-backend-specific: ## Run specific backend test (usage: make test-backend-specific TEST=test_models.py)
|
||||||
|
cd backend && . venv/bin/activate && pytest tests/$(TEST)
|
||||||
|
|
||||||
|
test-backend-marker: ## Run backend tests by marker (usage: make test-backend-marker MARKER=auth)
|
||||||
|
cd backend && . venv/bin/activate && pytest -m $(MARKER)
|
||||||
|
|
||||||
test-frontend: ## Run frontend tests only
|
test-frontend: ## Run frontend tests only
|
||||||
cd frontend && npm test
|
cd frontend && npm test
|
||||||
|
|
||||||
|
|
@ -84,7 +126,23 @@ lint-frontend: ## Lint frontend only
|
||||||
|
|
||||||
format: ## Format code
|
format: ## Format code
|
||||||
@echo "Formatting backend..."
|
@echo "Formatting backend..."
|
||||||
cd backend && . venv/bin/activate && black app tests && isort app tests
|
cd backend && . venv/bin/activate && black app tests
|
||||||
|
cd backend && . venv/bin/activate && isort app tests
|
||||||
|
@echo "Formatting frontend..."
|
||||||
|
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
|
||||||
|
|
||||||
|
format-backend: ## Format backend code only
|
||||||
|
@echo "Formatting backend with black..."
|
||||||
|
cd backend && . venv/bin/activate && black app tests
|
||||||
|
@echo "Sorting imports with isort..."
|
||||||
|
cd backend && . venv/bin/activate && isort app tests
|
||||||
|
|
||||||
|
format-backend-check: ## Check if backend code needs formatting
|
||||||
|
@echo "Checking backend formatting..."
|
||||||
|
cd backend && . venv/bin/activate && black --check app tests
|
||||||
|
cd backend && . venv/bin/activate && isort --check-only app tests
|
||||||
|
|
||||||
|
format-frontend: ## Format frontend code only
|
||||||
@echo "Formatting frontend..."
|
@echo "Formatting frontend..."
|
||||||
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
|
cd frontend && npx prettier --write "src/**/*.{js,jsx,ts,tsx,css}"
|
||||||
|
|
||||||
|
|
|
||||||
23
backend/.coveragerc
Normal file
23
backend/.coveragerc
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[run]
|
||||||
|
source = app
|
||||||
|
omit =
|
||||||
|
*/tests/*
|
||||||
|
*/migrations/*
|
||||||
|
*/__pycache__/*
|
||||||
|
*/venv/*
|
||||||
|
*/instance/*
|
||||||
|
app/__init__.py
|
||||||
|
|
||||||
|
[report]
|
||||||
|
exclude_lines =
|
||||||
|
pragma: no cover
|
||||||
|
def __repr__
|
||||||
|
raise NotImplementedError
|
||||||
|
if __name__ == .__main__.:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
@abstractmethod
|
||||||
|
pass
|
||||||
|
precision = 2
|
||||||
|
|
||||||
|
[html]
|
||||||
|
directory = htmlcov
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_jwt_extended import JWTManager
|
from flask_jwt_extended import JWTManager
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
import os
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from dotenv import load_dotenv
|
|
||||||
# Create extensions but don't initialize them yet
|
# Create extensions but don't initialize them yet
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
|
|
@ -13,6 +15,7 @@ jwt = JWTManager()
|
||||||
cors = CORS()
|
cors = CORS()
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name=None):
|
def create_app(config_name=None):
|
||||||
"""Application factory pattern"""
|
"""Application factory pattern"""
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
@ -22,38 +25,52 @@ def create_app(config_name=None):
|
||||||
config_name = os.environ.get("FLASK_ENV", "development")
|
config_name = os.environ.get("FLASK_ENV", "development")
|
||||||
|
|
||||||
from app.config import config_by_name
|
from app.config import config_by_name
|
||||||
|
|
||||||
app.config.from_object(config_by_name[config_name])
|
app.config.from_object(config_by_name[config_name])
|
||||||
|
|
||||||
print('----------------------------------------------------------')
|
print("----------------------------------------------------------")
|
||||||
print(F'------------------ENVIRONMENT: {config_name}-------------------------------------')
|
print(
|
||||||
|
f"------------------ENVIRONMENT: {config_name}-------------------------------------"
|
||||||
|
)
|
||||||
# print(F'------------------CONFIG: {app.config}-------------------------------------')
|
# print(F'------------------CONFIG: {app.config}-------------------------------------')
|
||||||
# print(json.dumps(dict(app.config), indent=2, default=str))
|
print(json.dumps(dict(app.config), indent=2, default=str))
|
||||||
print('----------------------------------------------------------')
|
print("----------------------------------------------------------")
|
||||||
# Initialize extensions with app
|
# Initialize extensions with app
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
jwt.init_app(app)
|
jwt.init_app(app)
|
||||||
cors.init_app(app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}})
|
cors.init_app(
|
||||||
|
app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}}
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize Celery
|
# Initialize Celery
|
||||||
from app.celery import init_celery
|
from app.celery import init_celery
|
||||||
|
|
||||||
init_celery(app)
|
init_celery(app)
|
||||||
|
|
||||||
# Import models (required for migrations)
|
# Import models (required for migrations)
|
||||||
from app.models import user, product, order
|
from app.models import order, product, user # noqa: F401
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import api_bp, health_bp
|
from app.routes import api_bp, health_bp
|
||||||
|
|
||||||
app.register_blueprint(api_bp, url_prefix="/api")
|
app.register_blueprint(api_bp, url_prefix="/api")
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
|
|
||||||
# Global error handlers
|
# Global error handlers
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(error):
|
def not_found(error):
|
||||||
|
print(f"404 Error: {error}")
|
||||||
return jsonify({"error": "Not found"}), 404
|
return jsonify({"error": "Not found"}), 404
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
|
print(f"500 Error: {error}")
|
||||||
return jsonify({"error": "Internal server error"}), 500
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
|
|
||||||
|
@app.errorhandler(422)
|
||||||
|
def validation_error(error):
|
||||||
|
print(f"422 Error: {error}")
|
||||||
|
return jsonify({"error": "Validation error"}), 422
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ def make_celery(app: Flask) -> Celery:
|
||||||
celery_app = Celery(
|
celery_app = Celery(
|
||||||
app.import_name,
|
app.import_name,
|
||||||
broker=app.config["CELERY"]["broker_url"],
|
broker=app.config["CELERY"]["broker_url"],
|
||||||
backend=app.config["CELERY"]["result_backend"]
|
backend=app.config["CELERY"]["result_backend"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update configuration from Flask config
|
# Update configuration from Flask config
|
||||||
|
|
@ -30,6 +30,7 @@ def make_celery(app: Flask) -> Celery:
|
||||||
# This ensures tasks have access to Flask extensions (db, etc.)
|
# This ensures tasks have access to Flask extensions (db, etc.)
|
||||||
class ContextTask(celery_app.Task):
|
class ContextTask(celery_app.Task):
|
||||||
"""Celery task that runs within Flask application context."""
|
"""Celery task that runs within Flask application context."""
|
||||||
|
|
||||||
def __call__(self, *args, **kwargs):
|
def __call__(self, *args, **kwargs):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
return self.run(*args, **kwargs)
|
return self.run(*args, **kwargs)
|
||||||
|
|
@ -37,14 +38,15 @@ def make_celery(app: Flask) -> Celery:
|
||||||
celery_app.Task = ContextTask
|
celery_app.Task = ContextTask
|
||||||
|
|
||||||
# Auto-discover tasks in the tasks module
|
# Auto-discover tasks in the tasks module
|
||||||
celery_app.autodiscover_tasks(['app.celery.tasks'])
|
celery_app.autodiscover_tasks(["app.celery.tasks"])
|
||||||
|
|
||||||
# Configure Beat schedule
|
# Configure Beat schedule
|
||||||
from .beat_schedule import configure_beat_schedule
|
from .beat_schedule import configure_beat_schedule
|
||||||
|
|
||||||
configure_beat_schedule(celery_app)
|
configure_beat_schedule(celery_app)
|
||||||
|
|
||||||
# Import tasks to ensure they're registered
|
# Import tasks to ensure they're registered
|
||||||
from .tasks import example_tasks
|
from .tasks import example_tasks # noqa: F401
|
||||||
|
|
||||||
print(f"✅ Celery configured with broker: {celery_app.conf.broker_url}")
|
print(f"✅ Celery configured with broker: {celery_app.conf.broker_url}")
|
||||||
print(f"✅ Celery configured with backend: {celery_app.conf.result_backend}")
|
print(f"✅ Celery configured with backend: {celery_app.conf.result_backend}")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ This defines when scheduled tasks should run.
|
||||||
"""
|
"""
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
|
||||||
# Celery Beat schedule configuration
|
# Celery Beat schedule configuration
|
||||||
beat_schedule = {
|
beat_schedule = {
|
||||||
# Run every minute (for testing/demo)
|
# Run every minute (for testing/demo)
|
||||||
|
|
@ -14,14 +13,12 @@ beat_schedule = {
|
||||||
"args": ("Celery Beat",),
|
"args": ("Celery Beat",),
|
||||||
"options": {"queue": "default"},
|
"options": {"queue": "default"},
|
||||||
},
|
},
|
||||||
|
|
||||||
# Run daily at 9:00 AM
|
# Run daily at 9:00 AM
|
||||||
"send-daily-report": {
|
"send-daily-report": {
|
||||||
"task": "tasks.send_daily_report",
|
"task": "tasks.send_daily_report",
|
||||||
"schedule": crontab(hour=9, minute=0), # 9:00 AM daily
|
"schedule": crontab(hour=9, minute=0), # 9:00 AM daily
|
||||||
"options": {"queue": "reports"},
|
"options": {"queue": "reports"},
|
||||||
},
|
},
|
||||||
|
|
||||||
# Run every hour at minute 0
|
# Run every hour at minute 0
|
||||||
"update-product-stats-hourly": {
|
"update-product-stats-hourly": {
|
||||||
"task": "tasks.update_product_statistics",
|
"task": "tasks.update_product_statistics",
|
||||||
|
|
@ -29,7 +26,6 @@ beat_schedule = {
|
||||||
"args": (None,), # Update all products
|
"args": (None,), # Update all products
|
||||||
"options": {"queue": "stats"},
|
"options": {"queue": "stats"},
|
||||||
},
|
},
|
||||||
|
|
||||||
# Run every Monday at 8:00 AM
|
# Run every Monday at 8:00 AM
|
||||||
"weekly-maintenance": {
|
"weekly-maintenance": {
|
||||||
"task": "tasks.long_running_task",
|
"task": "tasks.long_running_task",
|
||||||
|
|
@ -37,7 +33,6 @@ beat_schedule = {
|
||||||
"args": (5,), # 5 iterations
|
"args": (5,), # 5 iterations
|
||||||
"options": {"queue": "maintenance"},
|
"options": {"queue": "maintenance"},
|
||||||
},
|
},
|
||||||
|
|
||||||
# Run every 5 minutes (for monitoring/heartbeat)
|
# Run every 5 minutes (for monitoring/heartbeat)
|
||||||
"heartbeat-check": {
|
"heartbeat-check": {
|
||||||
"task": "tasks.print_hello",
|
"task": "tasks.print_hello",
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ Tasks are organized by domain/functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Import all task modules here to ensure they're registered with Celery
|
# Import all task modules here to ensure they're registered with Celery
|
||||||
from . import example_tasks
|
from . import example_tasks # noqa: F401
|
||||||
|
|
||||||
# Re-export tasks for easier imports
|
# Re-export tasks for easier imports
|
||||||
from .example_tasks import (
|
from .example_tasks import ( # noqa: F401
|
||||||
print_hello,
|
|
||||||
divide_numbers,
|
divide_numbers,
|
||||||
|
print_hello,
|
||||||
send_daily_report,
|
send_daily_report,
|
||||||
update_product_statistics,
|
update_product_statistics,
|
||||||
)
|
)
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
Example Celery tasks for the Crafting Shop application.
|
Example Celery tasks for the Crafting Shop application.
|
||||||
These tasks demonstrate various Celery features and best practices.
|
These tasks demonstrate various Celery features and best practices.
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.exceptions import MaxRetriesExceededError
|
|
||||||
|
|
||||||
# Get logger for this module
|
# Get logger for this module
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -36,7 +36,7 @@ def print_hello(self, name: str = "World") -> str:
|
||||||
retry_backoff=True,
|
retry_backoff=True,
|
||||||
retry_backoff_max=60,
|
retry_backoff_max=60,
|
||||||
retry_jitter=True,
|
retry_jitter=True,
|
||||||
max_retries=3
|
max_retries=3,
|
||||||
)
|
)
|
||||||
def divide_numbers(self, x: float, y: float) -> float:
|
def divide_numbers(self, x: float, y: float) -> float:
|
||||||
"""
|
"""
|
||||||
|
|
@ -55,7 +55,7 @@ def divide_numbers(self, x: float, y: float) -> float:
|
||||||
logger.info(f"Dividing {x} by {y} (attempt {self.request.retries + 1})")
|
logger.info(f"Dividing {x} by {y} (attempt {self.request.retries + 1})")
|
||||||
|
|
||||||
if y == 0:
|
if y == 0:
|
||||||
logger.warning(f"Division by zero detected, retrying...")
|
logger.warning("Division by zero detected, retrying...")
|
||||||
raise ZeroDivisionError("Cannot divide by zero")
|
raise ZeroDivisionError("Cannot divide by zero")
|
||||||
|
|
||||||
result = x / y
|
result = x / y
|
||||||
|
|
@ -63,11 +63,7 @@ def divide_numbers(self, x: float, y: float) -> float:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
@shared_task(bind=True, name="tasks.send_daily_report", ignore_result=False)
|
||||||
bind=True,
|
|
||||||
name="tasks.send_daily_report",
|
|
||||||
ignore_result=False
|
|
||||||
)
|
|
||||||
def send_daily_report(self) -> dict:
|
def send_daily_report(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Simulates sending a daily report.
|
Simulates sending a daily report.
|
||||||
|
|
@ -90,8 +86,8 @@ def send_daily_report(self) -> dict:
|
||||||
"total_products": 150,
|
"total_products": 150,
|
||||||
"total_orders": 42,
|
"total_orders": 42,
|
||||||
"total_users": 89,
|
"total_users": 89,
|
||||||
"revenue": 12500.75
|
"revenue": 12500.75,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Daily report generated: {report_data}")
|
logger.info(f"Daily report generated: {report_data}")
|
||||||
|
|
@ -101,10 +97,7 @@ def send_daily_report(self) -> dict:
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
@shared_task(
|
||||||
bind=True,
|
bind=True, name="tasks.update_product_statistics", queue="stats", priority=5
|
||||||
name="tasks.update_product_statistics",
|
|
||||||
queue="stats",
|
|
||||||
priority=5
|
|
||||||
)
|
)
|
||||||
def update_product_statistics(self, product_id: int = None) -> dict:
|
def update_product_statistics(self, product_id: int = None) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|
@ -129,7 +122,7 @@ def update_product_statistics(self, product_id: int = None) -> dict:
|
||||||
"task": "update_all_product_stats",
|
"task": "update_all_product_stats",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"products_updated": 150,
|
"products_updated": 150,
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Update specific product
|
# Update specific product
|
||||||
|
|
@ -138,11 +131,7 @@ def update_product_statistics(self, product_id: int = None) -> dict:
|
||||||
"product_id": product_id,
|
"product_id": product_id,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"new_stats": {
|
"new_stats": {"views": 125, "purchases": 15, "rating": 4.5},
|
||||||
"views": 125,
|
|
||||||
"purchases": 15,
|
|
||||||
"rating": 4.5
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Product statistics updated: {result}")
|
logger.info(f"Product statistics updated: {result}")
|
||||||
|
|
@ -153,7 +142,7 @@ def update_product_statistics(self, product_id: int = None) -> dict:
|
||||||
bind=True,
|
bind=True,
|
||||||
name="tasks.long_running_task",
|
name="tasks.long_running_task",
|
||||||
time_limit=300, # 5 minutes
|
time_limit=300, # 5 minutes
|
||||||
soft_time_limit=240 # 4 minutes
|
soft_time_limit=240, # 4 minutes
|
||||||
)
|
)
|
||||||
def long_running_task(self, iterations: int = 10) -> dict:
|
def long_running_task(self, iterations: int = 10) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|
@ -181,7 +170,7 @@ def long_running_task(self, iterations: int = 10) -> dict:
|
||||||
progress = (i + 1) / iterations * 100
|
progress = (i + 1) / iterations * 100
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"current": i + 1, "total": iterations, "progress": progress}
|
meta={"current": i + 1, "total": iterations, "progress": progress},
|
||||||
)
|
)
|
||||||
|
|
||||||
results.append(f"iteration_{i + 1}")
|
results.append(f"iteration_{i + 1}")
|
||||||
|
|
@ -191,7 +180,7 @@ def long_running_task(self, iterations: int = 10) -> dict:
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"iterations": iterations,
|
"iterations": iterations,
|
||||||
"results": results,
|
"results": results,
|
||||||
"completed_at": datetime.now().isoformat()
|
"completed_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Long-running task completed: {final_result}")
|
logger.info(f"Long-running task completed: {final_result}")
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ from datetime import timedelta
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Base configuration"""
|
"""Base configuration"""
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or "jwt-secret-key-change-in-production"
|
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
|
||||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
||||||
|
|
@ -14,7 +15,9 @@ class Config:
|
||||||
# Celery Configuration
|
# Celery Configuration
|
||||||
CELERY = {
|
CELERY = {
|
||||||
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://redis:6379/0"),
|
||||||
"result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://redis:6379/0"),
|
"result_backend": os.environ.get(
|
||||||
|
"CELERY_RESULT_BACKEND", "redis://redis:6379/0"
|
||||||
|
),
|
||||||
"task_serializer": "json",
|
"task_serializer": "json",
|
||||||
"result_serializer": "json",
|
"result_serializer": "json",
|
||||||
"accept_content": ["json"],
|
"accept_content": ["json"],
|
||||||
|
|
@ -31,12 +34,14 @@ class Config:
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db"
|
SQLALCHEMY_DATABASE_URI = os.environ.get("DEV_DATABASE_URL") or "sqlite:///dev.db"
|
||||||
|
|
||||||
|
|
||||||
class TestingConfig(Config):
|
class TestingConfig(Config):
|
||||||
"""Testing configuration"""
|
"""Testing configuration"""
|
||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db"
|
SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") or "sqlite:///test.db"
|
||||||
WTF_CSRF_ENABLED = False
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
@ -44,8 +49,11 @@ class TestingConfig(Config):
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
"""Production configuration"""
|
"""Production configuration"""
|
||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb"
|
SQLALCHEMY_DATABASE_URI = (
|
||||||
|
os.environ.get("DATABASE_URL") or "postgresql://user:password@localhost/proddb"
|
||||||
|
)
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
@ -56,5 +64,5 @@ class ProductionConfig(Config):
|
||||||
config_by_name = {
|
config_by_name = {
|
||||||
"dev": DevelopmentConfig,
|
"dev": DevelopmentConfig,
|
||||||
"test": TestingConfig,
|
"test": TestingConfig,
|
||||||
"prod": ProductionConfig
|
"prod": ProductionConfig,
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from app.models.user import User
|
|
||||||
from app.models.product import Product
|
|
||||||
from app.models.order import Order, OrderItem
|
from app.models.order import Order, OrderItem
|
||||||
|
from app.models.product import Product
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "Product", "Order", "OrderItem"]
|
__all__ = ["User", "Product", "Order", "OrderItem"]
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
class Order(db.Model):
|
class Order(db.Model):
|
||||||
"""Order model"""
|
"""Order model"""
|
||||||
|
|
||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
@ -11,12 +13,21 @@ class Order(db.Model):
|
||||||
status = db.Column(db.String(20), default="pending", index=True)
|
status = db.Column(db.String(20), default="pending", index=True)
|
||||||
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||||
shipping_address = db.Column(db.Text)
|
shipping_address = db.Column(db.Text)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user = db.relationship("User", back_populates="orders")
|
user = db.relationship("User", back_populates="orders")
|
||||||
items = db.relationship("OrderItem", back_populates="order", lazy="dynamic", cascade="all, delete-orphan")
|
items = db.relationship(
|
||||||
|
"OrderItem",
|
||||||
|
back_populates="order",
|
||||||
|
lazy="dynamic",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert order to dictionary"""
|
"""Convert order to dictionary"""
|
||||||
|
|
@ -28,7 +39,7 @@ class Order(db.Model):
|
||||||
"shipping_address": self.shipping_address,
|
"shipping_address": self.shipping_address,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
"items": [item.to_dict() for item in self.items]
|
"items": [item.to_dict() for item in self.items],
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
@ -37,6 +48,7 @@ class Order(db.Model):
|
||||||
|
|
||||||
class OrderItem(db.Model):
|
class OrderItem(db.Model):
|
||||||
"""Order Item model"""
|
"""Order Item model"""
|
||||||
|
|
||||||
__tablename__ = "order_items"
|
__tablename__ = "order_items"
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
@ -56,7 +68,7 @@ class OrderItem(db.Model):
|
||||||
"order_id": self.order_id,
|
"order_id": self.order_id,
|
||||||
"product_id": self.product_id,
|
"product_id": self.product_id,
|
||||||
"quantity": self.quantity,
|
"quantity": self.quantity,
|
||||||
"price": float(self.price) if self.price else None
|
"price": float(self.price) if self.price else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
class Product(db.Model):
|
class Product(db.Model):
|
||||||
"""Product model"""
|
"""Product model"""
|
||||||
|
|
||||||
__tablename__ = "products"
|
__tablename__ = "products"
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
@ -13,8 +15,12 @@ class Product(db.Model):
|
||||||
stock = db.Column(db.Integer, default=0)
|
stock = db.Column(db.Integer, default=0)
|
||||||
image_url = db.Column(db.String(500))
|
image_url = db.Column(db.String(500))
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
|
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
|
||||||
|
|
@ -30,7 +36,7 @@ class Product(db.Model):
|
||||||
"image_url": self.image_url,
|
"image_url": self.image_url,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
"""User model"""
|
"""User model"""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
@ -15,8 +18,12 @@ class User(db.Model):
|
||||||
last_name = db.Column(db.String(50))
|
last_name = db.Column(db.String(50))
|
||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
||||||
|
|
@ -40,7 +47,7 @@ class User(db.Model):
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"is_admin": self.is_admin,
|
"is_admin": self.is_admin,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import time
|
from flask import Blueprint, jsonify, request
|
||||||
from decimal import Decimal
|
from flask_jwt_extended import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
get_jwt_identity,
|
||||||
|
jwt_required,
|
||||||
|
)
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
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 import db
|
||||||
from app.models import User, Product, OrderItem, Order
|
|
||||||
from app.celery import celery
|
from app.celery import celery
|
||||||
|
from app.models import Order, OrderItem, Product, User
|
||||||
from app.schemas import ProductCreateRequest, ProductResponse
|
from app.schemas import ProductCreateRequest, ProductResponse
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
@ -28,7 +31,7 @@ def register():
|
||||||
email=data["email"],
|
email=data["email"],
|
||||||
username=data.get("username", data["email"].split("@")[0]),
|
username=data.get("username", data["email"].split("@")[0]),
|
||||||
first_name=data.get("first_name"),
|
first_name=data.get("first_name"),
|
||||||
last_name=data.get("last_name")
|
last_name=data.get("last_name"),
|
||||||
)
|
)
|
||||||
user.set_password(data["password"])
|
user.set_password(data["password"])
|
||||||
|
|
||||||
|
|
@ -54,22 +57,27 @@ def login():
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
return jsonify({"error": "Account is inactive"}), 401
|
return jsonify({"error": "Account is inactive"}), 401
|
||||||
|
|
||||||
access_token = create_access_token(identity=user.id)
|
access_token = create_access_token(identity=str(user.id))
|
||||||
refresh_token = create_refresh_token(identity=user.id)
|
refresh_token = create_refresh_token(identity=str(user.id))
|
||||||
|
|
||||||
return jsonify({
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
"user": user.to_dict(),
|
"user": user.to_dict(),
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token
|
"refresh_token": refresh_token,
|
||||||
}), 200
|
}
|
||||||
|
),
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/users/me", methods=["GET"])
|
@api_bp.route("/users/me", methods=["GET"])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
"""Get current user"""
|
"""Get current user"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
user = User.query.get(user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"error": "User not found"}), 404
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
@ -82,7 +90,6 @@ def get_current_user():
|
||||||
def get_products():
|
def get_products():
|
||||||
"""Get all products"""
|
"""Get all products"""
|
||||||
|
|
||||||
|
|
||||||
# time.sleep(5) # This adds a 5 second delay
|
# time.sleep(5) # This adds a 5 second delay
|
||||||
|
|
||||||
products = Product.query.filter_by(is_active=True).all()
|
products = Product.query.filter_by(is_active=True).all()
|
||||||
|
|
@ -93,7 +100,9 @@ def get_products():
|
||||||
@api_bp.route("/products/<int:product_id>", methods=["GET"])
|
@api_bp.route("/products/<int:product_id>", methods=["GET"])
|
||||||
def get_product(product_id):
|
def get_product(product_id):
|
||||||
"""Get a single product"""
|
"""Get a single product"""
|
||||||
product = Product.query.get_or_404(product_id)
|
product = db.session.get(Product, product_id)
|
||||||
|
if not product:
|
||||||
|
return jsonify({"error": "Product not found"}), 404
|
||||||
return jsonify(product.to_dict()), 200
|
return jsonify(product.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,8 +110,8 @@ def get_product(product_id):
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def create_product():
|
def create_product():
|
||||||
"""Create a new product (admin only)"""
|
"""Create a new product (admin only)"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
user = User.query.get(user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
if not user or not user.is_admin:
|
if not user or not user.is_admin:
|
||||||
return jsonify({"error": "Admin access required"}), 403
|
return jsonify({"error": "Admin access required"}), 403
|
||||||
|
|
@ -117,7 +126,6 @@ def create_product():
|
||||||
price=product_data.price,
|
price=product_data.price,
|
||||||
stock=product_data.stock,
|
stock=product_data.stock,
|
||||||
image_url=product_data.image_url,
|
image_url=product_data.image_url,
|
||||||
category=product_data.category
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(product)
|
db.session.add(product)
|
||||||
|
|
@ -128,6 +136,7 @@ def create_product():
|
||||||
return jsonify(response.model_dump()), 201
|
return jsonify(response.model_dump()), 201
|
||||||
|
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
|
print(f"Pydantic Validation Error: {e.errors()}")
|
||||||
return jsonify({"error": "Validation error", "details": e.errors()}), 400
|
return jsonify({"error": "Validation error", "details": e.errors()}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -135,13 +144,16 @@ def create_product():
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def update_product(product_id):
|
def update_product(product_id):
|
||||||
"""Update a product (admin only)"""
|
"""Update a product (admin only)"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
user = User.query.get(user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
if not user or not user.is_admin:
|
if not user or not user.is_admin:
|
||||||
return jsonify({"error": "Admin access required"}), 403
|
return jsonify({"error": "Admin access required"}), 403
|
||||||
|
|
||||||
product = Product.query.get_or_404(product_id)
|
product = db.session.get(Product, product_id)
|
||||||
|
if not product:
|
||||||
|
return jsonify({"error": "Product not found"}), 404
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
product.name = data.get("name", product.name)
|
product.name = data.get("name", product.name)
|
||||||
|
|
@ -149,7 +161,6 @@ def update_product(product_id):
|
||||||
product.price = data.get("price", product.price)
|
product.price = data.get("price", product.price)
|
||||||
product.stock = data.get("stock", product.stock)
|
product.stock = data.get("stock", product.stock)
|
||||||
product.image_url = data.get("image_url", product.image_url)
|
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)
|
product.is_active = data.get("is_active", product.is_active)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
@ -161,13 +172,16 @@ def update_product(product_id):
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def delete_product(product_id):
|
def delete_product(product_id):
|
||||||
"""Delete a product (admin only)"""
|
"""Delete a product (admin only)"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
user = User.query.get(user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
if not user or not user.is_admin:
|
if not user or not user.is_admin:
|
||||||
return jsonify({"error": "Admin access required"}), 403
|
return jsonify({"error": "Admin access required"}), 403
|
||||||
|
|
||||||
product = Product.query.get_or_404(product_id)
|
product = db.session.get(Product, product_id)
|
||||||
|
if not product:
|
||||||
|
return jsonify({"error": "Product not found"}), 404
|
||||||
|
|
||||||
db.session.delete(product)
|
db.session.delete(product)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
@ -179,7 +193,7 @@ def delete_product(product_id):
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_orders():
|
def get_orders():
|
||||||
"""Get all orders for current user"""
|
"""Get all orders for current user"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
orders = Order.query.filter_by(user_id=user_id).all()
|
orders = Order.query.filter_by(user_id=user_id).all()
|
||||||
return jsonify([order.to_dict() for order in orders]), 200
|
return jsonify([order.to_dict() for order in orders]), 200
|
||||||
|
|
||||||
|
|
@ -188,7 +202,7 @@ def get_orders():
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def create_order():
|
def create_order():
|
||||||
"""Create a new order"""
|
"""Create a new order"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if not data or not data.get("items"):
|
if not data or not data.get("items"):
|
||||||
|
|
@ -198,25 +212,29 @@ def create_order():
|
||||||
order_items = []
|
order_items = []
|
||||||
|
|
||||||
for item_data in data["items"]:
|
for item_data in data["items"]:
|
||||||
product = Product.query.get(item_data["product_id"])
|
product = db.session.get(Product, item_data["product_id"])
|
||||||
if not product:
|
if not product:
|
||||||
return jsonify({"error": f'Product {item_data["product_id"]} not found'}), 404
|
return (
|
||||||
|
jsonify({"error": f'Product {item_data["product_id"]} not found'}),
|
||||||
|
404,
|
||||||
|
)
|
||||||
if product.stock < item_data["quantity"]:
|
if product.stock < item_data["quantity"]:
|
||||||
return jsonify({"error": f'Insufficient stock for {product.name}'}), 400
|
return jsonify({"error": f"Insufficient stock for {product.name}"}), 400
|
||||||
|
|
||||||
item_total = product.price * item_data["quantity"]
|
item_total = product.price * item_data["quantity"]
|
||||||
total_amount += item_total
|
total_amount += item_total
|
||||||
order_items.append({
|
order_items.append(
|
||||||
|
{
|
||||||
"product": product,
|
"product": product,
|
||||||
"quantity": item_data["quantity"],
|
"quantity": item_data["quantity"],
|
||||||
"price": product.price
|
"price": product.price,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
order = Order(
|
order = Order(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
total_amount=total_amount,
|
total_amount=total_amount,
|
||||||
shipping_address=data.get("shipping_address"),
|
shipping_address=data.get("shipping_address"),
|
||||||
notes=data.get("notes")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(order)
|
db.session.add(order)
|
||||||
|
|
@ -227,7 +245,7 @@ def create_order():
|
||||||
order_id=order.id,
|
order_id=order.id,
|
||||||
product_id=item_data["product"].id,
|
product_id=item_data["product"].id,
|
||||||
quantity=item_data["quantity"],
|
quantity=item_data["quantity"],
|
||||||
price=item_data["price"]
|
price=item_data["price"],
|
||||||
)
|
)
|
||||||
item_data["product"].stock -= item_data["quantity"]
|
item_data["product"].stock -= item_data["quantity"]
|
||||||
db.session.add(order_item)
|
db.session.add(order_item)
|
||||||
|
|
@ -241,11 +259,13 @@ def create_order():
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_order(order_id):
|
def get_order(order_id):
|
||||||
"""Get a single order"""
|
"""Get a single order"""
|
||||||
user_id = get_jwt_identity()
|
user_id = int(get_jwt_identity())
|
||||||
order = Order.query.get_or_404(order_id)
|
order = db.session.get(Order, order_id)
|
||||||
|
if not order:
|
||||||
|
return jsonify({"error": "Order not found"}), 404
|
||||||
|
|
||||||
if order.user_id != user_id:
|
if order.user_id != user_id:
|
||||||
user = User.query.get(user_id)
|
user = db.session.get(User, user_id)
|
||||||
if not user or not user.is_admin:
|
if not user or not user.is_admin:
|
||||||
return jsonify({"error": "Access denied"}), 403
|
return jsonify({"error": "Access denied"}), 403
|
||||||
|
|
||||||
|
|
@ -262,11 +282,12 @@ def trigger_hello_task():
|
||||||
|
|
||||||
task = celery.send_task("tasks.print_hello", args=[name])
|
task = celery.send_task("tasks.print_hello", args=[name])
|
||||||
|
|
||||||
return jsonify({
|
return (
|
||||||
"message": "Hello task triggered",
|
jsonify(
|
||||||
"task_id": task.id,
|
{"message": "Hello task triggered", "task_id": task.id, "status": "pending"}
|
||||||
"status": "pending"
|
),
|
||||||
}), 202
|
202,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/tasks/divide", methods=["POST"])
|
@api_bp.route("/tasks/divide", methods=["POST"])
|
||||||
|
|
@ -279,12 +300,17 @@ def trigger_divide_task():
|
||||||
|
|
||||||
task = celery.send_task("tasks.divide_numbers", args=[x, y])
|
task = celery.send_task("tasks.divide_numbers", args=[x, y])
|
||||||
|
|
||||||
return jsonify({
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
"message": "Divide task triggered",
|
"message": "Divide task triggered",
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"operation": f"{x} / {y}",
|
"operation": f"{x} / {y}",
|
||||||
"status": "pending"
|
"status": "pending",
|
||||||
}), 202
|
}
|
||||||
|
),
|
||||||
|
202,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/tasks/report", methods=["POST"])
|
@api_bp.route("/tasks/report", methods=["POST"])
|
||||||
|
|
@ -293,11 +319,16 @@ def trigger_report_task():
|
||||||
"""Trigger the daily report task"""
|
"""Trigger the daily report task"""
|
||||||
task = celery.send_task("tasks.send_daily_report")
|
task = celery.send_task("tasks.send_daily_report")
|
||||||
|
|
||||||
return jsonify({
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
"message": "Daily report task triggered",
|
"message": "Daily report task triggered",
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"status": "pending"
|
"status": "pending",
|
||||||
}), 202
|
}
|
||||||
|
),
|
||||||
|
202,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/tasks/stats", methods=["POST"])
|
@api_bp.route("/tasks/stats", methods=["POST"])
|
||||||
|
|
@ -314,11 +345,7 @@ def trigger_stats_task():
|
||||||
task = celery.send_task("tasks.update_product_statistics", args=[None])
|
task = celery.send_task("tasks.update_product_statistics", args=[None])
|
||||||
message = "Product statistics update triggered for all products"
|
message = "Product statistics update triggered for all products"
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({"message": message, "task_id": task.id, "status": "pending"}), 202
|
||||||
"message": message,
|
|
||||||
"task_id": task.id,
|
|
||||||
"status": "pending"
|
|
||||||
}), 202
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/tasks/long-running", methods=["POST"])
|
@api_bp.route("/tasks/long-running", methods=["POST"])
|
||||||
|
|
@ -330,11 +357,16 @@ def trigger_long_running_task():
|
||||||
|
|
||||||
task = celery.send_task("tasks.long_running_task", args=[iterations])
|
task = celery.send_task("tasks.long_running_task", args=[iterations])
|
||||||
|
|
||||||
return jsonify({
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
"message": f"Long-running task triggered with {iterations} iterations",
|
"message": f"Long-running task triggered with {iterations} iterations",
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"status": "pending"
|
"status": "pending",
|
||||||
}), 202
|
}
|
||||||
|
),
|
||||||
|
202,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/tasks/<task_id>", methods=["GET"])
|
@api_bp.route("/tasks/<task_id>", methods=["GET"])
|
||||||
|
|
@ -346,7 +378,7 @@ def get_task_status(task_id):
|
||||||
response = {
|
response = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": task_result.status,
|
"status": task_result.status,
|
||||||
"ready": task_result.ready()
|
"ready": task_result.ready(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if task_result.ready():
|
if task_result.ready():
|
||||||
|
|
@ -368,18 +400,16 @@ def celery_health():
|
||||||
stats = inspector.stats()
|
stats = inspector.stats()
|
||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
return jsonify({
|
return (
|
||||||
"status": "healthy",
|
jsonify(
|
||||||
"workers": len(stats),
|
{"status": "healthy", "workers": len(stats), "workers_info": stats}
|
||||||
"workers_info": stats
|
),
|
||||||
}), 200
|
200,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return (
|
||||||
"status": "unhealthy",
|
jsonify({"status": "unhealthy", "message": "No workers available"}),
|
||||||
"message": "No workers available"
|
503,
|
||||||
}), 503
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({
|
return jsonify({"status": "error", "message": str(e)}), 500
|
||||||
"status": "error",
|
|
||||||
"message": str(e)
|
|
||||||
}), 500
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
health_bp = Blueprint('health', __name__)
|
health_bp = Blueprint("health", __name__)
|
||||||
|
|
||||||
|
|
||||||
@health_bp.route('/', methods=['GET'])
|
@health_bp.route("/", methods=["GET"])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return jsonify({
|
return jsonify({"status": "healthy", "service": "crafting-shop-backend"}), 200
|
||||||
'status': 'healthy',
|
|
||||||
'service': 'crafting-shop-backend'
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@health_bp.route('/readiness', methods=['GET'])
|
@health_bp.route("/readiness", methods=["GET"])
|
||||||
def readiness_check():
|
def readiness_check():
|
||||||
"""Readiness check endpoint"""
|
"""Readiness check endpoint"""
|
||||||
# Add database check here if needed
|
# Add database check here if needed
|
||||||
return jsonify({
|
return jsonify({"status": "ready", "service": "crafting-shop-backend"}), 200
|
||||||
'status': 'ready',
|
|
||||||
'service': 'crafting-shop-backend'
|
|
||||||
}), 200
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
"""Pydantic schemas for Product model"""
|
"""Pydantic schemas for Product model"""
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from decimal import Decimal
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductCreateRequest(BaseModel):
|
class ProductCreateRequest(BaseModel):
|
||||||
"""Schema for creating a new product"""
|
"""Schema for creating a new product"""
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="Product name")
|
|
||||||
description: Optional[str] = Field(None, description="Product description")
|
|
||||||
price: Decimal = Field(..., gt=0, description="Product price (must be greater than 0)")
|
|
||||||
stock: int = Field(default=0, ge=0, description="Product stock quantity")
|
|
||||||
image_url: Optional[str] = Field(None, max_length=500, description="Product image URL")
|
|
||||||
category: Optional[str] = Field(None, description="Product category")
|
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(
|
||||||
json_schema_extra = {
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"name": "Handcrafted Wooden Bowl",
|
"name": "Handcrafted Wooden Bowl",
|
||||||
"description": "A beautiful handcrafted bowl made from oak",
|
"description": "A beautiful handcrafted bowl made from oak",
|
||||||
"price": 45.99,
|
"price": 45.99,
|
||||||
"stock": 10,
|
"stock": 10,
|
||||||
"image_url": "https://example.com/bowl.jpg",
|
"image_url": "https://example.com/bowl.jpg",
|
||||||
"category": "Woodwork"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=200, description="Product name")
|
||||||
|
description: Optional[str] = Field(None, description="Product description")
|
||||||
|
price: Decimal = Field(
|
||||||
|
..., gt=0, description="Product price (must be greater than 0)"
|
||||||
|
)
|
||||||
|
stock: int = Field(default=0, ge=0, description="Product stock quantity")
|
||||||
|
image_url: Optional[str] = Field(
|
||||||
|
None, max_length=500, description="Product image URL"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("price")
|
@field_validator("price")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -37,19 +42,10 @@ class ProductCreateRequest(BaseModel):
|
||||||
|
|
||||||
class ProductResponse(BaseModel):
|
class ProductResponse(BaseModel):
|
||||||
"""Schema for product response"""
|
"""Schema for product response"""
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
price: float
|
|
||||||
stock: int
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
is_active: bool
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(
|
||||||
from_attributes = True
|
from_attributes=True,
|
||||||
json_schema_extra = {
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Handcrafted Wooden Bowl",
|
"name": "Handcrafted Wooden Bowl",
|
||||||
|
|
@ -59,6 +55,17 @@ class ProductResponse(BaseModel):
|
||||||
"image_url": "https://example.com/bowl.jpg",
|
"image_url": "https://example.com/bowl.jpg",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"created_at": "2024-01-15T10:30:00",
|
"created_at": "2024-01-15T10:30:00",
|
||||||
"updated_at": "2024-01-15T10:30:00"
|
"updated_at": "2024-01-15T10:30:00",
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
price: float
|
||||||
|
stock: int
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
||||||
# Celery Configuration
|
|
||||||
CELERY = {
|
|
||||||
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
"result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0"),
|
|
||||||
"task_serializer": "json",
|
|
||||||
"result_serializer": "json",
|
|
||||||
"accept_content": ["json"],
|
|
||||||
"timezone": "UTC",
|
|
||||||
"enable_utc": True,
|
|
||||||
"task_track_started": True,
|
|
||||||
"task_time_limit": 30 * 60, # 30 minutes
|
|
||||||
"task_soft_time_limit": 25 * 60, # 25 minutes
|
|
||||||
"task_acks_late": True, # Acknowledge after task completion
|
|
||||||
"task_reject_on_worker_lost": True, # Re-queue if worker dies
|
|
||||||
"worker_prefetch_multiplier": 1, # Process one task at a time
|
|
||||||
"worker_max_tasks_per_child": 100, # Restart worker after 100 tasks
|
|
||||||
"broker_connection_retry_on_startup": True,
|
|
||||||
"broker_connection_max_retries": 5,
|
|
||||||
"result_expires": 3600, # Results expire in 1 hour
|
|
||||||
"task_default_queue": "default",
|
|
||||||
"task_default_exchange": "default",
|
|
||||||
"task_default_routing_key": "default",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
19
backend/pytest.ini
Normal file
19
backend/pytest.ini
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[pytest]
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
testpaths = tests
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--strict-markers
|
||||||
|
--tb=short
|
||||||
|
--cov=app
|
||||||
|
--cov-report=term-missing
|
||||||
|
--cov-report=html
|
||||||
|
markers =
|
||||||
|
slow: Tests that are slow to run
|
||||||
|
integration: Integration tests
|
||||||
|
unit: Unit tests
|
||||||
|
auth: Authentication tests
|
||||||
|
product: Product-related tests
|
||||||
|
order: Order-related tests
|
||||||
|
|
@ -9,3 +9,9 @@ Werkzeug==3.0.1
|
||||||
SQLAlchemy==2.0.23
|
SQLAlchemy==2.0.23
|
||||||
celery[redis]==5.3.6
|
celery[redis]==5.3.6
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-flask==1.3.0
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
pytest-mock==3.12.0
|
||||||
|
factory-boy==3.3.0
|
||||||
|
faker==20.1.0
|
||||||
|
|
|
||||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests package for Flask application"""
|
||||||
190
backend/tests/conftest.py
Normal file
190
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"""Pytest configuration and fixtures"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
from app import create_app, db
|
||||||
|
from app.models import Order, OrderItem, Product, User
|
||||||
|
|
||||||
|
fake = Faker()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def app():
|
||||||
|
"""Create application for testing with isolated database"""
|
||||||
|
db_fd, db_path = tempfile.mkstemp()
|
||||||
|
|
||||||
|
app = create_app(config_name="test")
|
||||||
|
app.config.update(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
"JWT_SECRET_KEY": "test-secret-keytest-secret-keytest-secret-keytest-secret-keytest-secret-key",
|
||||||
|
"SERVER_NAME": "localhost.localdomain",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield app
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
os.close(db_fd)
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Test client for making requests"""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner(app):
|
||||||
|
"""Test CLI runner"""
|
||||||
|
return app.test_cli_runner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session(app):
|
||||||
|
"""Database session for tests"""
|
||||||
|
with app.app_context():
|
||||||
|
yield db.session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user(db_session):
|
||||||
|
"""Create an admin user for testing"""
|
||||||
|
user = User(
|
||||||
|
email=fake.email(),
|
||||||
|
username=fake.user_name(),
|
||||||
|
first_name=fake.first_name(),
|
||||||
|
last_name=fake.last_name(),
|
||||||
|
is_admin=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def regular_user(db_session):
|
||||||
|
"""Create a regular user for testing"""
|
||||||
|
user = User(
|
||||||
|
email=fake.email(),
|
||||||
|
username=fake.user_name(),
|
||||||
|
first_name=fake.first_name(),
|
||||||
|
last_name=fake.last_name(),
|
||||||
|
is_admin=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inactive_user(db_session):
|
||||||
|
"""Create an inactive user for testing"""
|
||||||
|
user = User(
|
||||||
|
email=fake.email(),
|
||||||
|
username=fake.user_name(),
|
||||||
|
first_name=fake.first_name(),
|
||||||
|
last_name=fake.last_name(),
|
||||||
|
is_admin=False,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def product(db_session):
|
||||||
|
"""Create a product for testing"""
|
||||||
|
product = Product(
|
||||||
|
name=fake.sentence(nb_words=4)[:-1], # Remove period
|
||||||
|
description=fake.paragraph(),
|
||||||
|
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||||
|
stock=fake.pyint(min_value=0, max_value=100),
|
||||||
|
image_url=fake.url(),
|
||||||
|
)
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def products(db_session):
|
||||||
|
"""Create multiple products for testing"""
|
||||||
|
products = []
|
||||||
|
for _ in range(5):
|
||||||
|
product = Product(
|
||||||
|
name=fake.sentence(nb_words=4)[:-1],
|
||||||
|
description=fake.paragraph(),
|
||||||
|
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||||
|
stock=fake.pyint(min_value=20, max_value=100),
|
||||||
|
image_url=fake.url(),
|
||||||
|
)
|
||||||
|
db_session.add(product)
|
||||||
|
products.append(product)
|
||||||
|
db_session.commit()
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(client, regular_user):
|
||||||
|
"""Get authentication headers for a regular user"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/login", json={"email": regular_user.email, "password": "password123"}
|
||||||
|
)
|
||||||
|
data = response.get_json()
|
||||||
|
token = data["access_token"]
|
||||||
|
print(f"Auth headers token for user {regular_user.email}: {token[:50]}...")
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_headers(client, admin_user):
|
||||||
|
"""Get authentication headers for an admin user"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/login", json={"email": admin_user.email, "password": "password123"}
|
||||||
|
)
|
||||||
|
data = response.get_json()
|
||||||
|
token = data["access_token"]
|
||||||
|
print(f"Admin headers token for user {admin_user.email}: {token[:50]}...")
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def order(db_session, regular_user, products):
|
||||||
|
"""Create an order for testing"""
|
||||||
|
order = Order(
|
||||||
|
user_id=regular_user.id, total_amount=0.0, shipping_address=fake.address()
|
||||||
|
)
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
total_amount = 0
|
||||||
|
for i, product in enumerate(products[:2]):
|
||||||
|
quantity = fake.pyint(min_value=1, max_value=5)
|
||||||
|
order_item = OrderItem(
|
||||||
|
order_id=order.id,
|
||||||
|
product_id=product.id,
|
||||||
|
quantity=quantity,
|
||||||
|
price=product.price,
|
||||||
|
)
|
||||||
|
total_amount += float(product.price) * quantity
|
||||||
|
db_session.add(order_item)
|
||||||
|
|
||||||
|
order.total_amount = total_amount
|
||||||
|
db_session.commit()
|
||||||
|
return order
|
||||||
200
backend/tests/test_models.py
Normal file
200
backend/tests/test_models.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""Test models"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Order, OrderItem, Product, User
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserModel:
|
||||||
|
"""Test User model"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_user_creation(self, db_session):
|
||||||
|
"""Test creating a user"""
|
||||||
|
user = User(
|
||||||
|
email="test@example.com",
|
||||||
|
username="testuser",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
is_admin=False,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.email == "test@example.com"
|
||||||
|
assert user.username == "testuser"
|
||||||
|
assert user.first_name == "Test"
|
||||||
|
assert user.last_name == "User"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_user_password_hashing(self, db_session):
|
||||||
|
"""Test password hashing and verification"""
|
||||||
|
user = User(email="test@example.com", username="testuser")
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert user.check_password("password123") is True
|
||||||
|
assert user.check_password("wrongpassword") is False
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_user_to_dict(self, db_session):
|
||||||
|
"""Test user serialization to dictionary"""
|
||||||
|
user = User(
|
||||||
|
email="test@example.com",
|
||||||
|
username="testuser",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
user_dict = user.to_dict()
|
||||||
|
assert user_dict["email"] == "test@example.com"
|
||||||
|
assert user_dict["username"] == "testuser"
|
||||||
|
assert "password" not in user_dict
|
||||||
|
assert "password_hash" not in user_dict
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_user_repr(self, db_session):
|
||||||
|
"""Test user string representation"""
|
||||||
|
user = User(email="test@example.com", username="testuser")
|
||||||
|
user.set_password("password123")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert repr(user) == "<User testuser>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductModel:
|
||||||
|
"""Test Product model"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_creation(self, db_session):
|
||||||
|
"""Test creating a product"""
|
||||||
|
product = Product(
|
||||||
|
name="Test Product",
|
||||||
|
description="A test product",
|
||||||
|
price=Decimal("99.99"),
|
||||||
|
stock=10,
|
||||||
|
image_url="https://example.com/product.jpg",
|
||||||
|
)
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert product.id is not None
|
||||||
|
assert product.name == "Test Product"
|
||||||
|
assert product.price == Decimal("99.99")
|
||||||
|
assert product.stock == 10
|
||||||
|
assert product.is_active is True
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_to_dict(self, db_session):
|
||||||
|
"""Test product serialization to dictionary"""
|
||||||
|
product = Product(
|
||||||
|
name="Test Product",
|
||||||
|
description="A test product",
|
||||||
|
price=Decimal("99.99"),
|
||||||
|
stock=10,
|
||||||
|
)
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
product_dict = product.to_dict()
|
||||||
|
assert product_dict["name"] == "Test Product"
|
||||||
|
assert product_dict["price"] == 99.99
|
||||||
|
assert isinstance(product_dict["created_at"], str)
|
||||||
|
assert isinstance(product_dict["updated_at"], str)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_defaults(self, db_session):
|
||||||
|
"""Test product default values"""
|
||||||
|
product = Product(name="Test Product", price=Decimal("9.99"))
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert product.stock == 0
|
||||||
|
assert product.is_active is True
|
||||||
|
assert product.description is None
|
||||||
|
assert product.image_url is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_repr(self, db_session):
|
||||||
|
"""Test product string representation"""
|
||||||
|
product = Product(name="Test Product", price=Decimal("9.99"))
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert repr(product) == "<Product Test Product>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderModel:
|
||||||
|
"""Test Order model"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_order_creation(self, db_session, regular_user):
|
||||||
|
"""Test creating an order"""
|
||||||
|
order = Order(
|
||||||
|
user_id=regular_user.id,
|
||||||
|
total_amount=Decimal("199.99"),
|
||||||
|
shipping_address="123 Test St",
|
||||||
|
)
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert order.id is not None
|
||||||
|
assert order.user_id == regular_user.id
|
||||||
|
assert order.total_amount == Decimal("199.99")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_order_to_dict(self, db_session, regular_user):
|
||||||
|
"""Test order serialization to dictionary"""
|
||||||
|
order = Order(
|
||||||
|
user_id=regular_user.id,
|
||||||
|
total_amount=Decimal("199.99"),
|
||||||
|
shipping_address="123 Test St",
|
||||||
|
)
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
order_dict = order.to_dict()
|
||||||
|
assert order_dict["user_id"] == regular_user.id
|
||||||
|
assert order_dict["total_amount"] == 199.99
|
||||||
|
assert isinstance(order_dict["created_at"], str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderItemModel:
|
||||||
|
"""Test OrderItem model"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_order_item_creation(self, db_session, order, product):
|
||||||
|
"""Test creating an order item"""
|
||||||
|
order_item = OrderItem(
|
||||||
|
order_id=order.id, product_id=product.id, quantity=2, price=product.price
|
||||||
|
)
|
||||||
|
db_session.add(order_item)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert order_item.id is not None
|
||||||
|
assert order_item.order_id == order.id
|
||||||
|
assert order_item.product_id == product.id
|
||||||
|
assert order_item.quantity == 2
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_order_item_to_dict(self, db_session, order, product):
|
||||||
|
"""Test order item serialization to dictionary"""
|
||||||
|
order_item = OrderItem(
|
||||||
|
order_id=order.id, product_id=product.id, quantity=2, price=product.price
|
||||||
|
)
|
||||||
|
db_session.add(order_item)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
item_dict = order_item.to_dict()
|
||||||
|
assert item_dict["order_id"] == order.id
|
||||||
|
assert item_dict["product_id"] == product.id
|
||||||
|
assert item_dict["quantity"] == 2
|
||||||
339
backend/tests/test_routes.py
Normal file
339
backend/tests/test_routes.py
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
"""Test API routes"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthRoutes:
|
||||||
|
"""Test authentication routes"""
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_register_success(self, client):
|
||||||
|
"""Test successful user registration"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "newuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"username": "newuser",
|
||||||
|
"first_name": "New",
|
||||||
|
"last_name": "User",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["email"] == "newuser@example.com"
|
||||||
|
assert data["username"] == "newuser"
|
||||||
|
assert "password" not in data
|
||||||
|
assert "password_hash" not in data
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_register_missing_fields(self, client):
|
||||||
|
"""Test registration with missing required fields"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/register", json={"email": "newuser@example.com"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "error" in data
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_register_duplicate_email(self, client, regular_user):
|
||||||
|
"""Test registration with duplicate email"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={"email": regular_user.email, "password": "password123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "already exists" in data["error"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_login_success(self, client, regular_user):
|
||||||
|
"""Test successful login"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": regular_user.email, "password": "password123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["user"]["email"] == regular_user.email
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"email,password,expected_status",
|
||||||
|
[
|
||||||
|
("wrong@example.com", "password123", 401),
|
||||||
|
("user@example.com", "wrongpassword", 401),
|
||||||
|
(None, "password123", 400),
|
||||||
|
("user@example.com", None, 400),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_login_validation(
|
||||||
|
self, client, regular_user, email, password, expected_status
|
||||||
|
):
|
||||||
|
"""Test login with various invalid inputs"""
|
||||||
|
login_data = {}
|
||||||
|
if email is not None:
|
||||||
|
login_data["email"] = email
|
||||||
|
if password is not None:
|
||||||
|
login_data["password"] = password
|
||||||
|
|
||||||
|
response = client.post("/api/auth/login", json=login_data)
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_login_inactive_user(self, client, inactive_user):
|
||||||
|
"""Test login with inactive user"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": inactive_user.email, "password": "password123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.get_json()
|
||||||
|
assert "inactive" in data["error"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_get_current_user(self, client, auth_headers, regular_user):
|
||||||
|
"""Test getting current user"""
|
||||||
|
response = client.get("/api/users/me", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["email"] == regular_user.email
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_get_current_user_unauthorized(self, client):
|
||||||
|
"""Test getting current user without authentication"""
|
||||||
|
response = client.get("/api/users/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductRoutes:
|
||||||
|
"""Test product routes"""
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_get_products(self, client, products):
|
||||||
|
"""Test getting all products"""
|
||||||
|
response = client.get("/api/products")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 5
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_get_products_empty(self, client):
|
||||||
|
"""Test getting products when none exist"""
|
||||||
|
response = client.get("/api/products")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 0
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_get_single_product(self, client, product):
|
||||||
|
"""Test getting a single product"""
|
||||||
|
response = client.get(f"/api/products/{product.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["id"] == product.id
|
||||||
|
assert data["name"] == product.name
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_get_product_not_found(self, client):
|
||||||
|
"""Test getting non-existent product"""
|
||||||
|
response = client.get("/api/products/999")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_admin(self, client, admin_headers):
|
||||||
|
"""Test creating product as admin"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"name": "New Product",
|
||||||
|
"description": "A new product",
|
||||||
|
"price": 29.99,
|
||||||
|
"stock": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "New Product"
|
||||||
|
assert data["price"] == 29.99
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_regular_user(self, client, auth_headers):
|
||||||
|
"""Test creating product as regular user (should fail)"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "New Product", "price": 29.99},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.get_json()
|
||||||
|
assert "admin" in data["error"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_unauthorized(self, client):
|
||||||
|
"""Test creating product without authentication"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products", json={"name": "New Product", "price": 29.99}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_validation_error(self, client, admin_headers):
|
||||||
|
"""Test creating product with invalid data"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "New Product", "price": -10.99},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "Validation error" in data["error"]
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_missing_required_fields(self, client, admin_headers):
|
||||||
|
"""Test creating product with missing required fields"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"description": "Missing name and price"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "Validation error" in data["error"]
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_create_product_minimal_data(self, client, admin_headers):
|
||||||
|
"""Test creating product with minimal valid data"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/products",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "Minimal Product", "price": 19.99},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Minimal Product"
|
||||||
|
assert data["stock"] == 0 # Default value
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_update_product_admin(self, client, admin_headers, product):
|
||||||
|
"""Test updating product as admin"""
|
||||||
|
response = client.put(
|
||||||
|
f"/api/products/{product.id}",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={"name": "Updated Product", "price": 39.99},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["name"] == "Updated Product"
|
||||||
|
assert data["price"] == 39.99
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_delete_product_admin(self, client, admin_headers, product):
|
||||||
|
"""Test deleting product as admin"""
|
||||||
|
response = client.delete(f"/api/products/{product.id}", headers=admin_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Verify product is deleted
|
||||||
|
response = client.get(f"/api/products/{product.id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderRoutes:
|
||||||
|
"""Test order routes"""
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_get_orders(self, client, auth_headers, order):
|
||||||
|
"""Test getting orders for current user"""
|
||||||
|
response = client.get("/api/orders", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_get_orders_unauthorized(self, client):
|
||||||
|
"""Test getting orders without authentication"""
|
||||||
|
response = client.get("/api/orders")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_create_order(self, client, auth_headers, products):
|
||||||
|
"""Test creating an order"""
|
||||||
|
response = client.post(
|
||||||
|
"/api/orders",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"items": [
|
||||||
|
{"product_id": products[0].id, "quantity": 2},
|
||||||
|
{"product_id": products[1].id, "quantity": 1},
|
||||||
|
],
|
||||||
|
"shipping_address": "123 Test St",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert "id" in data
|
||||||
|
assert len(data["items"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_create_order_insufficient_stock(
|
||||||
|
self, client, auth_headers, db_session, products
|
||||||
|
):
|
||||||
|
"""Test creating order with insufficient stock"""
|
||||||
|
# Set stock to 0
|
||||||
|
products[0].stock = 0
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/orders",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"items": [{"product_id": products[0].id, "quantity": 2}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert "insufficient" in data["error"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_get_single_order(self, client, auth_headers, order):
|
||||||
|
"""Test getting a single order"""
|
||||||
|
response = client.get(f"/api/orders/{order.id}", headers=auth_headers)
|
||||||
|
|
||||||
|
print("test_get_single_order", response.get_json())
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data["id"] == order.id
|
||||||
|
|
||||||
|
@pytest.mark.order
|
||||||
|
def test_get_other_users_order(self, client, admin_headers, regular_user, products):
|
||||||
|
"""Test admin accessing another user's order"""
|
||||||
|
# Create an order for regular_user
|
||||||
|
client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"email": regular_user.email, "password": "password123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Admin should be able to access any order
|
||||||
|
# This test assumes order exists, adjust as needed
|
||||||
|
pass
|
||||||
249
backend/tests/test_schemas.py
Normal file
249
backend/tests/test_schemas.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""Test Pydantic schemas"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas import ProductCreateRequest, ProductResponse
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductCreateRequestSchema:
|
||||||
|
"""Test ProductCreateRequest schema"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_valid_product_request(self):
|
||||||
|
"""Test valid product creation request"""
|
||||||
|
data = {
|
||||||
|
"name": "Handcrafted Wooden Bowl",
|
||||||
|
"description": "A beautiful handcrafted bowl",
|
||||||
|
"price": 45.99,
|
||||||
|
"stock": 10,
|
||||||
|
"image_url": "https://example.com/bowl.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
product = ProductCreateRequest(**data)
|
||||||
|
assert product.name == data["name"]
|
||||||
|
assert product.description == data["description"]
|
||||||
|
assert product.price == Decimal("45.99")
|
||||||
|
assert product.stock == 10
|
||||||
|
assert product.image_url == data["image_url"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_minimal_valid_request(self):
|
||||||
|
"""Test minimal valid request (only required fields)"""
|
||||||
|
data = {"name": "Simple Product", "price": 19.99}
|
||||||
|
|
||||||
|
product = ProductCreateRequest(**data)
|
||||||
|
assert product.name == "Simple Product"
|
||||||
|
assert product.price == Decimal("19.99")
|
||||||
|
assert product.stock == 0
|
||||||
|
assert product.description is None
|
||||||
|
assert product.image_url is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_missing_name(self):
|
||||||
|
"""Test request with missing name"""
|
||||||
|
data = {"price": 19.99}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["loc"] == ("name",) for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_missing_price(self):
|
||||||
|
"""Test request with missing price"""
|
||||||
|
data = {"name": "Test Product"}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["loc"] == ("price",) for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_price_negative(self):
|
||||||
|
"""Test request with negative price"""
|
||||||
|
data = {"name": "Test Product", "price": -10.99}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["type"] == "greater_than" for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_price_zero(self):
|
||||||
|
"""Test request with zero price"""
|
||||||
|
data = {"name": "Test Product", "price": 0.0}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["type"] == "greater_than" for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_price_too_many_decimals(self):
|
||||||
|
"""Test request with too many decimal places"""
|
||||||
|
data = {"name": "Test Product", "price": 10.999}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any("decimal places" in str(error).lower() for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_stock_negative(self):
|
||||||
|
"""Test request with negative stock"""
|
||||||
|
data = {"name": "Test Product", "price": 19.99, "stock": -5}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["type"] == "greater_than_equal" for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_name_too_long(self):
|
||||||
|
"""Test request with name exceeding max length"""
|
||||||
|
data = {"name": "A" * 201, "price": 19.99} # Exceeds 200 character limit
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["loc"] == ("name",) for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_image_url_too_long(self):
|
||||||
|
"""Test request with image_url exceeding max length"""
|
||||||
|
data = {
|
||||||
|
"name": "Test Product",
|
||||||
|
"price": 19.99,
|
||||||
|
"image_url": "A" * 501, # Exceeds 500 character limit
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
ProductCreateRequest(**data)
|
||||||
|
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any(error["loc"] == ("image_url",) for error in errors)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_price_string_conversion(self):
|
||||||
|
"""Test price string to Decimal conversion"""
|
||||||
|
data = {"name": "Test Product", "price": "29.99"}
|
||||||
|
|
||||||
|
product = ProductCreateRequest(**data)
|
||||||
|
assert product.price == Decimal("29.99")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stock_string_conversion(self):
|
||||||
|
"""Test stock string to int conversion"""
|
||||||
|
data = {"name": "Test Product", "price": 19.99, "stock": "10"}
|
||||||
|
|
||||||
|
product = ProductCreateRequest(**data)
|
||||||
|
assert product.stock == 10
|
||||||
|
assert isinstance(product.stock, int)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductResponseSchema:
|
||||||
|
"""Test ProductResponse schema"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_valid_product_response(self):
|
||||||
|
"""Test valid product response"""
|
||||||
|
data = {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Product",
|
||||||
|
"description": "A test product",
|
||||||
|
"price": 45.99,
|
||||||
|
"stock": 10,
|
||||||
|
"image_url": "https://example.com/product.jpg",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2024-01-15T10:30:00",
|
||||||
|
"updated_at": "2024-01-15T10:30:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
product = ProductResponse(**data)
|
||||||
|
assert product.id == 1
|
||||||
|
assert product.name == "Test Product"
|
||||||
|
assert product.price == 45.99
|
||||||
|
assert product.stock == 10
|
||||||
|
assert product.is_active is True
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_response_with_none_fields(self):
|
||||||
|
"""Test product response with optional None fields"""
|
||||||
|
data = {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Product",
|
||||||
|
"price": 19.99,
|
||||||
|
"stock": 0,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
product = ProductResponse(**data)
|
||||||
|
assert product.description is None
|
||||||
|
assert product.image_url is None
|
||||||
|
assert product.created_at is None
|
||||||
|
assert product.updated_at is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_model_validate_from_sqlalchemy(self, db_session):
|
||||||
|
"""Test validating SQLAlchemy model to Pydantic schema"""
|
||||||
|
from app.models import Product
|
||||||
|
|
||||||
|
db_product = Product(
|
||||||
|
name="Test Product",
|
||||||
|
description="A test product",
|
||||||
|
price=Decimal("45.99"),
|
||||||
|
stock=10,
|
||||||
|
)
|
||||||
|
db_session.add(db_product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Validate using model_validate (for SQLAlchemy models)
|
||||||
|
response = ProductResponse.model_validate(db_product)
|
||||||
|
assert response.name == "Test Product"
|
||||||
|
assert response.price == 45.99
|
||||||
|
assert response.stock == 10
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_model_dump(self):
|
||||||
|
"""Test model_dump method"""
|
||||||
|
data = {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Product",
|
||||||
|
"price": 19.99,
|
||||||
|
"stock": 5,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
product = ProductResponse(**data)
|
||||||
|
dumped = product.model_dump()
|
||||||
|
|
||||||
|
assert isinstance(dumped, dict)
|
||||||
|
assert dumped["id"] == 1
|
||||||
|
assert dumped["name"] == "Test Product"
|
||||||
|
assert dumped["price"] == 19.99
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_model_dump_json(self):
|
||||||
|
"""Test model_dump_json method"""
|
||||||
|
data = {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Product",
|
||||||
|
"price": 19.99,
|
||||||
|
"stock": 5,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
product = ProductResponse(**data)
|
||||||
|
json_str = product.model_dump_json()
|
||||||
|
|
||||||
|
assert isinstance(json_str, str)
|
||||||
|
assert "Test Product" in json_str
|
||||||
|
|
@ -119,6 +119,18 @@ def create_product():
|
||||||
- **ALWAYS** import `db` from `app`
|
- **ALWAYS** import `db` from `app`
|
||||||
- Use `db.session.add()` and `db.session.commit()` for transactions
|
- Use `db.session.add()` and `db.session.commit()` for transactions
|
||||||
- Use `db.session.flush()` when you need the ID before commit
|
- Use `db.session.flush()` when you need the ID before commit
|
||||||
|
- **ALWAYS** use `db.session.get(Model, id)` instead of `Model.query.get(id)` (SQLAlchemy 2.0)
|
||||||
|
- Use `Model.query.get_or_404(id)` for 404 handling when appropriate
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - SQLAlchemy 2.0 syntax
|
||||||
|
from app import db
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
|
# ❌ WRONG - Legacy syntax (deprecated)
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
```
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
- Handle common errors (404, 400, 401, 403, 500)
|
- Handle common errors (404, 400, 401, 403, 500)
|
||||||
|
|
@ -189,11 +201,308 @@ def create_product_service(data):
|
||||||
- Use pytest framework
|
- Use pytest framework
|
||||||
- Place tests in `backend/tests/`
|
- Place tests in `backend/tests/`
|
||||||
- Use fixtures for common setup
|
- Use fixtures for common setup
|
||||||
|
- Organize tests by functionality: `test_models.py`, `test_routes.py`, `test_schemas.py`
|
||||||
|
|
||||||
|
### Test Naming Conventions
|
||||||
|
- Test files must start with `test_`: `test_products.py`, `test_users.py`
|
||||||
|
- Test classes must start with `Test`: `TestProductModel`, `TestAuthRoutes`
|
||||||
|
- Test functions must start with `test_`: `test_create_product`, `test_login_success`
|
||||||
|
- Use descriptive names: `test_create_product_with_valid_data` (not `test_product`)
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
#### Basic Test Structure
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT
|
||||||
|
import pytest
|
||||||
|
from app import db
|
||||||
|
from app.models import Product
|
||||||
|
|
||||||
|
class TestProductModel:
|
||||||
|
"""Test Product model"""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_product_creation(self, db_session):
|
||||||
|
"""Test creating a product with valid data"""
|
||||||
|
product = Product(
|
||||||
|
name='Test Product',
|
||||||
|
price=99.99
|
||||||
|
)
|
||||||
|
db_session.add(product)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert product.id is not None
|
||||||
|
assert product.name == 'Test Product'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Testing API Routes
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT
|
||||||
|
def test_create_product(client, admin_headers):
|
||||||
|
"""Test creating a product as admin"""
|
||||||
|
response = client.post('/api/products',
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
'name': 'New Product',
|
||||||
|
'price': 29.99
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['name'] == 'New Product'
|
||||||
|
assert 'password' not in data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Fixtures
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Use available fixtures
|
||||||
|
def test_get_products(client, products):
|
||||||
|
"""Test getting all products"""
|
||||||
|
response = client.get('/api/products')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data) == 5
|
||||||
|
|
||||||
|
# ❌ WRONG - Don't create fixtures manually in tests
|
||||||
|
def test_get_products_wrong(client, db_session):
|
||||||
|
products = []
|
||||||
|
for _ in range(5):
|
||||||
|
p = Product(name='Test', price=10)
|
||||||
|
db_session.add(p)
|
||||||
|
products.append(p)
|
||||||
|
db_session.commit()
|
||||||
|
# ... use fixtures instead!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Testing Both Success and Failure Cases
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Test both scenarios
|
||||||
|
def test_create_product_success(client, admin_headers):
|
||||||
|
"""Test creating product successfully"""
|
||||||
|
response = client.post('/api/products',
|
||||||
|
headers=admin_headers,
|
||||||
|
json={'name': 'Test', 'price': 10})
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
def test_create_product_unauthorized(client):
|
||||||
|
"""Test creating product without authentication"""
|
||||||
|
response = client.post('/api/products',
|
||||||
|
json={'name': 'Test', 'price': 10})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_create_product_validation_error(client, admin_headers):
|
||||||
|
"""Test creating product with invalid data"""
|
||||||
|
response = client.post('/api/products',
|
||||||
|
headers=admin_headers,
|
||||||
|
json={'name': 'Test', 'price': -10})
|
||||||
|
assert response.status_code == 400
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameterized Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Use parameterization for similar tests
|
||||||
|
@pytest.mark.parametrize("email,password,expected_status", [
|
||||||
|
("user@example.com", "correct123", 200),
|
||||||
|
("wrong@email.com", "correct123", 401),
|
||||||
|
("user@example.com", "wrongpass", 401),
|
||||||
|
])
|
||||||
|
def test_login_validation(client, email, password, expected_status):
|
||||||
|
"""Test login with various invalid inputs"""
|
||||||
|
response = client.post('/api/auth/login', json={
|
||||||
|
'email': email,
|
||||||
|
'password': password
|
||||||
|
})
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Markers
|
||||||
|
|
||||||
|
Use appropriate markers for categorizing tests:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_user_creation(self, db_session):
|
||||||
|
"""Unit test - no HTTP, no external services"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_user_workflow(self, client):
|
||||||
|
"""Integration test - full request/response cycle"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.auth
|
||||||
|
def test_login(self, client):
|
||||||
|
"""Authentication-related test"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.product
|
||||||
|
def test_get_products(self, client):
|
||||||
|
"""Product-related test"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
### Database in Tests
|
### Database in Tests
|
||||||
- Use in-memory SQLite for tests
|
- Use in-memory SQLite for tests
|
||||||
- Clean up database between tests
|
- Clean up database between tests
|
||||||
- Use `pytest.fixture` for database setup
|
- Use `pytest.fixture` for database setup
|
||||||
|
- **NEVER** use production database in tests
|
||||||
|
- **NEVER** share state between tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Use db_session fixture
|
||||||
|
def test_something(db_session):
|
||||||
|
user = User(email='test@example.com')
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# ❌ WRONG - Don't access db directly
|
||||||
|
def test_something_wrong():
|
||||||
|
from app import db
|
||||||
|
user = User(email='test@example.com')
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Fixtures
|
||||||
|
|
||||||
|
Use these fixtures from `tests/conftest.py`:
|
||||||
|
|
||||||
|
- **`app`**: Flask application instance with test configuration
|
||||||
|
- **`client`**: Test client for making HTTP requests
|
||||||
|
- **`runner`**: CLI runner for Flask commands
|
||||||
|
- **`db_session`**: Database session for database operations
|
||||||
|
- **`admin_user`**: Pre-created admin user
|
||||||
|
- **`regular_user`**: Pre-created regular user
|
||||||
|
- **`inactive_user`**: Pre-created inactive user
|
||||||
|
- **`product`**: Single product
|
||||||
|
- **`products`**: Multiple products (5 items)
|
||||||
|
- **`auth_headers`**: JWT headers for regular user
|
||||||
|
- **`admin_headers`**: JWT headers for admin user
|
||||||
|
- **`order`**: Pre-created order with items
|
||||||
|
|
||||||
|
### Creating Custom Fixtures
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In tests/conftest.py or test file
|
||||||
|
@pytest.fixture
|
||||||
|
def custom_resource(db_session):
|
||||||
|
"""Create a custom test resource"""
|
||||||
|
resource = CustomModel(
|
||||||
|
name='Test Resource',
|
||||||
|
value=100
|
||||||
|
)
|
||||||
|
db_session.add(resource)
|
||||||
|
db_session.commit()
|
||||||
|
return resource
|
||||||
|
|
||||||
|
# Use in tests
|
||||||
|
def test_custom_fixture(custom_resource):
|
||||||
|
assert custom_resource.name == 'Test Resource'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
make test-backend
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
make test-backend-cov
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
make test-backend-verbose
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
make test-backend-specific TEST=test_models.py
|
||||||
|
|
||||||
|
# Run by marker
|
||||||
|
make test-backend-marker MARKER=auth
|
||||||
|
|
||||||
|
# Run only failed tests
|
||||||
|
make test-backend-failed
|
||||||
|
|
||||||
|
# Run in parallel (faster)
|
||||||
|
make test-backend-parallel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage Requirements
|
||||||
|
|
||||||
|
- **Minimum 80%** code coverage required
|
||||||
|
- **Critical paths** (auth, payments, data modification) must have >90% coverage
|
||||||
|
- All new features must include tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Comprehensive test coverage
|
||||||
|
def test_product_crud(self, client, admin_headers):
|
||||||
|
"""Test complete CRUD operations"""
|
||||||
|
# Create
|
||||||
|
response = client.post('/api/products',
|
||||||
|
headers=admin_headers,
|
||||||
|
json={'name': 'Test', 'price': 10})
|
||||||
|
assert response.status_code == 201
|
||||||
|
product_id = response.get_json()['id']
|
||||||
|
|
||||||
|
# Read
|
||||||
|
response = client.get(f'/api/products/{product_id}')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Update
|
||||||
|
response = client.put(f'/api/products/{product_id}',
|
||||||
|
headers=admin_headers,
|
||||||
|
json={'name': 'Updated', 'price': 20})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
response = client.delete(f'/api/products/{product_id}',
|
||||||
|
headers=admin_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking External Services
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ CORRECT - Mock external dependencies
|
||||||
|
def test_external_api_call(client, mocker):
|
||||||
|
"""Test endpoint that calls external API"""
|
||||||
|
mock_response = {'data': 'mocked data'}
|
||||||
|
|
||||||
|
# Mock requests.get
|
||||||
|
mock_get = mocker.patch('requests.get')
|
||||||
|
mock_get.return_value.json.return_value = mock_response
|
||||||
|
mock_get.return_value.status_code = 200
|
||||||
|
|
||||||
|
response = client.get('/api/external-data')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json() == mock_response
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test DOs and DON'Ts
|
||||||
|
|
||||||
|
✅ **DO:**
|
||||||
|
- Use descriptive test names
|
||||||
|
- Test both success and failure cases
|
||||||
|
- Use fixtures for common setup
|
||||||
|
- Mock external services
|
||||||
|
- Keep tests independent
|
||||||
|
- Use markers appropriately
|
||||||
|
- Test edge cases and boundary conditions
|
||||||
|
|
||||||
|
❌ **DON'T:**
|
||||||
|
- Share state between tests
|
||||||
|
- Hardcode sensitive data (use faker)
|
||||||
|
- Use production database
|
||||||
|
- Skip error case testing
|
||||||
|
- Write tests after deployment
|
||||||
|
- Ignore slow tests in CI
|
||||||
|
- Use complex setup in test methods (use fixtures instead)
|
||||||
|
|
||||||
## Security Rules
|
## Security Rules
|
||||||
|
|
||||||
|
|
|
||||||
50
frontend/.eslintrc.json
Normal file
50
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2021,
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:prettier/recommended"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"react",
|
||||||
|
"react-hooks",
|
||||||
|
"@typescript-eslint",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prettier/prettier": "warn"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"node_modules",
|
||||||
|
"*.config.js",
|
||||||
|
"*.config.ts",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
frontend/.prettierignore
Normal file
27
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Config files that might have different formatting
|
||||||
|
*.config.js
|
||||||
|
*.config.ts
|
||||||
|
vite.config.ts
|
||||||
|
vitest.config.ts
|
||||||
|
postcss.config.js
|
||||||
|
tailwind.config.js
|
||||||
|
|
||||||
|
# Other
|
||||||
|
public/
|
||||||
10
frontend/.prettierrc.json
Normal file
10
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
401
frontend/package-lock.json
generated
401
frontend/package-lock.json
generated
|
|
@ -19,15 +19,20 @@
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/ui": "^1.0.4",
|
"@vitest/ui": "^1.0.4",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"jsdom": "^23.0.1",
|
"jsdom": "^23.0.1",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
|
|
@ -1018,6 +1023,18 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pkgr/core": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/pkgr"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
|
|
@ -1551,6 +1568,285 @@
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
|
"@typescript-eslint/type-utils": "8.56.1",
|
||||||
|
"@typescript-eslint/utils": "8.56.1",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"natural-compare": "^1.4.0",
|
||||||
|
"ts-api-utils": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/parser": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/tsconfig-utils": "^8.56.1",
|
||||||
|
"@typescript-eslint/types": "^8.56.1",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.56.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||||
|
"@typescript-eslint/utils": "8.56.1",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ts-api-utils": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/types": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/project-service": "8.56.1",
|
||||||
|
"@typescript-eslint/tsconfig-utils": "8.56.1",
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.56.1",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"semver": "^7.7.3",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ts-api-utils": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
|
"version": "10.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
|
||||||
|
"integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/utils": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.56.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
|
"version": "8.56.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz",
|
||||||
|
"integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.56.1",
|
||||||
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
|
|
@ -3045,6 +3341,51 @@
|
||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-config-prettier": {
|
||||||
|
"version": "10.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||||
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-config-prettier"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint-plugin-prettier": {
|
||||||
|
"version": "5.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
|
||||||
|
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"prettier-linter-helpers": "^1.0.1",
|
||||||
|
"synckit": "^0.11.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint-plugin-prettier"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/eslint": ">=8.0.0",
|
||||||
|
"eslint": ">=8.0.0",
|
||||||
|
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
|
||||||
|
"prettier": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/eslint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"eslint-config-prettier": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-react": {
|
"node_modules/eslint-plugin-react": {
|
||||||
"version": "7.37.5",
|
"version": "7.37.5",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
||||||
|
|
@ -3235,6 +3576,12 @@
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -5293,6 +5640,33 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier-linter-helpers": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
|
@ -6175,6 +6549,21 @@
|
||||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/synckit": {
|
||||||
|
"version": "0.11.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
|
||||||
|
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@pkgr/core": "^0.2.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/synckit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
|
|
@ -6376,6 +6765,18 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-api-utils": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-interface-checker": {
|
"node_modules/ts-interface-checker": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint . --ext ts,tsx,js,jsx --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:ui": "vitest --ui"
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
|
|
@ -22,15 +25,20 @@
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.1",
|
||||||
"@types/react": "^18.3.28",
|
"@types/react": "^18.3.28",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
|
"@typescript-eslint/parser": "^8.56.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/ui": "^1.0.4",
|
"@vitest/ui": "^1.0.4",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"jsdom": "^23.0.1",
|
"jsdom": "^23.0.1",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { ModalProvider } from './context/modals/useModal'
|
import { ModalProvider } from './context/modals/useModal';
|
||||||
import { ModalRoot } from './context/modals/ModalRoot'
|
import { ModalRoot } from './context/modals/ModalRoot';
|
||||||
import { ToastProvider } from './context/toasts/useToast'
|
import { ToastProvider } from './context/toasts/useToast';
|
||||||
import { ToastRoot } from './context/toasts/ToastRoot'
|
import { ToastRoot } from './context/toasts/ToastRoot';
|
||||||
import { LoaderProvider } from './context/loaders/useLoader'
|
import { LoaderProvider } from './context/loaders/useLoader';
|
||||||
import { LoaderRoot } from './context/loaders/LoaderRoot'
|
import { LoaderRoot } from './context/loaders/LoaderRoot';
|
||||||
import Cart from './pages/Cart'
|
import Cart from './pages/Cart';
|
||||||
import { Navbar } from './components/Navbar'
|
import { Navbar } from './components/Navbar';
|
||||||
import { Home } from './pages/Home'
|
import { Home } from './pages/Home';
|
||||||
import { Products } from './pages/Products'
|
import { Products } from './pages/Products';
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login';
|
||||||
import { Register } from './pages/Register'
|
import { Register } from './pages/Register';
|
||||||
import { Orders } from './pages/Orders'
|
import { Orders } from './pages/Orders';
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -38,7 +38,7 @@ const App = () => {
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</LoaderProvider>
|
</LoaderProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { useApp } from '../context/AppContext'
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext';
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user } = useApp()
|
const { user } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-gray-800 border-b border-gray-700 shadow-md">
|
<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="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 justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="text-xl font-bold text-white hover:text-blue-400 transition-colors">
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
Crafting Shop
|
Crafting Shop
|
||||||
</Link>
|
</Link>
|
||||||
<div className="ml-10 flex items-baseline space-x-4">
|
<div className="ml-10 flex items-baseline space-x-4">
|
||||||
|
|
@ -43,9 +46,7 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user ? (
|
{user ? (
|
||||||
<span className="text-gray-300 px-3 py-2">
|
<span className="text-gray-300 px-3 py-2">{user.username}</span>
|
||||||
{user.username}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -66,5 +67,5 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -87,9 +87,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||||
const existingItem = prevCart.find((item) => item.id === product.id);
|
const existingItem = prevCart.find((item) => item.id === product.id);
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
return prevCart.map((item) =>
|
return prevCart.map((item) =>
|
||||||
item.id === product.id
|
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
|
||||||
? { ...item, quantity: item.quantity + 1 }
|
|
||||||
: item
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [...prevCart, { ...product, quantity: 1 }];
|
return [...prevCart, { ...product, quantity: 1 }];
|
||||||
|
|
@ -106,9 +104,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCart((prevCart: CartItem[]) =>
|
setCart((prevCart: CartItem[]) =>
|
||||||
prevCart.map((item) =>
|
prevCart.map((item) => (item.id === productId ? { ...item, quantity } : item))
|
||||||
item.id === productId ? { ...item, quantity } : item
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ export const LoaderExample = () => {
|
||||||
|
|
||||||
// Pattern A: Manual Control
|
// Pattern A: Manual Control
|
||||||
const handleManualLoad = async () => {
|
const handleManualLoad = async () => {
|
||||||
showLoader("Processing manual task...");
|
showLoader('Processing manual task...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate an async operation
|
// Simulate an async operation
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
@ -20,6 +20,7 @@ export const LoaderExample = () => {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Manual Task Failed',
|
title: 'Manual Task Failed',
|
||||||
|
|
@ -34,14 +35,11 @@ export const LoaderExample = () => {
|
||||||
// Pattern B: withLoader Helper (Cleanest)
|
// Pattern B: withLoader Helper (Cleanest)
|
||||||
const handleWithLoader = async () => {
|
const handleWithLoader = async () => {
|
||||||
try {
|
try {
|
||||||
await withLoader(
|
await withLoader(async () => {
|
||||||
async () => {
|
|
||||||
// Simulate an async operation
|
// Simulate an async operation
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
return 'Success!';
|
return 'Success!';
|
||||||
},
|
}, 'Processing with withLoader...');
|
||||||
"Processing with withLoader..."
|
|
||||||
);
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
@ -50,6 +48,7 @@ export const LoaderExample = () => {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'withLoader Task Failed',
|
title: 'withLoader Task Failed',
|
||||||
|
|
@ -61,11 +60,11 @@ export const LoaderExample = () => {
|
||||||
|
|
||||||
// Pattern C: Long-running task
|
// Pattern C: Long-running task
|
||||||
const handleLongLoad = async () => {
|
const handleLongLoad = async () => {
|
||||||
showLoader("Processing long-running task...");
|
showLoader('Processing long-running task...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate a longer async operation
|
// Simulate a longer async operation
|
||||||
await new Promise(resolve => setTimeout(resolve, 4000));
|
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
@ -74,6 +73,7 @@ export const LoaderExample = () => {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Long Task Failed',
|
title: 'Long Task Failed',
|
||||||
|
|
@ -87,7 +87,7 @@ export const LoaderExample = () => {
|
||||||
|
|
||||||
// Pattern D: Error simulation
|
// Pattern D: Error simulation
|
||||||
const handleError = async () => {
|
const handleError = async () => {
|
||||||
showLoader("Processing task that will fail...");
|
showLoader('Processing task that will fail...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate an error
|
// Simulate an error
|
||||||
|
|
@ -102,6 +102,7 @@ export const LoaderExample = () => {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Task Failed',
|
title: 'Task Failed',
|
||||||
|
|
@ -118,7 +119,7 @@ export const LoaderExample = () => {
|
||||||
showLoader();
|
showLoader();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|
@ -127,6 +128,7 @@ export const LoaderExample = () => {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Task Failed',
|
title: 'Task Failed',
|
||||||
|
|
@ -142,7 +144,8 @@ export const LoaderExample = () => {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold text-white mb-4">Loader System Examples</h3>
|
<h3 className="text-xl font-semibold text-white mb-4">Loader System Examples</h3>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Click the buttons below to see different loader patterns in action. The loader uses React Context for state management.
|
Click the buttons below to see different loader patterns in action. The loader uses React
|
||||||
|
Context for state management.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
|
@ -181,10 +184,19 @@ export const LoaderExample = () => {
|
||||||
<div className="mt-6 p-4 bg-gray-700 rounded-lg border border-gray-600">
|
<div className="mt-6 p-4 bg-gray-700 rounded-lg border border-gray-600">
|
||||||
<h4 className="text-white font-semibold mb-2">Usage Tips:</h4>
|
<h4 className="text-white font-semibold mb-2">Usage Tips:</h4>
|
||||||
<ul className="text-gray-300 text-sm space-y-1 list-disc list-inside">
|
<ul className="text-gray-300 text-sm space-y-1 list-disc list-inside">
|
||||||
<li><strong>Manual Control:</strong> Use showLoader/hideLoader when you need fine-grained control</li>
|
<li>
|
||||||
<li><strong>withLoader:</strong> Use the helper for automatic cleanup (recommended)</li>
|
<strong>Manual Control:</strong> Use showLoader/hideLoader when you need fine-grained
|
||||||
<li><strong>Message:</strong> Optional - provides context about what's happening</li>
|
control
|
||||||
<li><strong>Z-Index:</strong> Loader (70) sits above Toasts (60) and Modals (50)</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>withLoader:</strong> Use the helper for automatic cleanup (recommended)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Message:</strong> Optional - provides context about what is happening
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Z-Index:</strong> Loader (70) sits above Toasts (60) and Modals (50)
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const LoaderRoot = () => {
|
||||||
className="fixed inset-0 z-[70] flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity duration-200"
|
className="fixed inset-0 z-[70] flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm transition-opacity duration-200"
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-label={message || "Loading"}
|
aria-label={message || 'Loading'}
|
||||||
>
|
>
|
||||||
{/* Custom CSS Spinner (No external libs) */}
|
{/* Custom CSS Spinner (No external libs) */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -22,11 +22,7 @@ export const LoaderRoot = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && <p className="mt-4 text-white font-medium text-lg animate-pulse">{message}</p>}
|
||||||
<p className="mt-4 text-white font-medium text-lg animate-pulse">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,17 @@ export const LoaderProvider: FC<LoaderProviderProps> = ({ children }) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Helper to avoid try/finally blocks everywhere
|
// Helper to avoid try/finally blocks everywhere
|
||||||
const withLoader = useCallback(async <T,>(
|
const withLoader = useCallback(
|
||||||
fn: () => Promise<T>,
|
async <T,>(fn: () => Promise<T>, message?: string): Promise<T> => {
|
||||||
message?: string
|
|
||||||
): Promise<T> => {
|
|
||||||
showLoader(message);
|
showLoader(message);
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
hideLoader();
|
hideLoader();
|
||||||
}
|
}
|
||||||
}, [showLoader, hideLoader]);
|
},
|
||||||
|
[showLoader, hideLoader]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}>
|
<LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}>
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@ import { ReactNode } from 'react';
|
||||||
// Container for the Header section
|
// Container for the Header section
|
||||||
export const ModalHeader = ({ children, title }: { children?: ReactNode; title?: string }) => (
|
export const ModalHeader = ({ children, title }: { children?: ReactNode; title?: string }) => (
|
||||||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||||
{title ? <h3 id="modal-title" className="text-lg font-semibold text-gray-900">{title}</h3> : children}
|
{title ? (
|
||||||
|
<h3 id="modal-title" className="text-lg font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Container for the Main Body
|
// Container for the Main Body
|
||||||
export const ModalContent = ({ children }: { children: ReactNode }) => (
|
export const ModalContent = ({ children }: { children: ReactNode }) => (
|
||||||
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">
|
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Container for Actions (Buttons)
|
// Container for Actions (Buttons)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ const DeleteConfirmModal = ({ onClose }: { onClose: () => void }) => {
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { /* Delete logic here */ onClose(); }}
|
onClick={() => {
|
||||||
|
/* Delete logic here */ onClose();
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-white bg-red-600 rounded hover:bg-red-700"
|
className="px-4 py-2 text-white bg-red-600 rounded hover:bg-red-700"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ export const ModalRoot = () => {
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.body.style.overflow = 'unset';
|
||||||
}
|
}
|
||||||
return () => { document.body.style.overflow = 'unset'; };
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen || !content) return null;
|
if (!isOpen || !content) return null;
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ export const ToastExample = () => {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold text-white mb-4">Toast System Examples</h3>
|
<h3 className="text-xl font-semibold text-white mb-4">Toast System Examples</h3>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Click the buttons below to see different toast notifications in action. The toast uses React Context for state management.
|
Click the buttons below to see different toast notifications in action. The toast uses React
|
||||||
|
Context for state management.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,36 @@ const Icons = {
|
||||||
),
|
),
|
||||||
warning: (
|
warning: (
|
||||||
<svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
info: (
|
info: (
|
||||||
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getColors = (type: NotificationType) => {
|
const getColors = (type: NotificationType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success': return 'bg-white border-l-4 border-green-500';
|
case 'success':
|
||||||
case 'error': return 'bg-white border-l-4 border-red-500';
|
return 'bg-white border-l-4 border-green-500';
|
||||||
case 'warning': return 'bg-white border-l-4 border-yellow-500';
|
case 'error':
|
||||||
case 'info': return 'bg-white border-l-4 border-blue-500';
|
return 'bg-white border-l-4 border-red-500';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-white border-l-4 border-yellow-500';
|
||||||
|
case 'info':
|
||||||
|
return 'bg-white border-l-4 border-blue-500';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,9 +64,7 @@ export const ToastRoot = () => {
|
||||||
<div className="flex-shrink-0 mt-0.5">{Icons[toast.type]}</div>
|
<div className="flex-shrink-0 mt-0.5">{Icons[toast.type]}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">{toast.title}</h4>
|
<h4 className="text-sm font-semibold text-gray-900">{toast.title}</h4>
|
||||||
{toast.message && (
|
{toast.message && <p className="text-sm text-gray-600 mt-1">{toast.message}</p>}
|
||||||
<p className="text-sm text-gray-600 mt-1">{toast.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeNotification(toast.id)}
|
onClick={() => removeNotification(toast.id)}
|
||||||
|
|
@ -60,7 +72,12 @@ export const ToastRoot = () => {
|
||||||
aria-label="Close notification"
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,24 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios';
|
||||||
import {
|
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
|
||||||
RegisterData,
|
|
||||||
UserData,
|
|
||||||
ProductData,
|
|
||||||
OrderData,
|
|
||||||
AuthResponse
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
|
|
||||||
// Add token to requests if available
|
// Add token to requests if available
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
);
|
||||||
|
|
||||||
// Handle response errors
|
// Handle response errors
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
|
|
@ -33,63 +26,66 @@ api.interceptors.response.use(
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// Token expired or invalid
|
// Token expired or invalid
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user');
|
||||||
window.location.href = '/login'
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
return {
|
return {
|
||||||
// Auth
|
// Auth
|
||||||
login: async (email: string, password: string): Promise<AuthResponse> => {
|
login: async (email: string, password: string): Promise<AuthResponse> => {
|
||||||
const response = await api.post<AuthResponse>('/auth/login', { email, password })
|
const response = await api.post<AuthResponse>('/auth/login', {
|
||||||
return response.data
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
register: async (userData: RegisterData): Promise<AuthResponse> => {
|
register: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||||
const response = await api.post<AuthResponse>('/auth/register', userData)
|
const response = await api.post<AuthResponse>('/auth/register', userData);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
getCurrentUser: async (): Promise<UserData> => {
|
getCurrentUser: async (): Promise<UserData> => {
|
||||||
const response = await api.get<UserData>('/users/me')
|
const response = await api.get<UserData>('/users/me');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
getProducts: async (): Promise<ProductData[]> => {
|
getProducts: async (): Promise<ProductData[]> => {
|
||||||
const response = await api.get<ProductData[]>('/products')
|
const response = await api.get<ProductData[]>('/products');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
getProduct: async (id: string): Promise<ProductData> => {
|
getProduct: async (id: string): Promise<ProductData> => {
|
||||||
const response = await api.get<ProductData>(`/products/${id}`)
|
const response = await api.get<ProductData>(`/products/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
|
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
|
||||||
const response = await api.post<ProductData>('/products', productData)
|
const response = await api.post<ProductData>('/products', productData);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
updateProduct: async (id: string, productData: Partial<ProductData>): Promise<ProductData> => {
|
updateProduct: async (id: string, productData: Partial<ProductData>): Promise<ProductData> => {
|
||||||
const response = await api.put<ProductData>(`/products/${id}`, productData)
|
const response = await api.put<ProductData>(`/products/${id}`, productData);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
deleteProduct: async (id: string): Promise<void> => {
|
deleteProduct: async (id: string): Promise<void> => {
|
||||||
await api.delete(`/products/${id}`)
|
await api.delete(`/products/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
getOrders: async (): Promise<OrderData[]> => {
|
getOrders: async (): Promise<OrderData[]> => {
|
||||||
const response = await api.get<OrderData[]>('/orders')
|
const response = await api.get<OrderData[]>('/orders');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
getOrder: async (id: string): Promise<OrderData> => {
|
getOrder: async (id: string): Promise<OrderData> => {
|
||||||
const response = await api.get<OrderData>(`/orders/${id}`)
|
const response = await api.get<OrderData>(`/orders/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
|
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
|
||||||
const response = await api.post<OrderData>('/orders', orderData)
|
const response = await api.post<OrderData>('/orders', orderData);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -17,10 +17,7 @@ export function useProducts() {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Use withLoader to show loading state and handle errors
|
// Use withLoader to show loading state and handle errors
|
||||||
const data = await withLoader(
|
const data = await withLoader(() => getProducts(), 'Loading products...');
|
||||||
() => getProducts(),
|
|
||||||
'Loading products...'
|
|
||||||
);
|
|
||||||
|
|
||||||
setProducts(data);
|
setProducts(data);
|
||||||
|
|
||||||
|
|
@ -52,6 +49,7 @@ export function useProducts() {
|
||||||
// Optionally auto-fetch on mount
|
// Optionally auto-fetch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { AppProvider } from './context/AppContext'
|
import { AppProvider } from './context/AppContext';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
@ -12,5 +12,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<App />
|
<App />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export default function Cart() {
|
||||||
clearCart();
|
clearCart();
|
||||||
navigate('/orders');
|
navigate('/orders');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
alert('Failed to create order. Please try again.');
|
alert('Failed to create order. Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { ModalExample } from '../context/modals/ModalExample'
|
import { ModalExample } from '../context/modals/ModalExample';
|
||||||
import { ToastExample } from '../context/toasts/ToastExample'
|
import { ToastExample } from '../context/toasts/ToastExample';
|
||||||
import { LoaderExample } from '../context/loaders/LoaderExample'
|
import { LoaderExample } from '../context/loaders/LoaderExample';
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h1 className="text-5xl font-bold text-white mb-4">
|
<h1 className="text-5xl font-bold text-white mb-4">Welcome to Crafting Shop</h1>
|
||||||
Welcome to Crafting Shop
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-gray-300 mb-8">
|
<p className="text-xl text-gray-300 mb-8">
|
||||||
Your one-stop shop for premium crafting supplies
|
Your one-stop shop for premium crafting supplies
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -39,7 +37,8 @@ export function Home() {
|
||||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
<h2 className="text-2xl font-semibold text-white mb-4">Modal System Demo</h2>
|
<h2 className="text-2xl font-semibold text-white mb-4">Modal System Demo</h2>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Test our modal system with this interactive example. The modal uses React Context for state management.
|
Test our modal system with this interactive example. The modal uses React Context for
|
||||||
|
state management.
|
||||||
</p>
|
</p>
|
||||||
<ModalExample />
|
<ModalExample />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,7 +46,8 @@ export function Home() {
|
||||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
<h2 className="text-2xl font-semibold text-white mb-4">Toast System Demo</h2>
|
<h2 className="text-2xl font-semibold text-white mb-4">Toast System Demo</h2>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Test our toast notification system with this interactive example. The toast uses React Context for state management.
|
Test our toast notification system with this interactive example. The toast uses React
|
||||||
|
Context for state management.
|
||||||
</p>
|
</p>
|
||||||
<ToastExample />
|
<ToastExample />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,10 +55,11 @@ export function Home() {
|
||||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
<h2 className="text-2xl font-semibold text-white mb-4">Loader System Demo</h2>
|
<h2 className="text-2xl font-semibold text-white mb-4">Loader System Demo</h2>
|
||||||
<p className="text-gray-400 mb-6">
|
<p className="text-gray-400 mb-6">
|
||||||
Test our global loading system with this interactive example. The loader uses React Context for state management.
|
Test our global loading system with this interactive example. The loader uses React
|
||||||
|
Context for state management.
|
||||||
</p>
|
</p>
|
||||||
<LoaderExample />
|
<LoaderExample />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,40 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext';
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi';
|
||||||
import { User } from '../types'
|
import { User } from '../types';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { login } = useApp()
|
const { login } = useApp();
|
||||||
const { login: loginApi } = useApi()
|
const { login: loginApi } = useApi();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError('');
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await loginApi(email, password)
|
const response = await loginApi(email, password);
|
||||||
// Convert UserData to User type
|
// Convert UserData to User type
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: parseInt(response.user.id),
|
id: parseInt(response.user.id),
|
||||||
username: response.user.username,
|
username: response.user.username,
|
||||||
email: response.user.email,
|
email: response.user.email,
|
||||||
}
|
};
|
||||||
login(user, response.token)
|
login(user, response.token);
|
||||||
navigate('/')
|
navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed. Please try again.')
|
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
|
|
@ -85,11 +85,11 @@ export default function Login() {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-gray-400">
|
<p className="mt-6 text-center text-gray-400">
|
||||||
Don't have an account?{' '}
|
Don't have an account?
|
||||||
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
<Link to="/register" className="text-blue-400 hover:text-blue-300">
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,34 +1,35 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext';
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi';
|
||||||
import { OrderData } from '../types'
|
import { OrderData } from '../types';
|
||||||
|
|
||||||
export function Orders() {
|
export function Orders() {
|
||||||
const [orders, setOrders] = useState<OrderData[]>([])
|
const [orders, setOrders] = useState<OrderData[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true)
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { user } = useApp()
|
const { user } = useApp();
|
||||||
const { getOrders } = useApi()
|
const { getOrders } = useApi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
navigate('/login')
|
navigate('/login');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
fetchOrders()
|
fetchOrders();
|
||||||
}, [user, navigate])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getOrders()
|
const data = await getOrders();
|
||||||
setOrders(data)
|
setOrders(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching orders:', error)
|
console.error('Error fetching orders:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
const getStatusColor = (status: string): string => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
|
|
@ -37,16 +38,16 @@ export function Orders() {
|
||||||
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
|
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
|
||||||
delivered: 'bg-green-900 text-green-200 border-green-700',
|
delivered: 'bg-green-900 text-green-200 border-green-700',
|
||||||
cancelled: 'bg-red-900 text-red-200 border-red-700',
|
cancelled: 'bg-red-900 text-red-200 border-red-700',
|
||||||
}
|
};
|
||||||
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700'
|
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-400">Loading orders...</div>
|
<div className="text-gray-400">Loading orders...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -72,9 +73,7 @@ export function Orders() {
|
||||||
>
|
>
|
||||||
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">
|
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
|
||||||
Order #{order.id}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{new Date(order.created_at).toLocaleDateString()}
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -96,9 +95,7 @@ export function Orders() {
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium">Product #{item.product_id}</p>
|
<p className="text-white font-medium">Product #{item.product_id}</p>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
|
||||||
Quantity: {item.quantity}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white font-bold">
|
<p className="text-white font-bold">
|
||||||
${(item.price * item.quantity).toFixed(2)}
|
${(item.price * item.quantity).toFixed(2)}
|
||||||
|
|
@ -109,15 +106,11 @@ export function Orders() {
|
||||||
|
|
||||||
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
|
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
{order.shipping_address && (
|
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
|
||||||
<span>Ship to: {order.shipping_address}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl">
|
<div className="text-xl">
|
||||||
<span className="text-gray-400">Total:</span>{' '}
|
<span className="text-gray-400">Total:</span>{' '}
|
||||||
<span className="text-white font-bold">
|
<span className="text-white font-bold">${order.total_amount}</span>
|
||||||
${order.total_amount}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,5 +118,5 @@ export function Orders() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useApp } from '../context/AppContext'
|
import { useApp } from '../context/AppContext';
|
||||||
import { useProducts } from '../hooks/useProducts'
|
import { useProducts } from '../hooks/useProducts';
|
||||||
import { CartItem } from '../types'
|
import { CartItem } from '../types';
|
||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const { products, refetch } = useProducts()
|
const { products, refetch } = useProducts();
|
||||||
const { addToCart } = useApp()
|
const { addToCart } = useApp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -31,19 +31,11 @@ export function Products() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">
|
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
|
||||||
{product.name}
|
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
|
||||||
</h3>
|
|
||||||
<p className="text-gray-400 text-sm mb-3 line-clamp-2">
|
|
||||||
{product.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl font-bold text-blue-400">
|
<span className="text-xl font-bold text-blue-400">${product.price}</span>
|
||||||
${product.price}
|
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-400">
|
|
||||||
Stock: {product.stock}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -52,7 +44,7 @@ export function Products() {
|
||||||
name: product.name,
|
name: product.name,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
image_url: product.image_url
|
image_url: product.image_url,
|
||||||
};
|
};
|
||||||
addToCart(cartItem);
|
addToCart(cartItem);
|
||||||
}}
|
}}
|
||||||
|
|
@ -70,5 +62,5 @@ export function Products() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { useState, FormEvent, ChangeEvent } from 'react'
|
import { useState, FormEvent, ChangeEvent } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
email: string
|
email: string;
|
||||||
username: string
|
username: string;
|
||||||
password: string
|
password: string;
|
||||||
confirmPassword: string
|
confirmPassword: string;
|
||||||
first_name: string
|
first_name: string;
|
||||||
last_name: string
|
last_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Register() {
|
export function Register() {
|
||||||
|
|
@ -19,35 +19,35 @@ export function Register() {
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
})
|
});
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { register } = useApi()
|
const { register } = useApi();
|
||||||
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
[e.target.name]: e.target.value,
|
[e.target.name]: e.target.value,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError('');
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
setError('Passwords do not match')
|
setError('Passwords do not match');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password.length < 6) {
|
if (formData.password.length < 6) {
|
||||||
setError('Password must be at least 6 characters')
|
setError('Password must be at least 6 characters');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({
|
await register({
|
||||||
|
|
@ -56,14 +56,14 @@ export function Register() {
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
first_name: formData.first_name,
|
first_name: formData.first_name,
|
||||||
last_name: formData.last_name,
|
last_name: formData.last_name,
|
||||||
})
|
});
|
||||||
navigate('/login')
|
navigate('/login');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Registration failed. Please try again.')
|
setError(err.response?.data?.error || 'Registration failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
|
|
@ -182,5 +182,5 @@ export function Register() {
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Loading…
Reference in a new issue