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:
david 2026-02-25 15:55:04 +03:00
commit 861160566c
69 changed files with 2852 additions and 1285 deletions

69
.github/workflows/backend.yml vendored Normal file
View 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

View file

@ -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

View file

@ -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
View 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
View 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

View file

@ -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
View 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

View file

@ -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

View file

@ -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}")

View file

@ -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",

View file

@ -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,
) )

View file

@ -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}")

View file

@ -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,
} }

View file

@ -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"]

View file

@ -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):

View file

@ -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):

View file

@ -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):

View file

@ -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 (
"user": user.to_dict(), jsonify(
"access_token": access_token, {
"refresh_token": refresh_token "user": user.to_dict(),
}), 200 "access_token": access_token,
"refresh_token": refresh_token,
}
),
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, {
"quantity": item_data["quantity"], "product": product,
"price": product.price "quantity": item_data["quantity"],
}) "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 (
"message": "Divide task triggered", jsonify(
"task_id": task.id, {
"operation": f"{x} / {y}", "message": "Divide task triggered",
"status": "pending" "task_id": task.id,
}), 202 "operation": f"{x} / {y}",
"status": "pending",
}
),
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 (
"message": "Daily report task triggered", jsonify(
"task_id": task.id, {
"status": "pending" "message": "Daily report task triggered",
}), 202 "task_id": task.id,
"status": "pending",
}
),
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 (
"message": f"Long-running task triggered with {iterations} iterations", jsonify(
"task_id": task.id, {
"status": "pending" "message": f"Long-running task triggered with {iterations} iterations",
}), 202 "task_id": task.id,
"status": "pending",
}
),
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

View file

@ -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

View file

@ -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

View file

@ -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
View 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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests package for Flask application"""

190
backend/tests/conftest.py Normal file
View 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

View 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

View 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

View 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

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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>
)
}

View file

@ -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>
) );
} }

View file

@ -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
)
); );
}; };

View file

@ -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>

View file

@ -11,22 +11,18 @@ 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">
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin"></div> <div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
{/* Optional inner circle for style */} {/* Optional inner circle for style */}
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-white/50 border-b-transparent rounded-full animate-spin"></div> <div className="w-8 h-8 border-4 border-white/50 border-b-transparent rounded-full animate-spin"></div>
</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
); );

View file

@ -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 showLoader(message);
): Promise<T> => { try {
showLoader(message); return await fn();
try { } finally {
return await fn(); hideLoader();
} finally { }
hideLoader(); },
} [showLoader, hideLoader]
}, [showLoader, hideLoader]); );
return ( return (
<LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}> <LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}>

View file

@ -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)

View file

@ -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

View file

@ -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;

View file

@ -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">

View file

@ -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>

View file

@ -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
},
}
}

View file

@ -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;
}, },
} };
} }

View file

@ -17,20 +17,17 @@ 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);
// // Show success toast // // Show success toast
// addNotification({ // addNotification({
// type: 'success', // type: 'success',
// title: 'Products Loaded', // title: 'Products Loaded',
// message: `Successfully loaded ${data.length} products.`, // message: `Successfully loaded ${data.length} products.`,
// duration: 3000, // duration: 3000,
// }); // });
return data; return data;
} catch (err) { } catch (err) {
@ -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 {

View file

@ -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>
) );

View file

@ -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.');
} }
}; };

View file

@ -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>
) );
} }

View file

@ -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&apos;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>
) );
} }

View file

@ -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>
) );
} }

View file

@ -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>
) );
} }

View file

@ -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>
)
}

View file

@ -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>
) );
} }

View file

@ -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',
}
)
)