lint frontend

This commit is contained in:
david 2026-02-25 12:29:45 +03:00
parent 1c35df931a
commit e230af21c4
43 changed files with 960 additions and 1474 deletions

View file

@ -1,141 +0,0 @@
name: Backend Tests
on:
push:
branches: [ main, develop ]
paths:
- 'backend/**'
- '.github/workflows/backend-tests.yml'
pull_request:
branches: [ main, develop ]
paths:
- 'backend/**'
- '.github/workflows/backend-tests.yml'
jobs:
test:
runs-on: [docker]
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'backend/requirements/*.txt'
- name: Install dependencies
working-directory: ./backend
run: |
python -m pip install --upgrade pip
pip install -r requirements/base.txt
pip install -r requirements/dev.txt
- name: Lint with flake8 (if installed)
working-directory: ./backend
run: |
pip install flake8
# Stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Exit-zero treats all errors as warnings
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
continue-on-error: true
- name: Run tests with pytest
working-directory: ./backend
env:
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
CELERY_BROKER_URL: redis://localhost:6379/0
CELERY_RESULT_BACKEND: redis://localhost:6379/0
SECRET_KEY: test-secret-key
JWT_SECRET_KEY: test-jwt-secret-key
FLASK_ENV: testing
run: |
pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./backend/coverage.xml
flags: backend
name: codecov-umbrella
fail_ci_if_error: false
- name: Upload coverage HTML as artifact
uses: actions/upload-artifact@v3
with:
name: coverage-report-python-${{ matrix.python-version }}
path: backend/htmlcov/
retention-days: 7
- name: Check coverage thresholds
working-directory: ./backend
run: |
coverage report --fail-under=80
security-scan:
runs-on: [docker]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
working-directory: ./backend
run: |
python -m pip install --upgrade pip
pip install bandit safety
- name: Run Bandit security linter
working-directory: ./backend
run: |
bandit -r app -f json -o bandit-report.json || true
continue-on-error: true
- name: Check for known security vulnerabilities
working-directory: ./backend
run: |
safety check --json --output safety-report.json || true
continue-on-error: true
- name: Upload security reports
uses: actions/upload-artifact@v3
with:
name: security-reports
path: |
backend/bandit-report.json
backend/safety-report.json

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,116 +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
container:
image: nikolaik/python-nodejs:python3.12-nodejs24-alpine
steps:
- uses: actions/checkout@v6
- name: Set up Python
run: |
python --version
- name: Cache pip packages
uses: actions/cache@v3
with:
# This path is on the runner, but we mounted it to the container
path: /tmp/pip-cache
key: ${{ runner.os }}-pip-${{ hashFiles('backend/requirements/dev.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 --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
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

View file

@ -1,3 +1,4 @@
import json
import os
from dotenv import load_dotenv
@ -32,7 +33,7 @@ def create_app(config_name=None):
f"------------------ENVIRONMENT: {config_name}-------------------------------------"
)
# 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("----------------------------------------------------------")
# Initialize extensions with app
db.init_app(app)

View file

@ -131,7 +131,7 @@ def products(db_session):
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=0, max_value=100),
stock=fake.pyint(min_value=20, max_value=100),
image_url=fake.url(),
)
db_session.add(product)

View file

@ -1,491 +0,0 @@
# Testing Guide
## Overview
This document provides a comprehensive guide to testing the Flask application using pytest. The testing infrastructure includes unit tests, integration tests, CI/CD pipelines, and pre-commit hooks.
## Table of Contents
1. [Installation](#installation)
2. [Running Tests](#running-tests)
3. [Test Structure](#test-structure)
4. [Writing Tests](#writing-tests)
5. [Fixtures](#fixtures)
6. [Coverage](#coverage)
7. [CI/CD Pipeline](#cicd-pipeline)
8. [Pre-commit Hooks](#pre-commit-hooks)
9. [Best Practices](#best-practices)
## Installation
### Install Test Dependencies
```bash
cd backend
pip install -r requirements/base.txt
```
The base requirements include:
- `pytest==7.4.3` - Testing framework
- `pytest-flask==1.3.0` - Flask integration
- `pytest-cov==4.1.0` - Coverage reporting
- `pytest-mock==3.12.0` - Mocking utilities
- `factory-boy==3.3.0` - Test data factories
- `faker==20.1.0` - Fake data generation
## Running Tests
### Run All Tests
```bash
cd backend
pytest
```
### Run with Verbose Output
```bash
pytest -v
```
### Run with Coverage Report
```bash
pytest --cov=app --cov-report=html --cov-report=term
```
### Run Specific Test Files
```bash
# Run all model tests
pytest tests/test_models.py
# Run all route tests
pytest tests/test_routes.py
# Run all schema tests
pytest tests/test_schemas.py
```
### Run by Test Name
```bash
pytest -k "test_user_creation"
pytest -k "test_login"
```
### Run by Markers
```bash
# Run only unit tests
pytest -m unit
# Run only integration tests
pytest -m integration
# Run only authentication tests
pytest -m auth
# Run only product tests
pytest -m product
# Run only order tests
pytest -m order
```
### Run Tests in Parallel (faster)
Install pytest-xdist:
```bash
pip install pytest-xdist
pytest -n auto # Use all available CPUs
```
## Test Structure
```
backend/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Global fixtures and configuration
│ ├── test_models.py # Model tests
│ ├── test_routes.py # Route/API tests
│ └── test_schemas.py # Pydantic schema tests
├── pytest.ini # Pytest configuration
├── .coveragerc # Coverage configuration
└── app/
├── __init__.py
├── models/ # Database models
├── routes/ # API routes
├── schemas/ # Pydantic schemas
└── ...
```
## Writing Tests
### Test File Structure
```python
import pytest
from app.models import User
from app import db
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'
)
user.set_password('password123')
db_session.add(user)
db_session.commit()
assert user.id is not None
assert user.email == 'test@example.com'
```
### Test API Routes
```python
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
def test_create_product(client, admin_headers):
"""Test creating a product"""
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'
```
### Parameterized Tests
```python
@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 inputs"""
response = client.post('/api/auth/login', json={
'email': email,
'password': password
})
assert response.status_code == expected_status
```
## Fixtures
### Available Fixtures
#### Application Fixtures
- **`app`**: Flask application instance with test configuration
- **`client`**: Test client for making HTTP requests
- **`runner`**: CLI runner for testing Flask CLI commands
- **`db_session`**: Database session for database operations
#### User Fixtures
- **`admin_user`**: Creates an admin user
- **`regular_user`**: Creates a regular user
- **`inactive_user`**: Creates an inactive user
#### Product Fixtures
- **`product`**: Creates a single product
- **`products`**: Creates 5 products
#### Authentication Fixtures
- **`auth_headers`**: JWT headers for regular user
- **`admin_headers`**: JWT headers for admin user
#### Order Fixtures
- **`order`**: Creates an order with items
### Creating Custom Fixtures
```python
# In conftest.py or test file
@pytest.fixture
def custom_product(db_session):
"""Create a custom product"""
product = Product(
name='Custom Product',
price=99.99,
stock=50
)
db_session.add(product)
db_session.commit()
return product
# Use in tests
def test_custom_fixture(custom_product):
assert custom_product.name == 'Custom Product'
```
## Coverage
### Coverage Configuration
Coverage is configured in `.coveragerc`:
```ini
[run]
source = app
omit =
*/tests/*
*/migrations/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
```
### Coverage Thresholds
The CI/CD pipeline enforces 80% minimum code coverage.
### Generate Coverage Report
```bash
# Terminal report
pytest --cov=app --cov-report=term
# HTML report
pytest --cov=app --cov-report=html
open htmlcov/index.html # Mac
xdg-open htmlcov/index.html # Linux
```
### Coverage Report Example
```
Name Stmts Miss Cover Missing
----------------------------------------------
app/__init__.py 10 2 80% 15-16
app/models/user.py 45 5 89% 23, 45
app/routes/api.py 120 20 83% 78-85
----------------------------------------------
TOTAL 175 27 85%
```
## CI/CD Pipeline
### GitHub Actions Workflow
The backend has automated testing via GitHub Actions:
**File**: `.github/workflows/backend-tests.yml`
### Pipeline Stages
1. **Test Matrix**: Runs tests on Python 3.10, 3.11, and 3.12
2. **Services**: Sets up PostgreSQL and Redis
3. **Linting**: Runs flake8 for code quality
4. **Testing**: Executes pytest with coverage
5. **Coverage Upload**: Sends coverage to Codecov
6. **Security Scan**: Runs bandit and safety
### Triggering the Pipeline
The pipeline runs automatically on:
- Push to `main` or `develop` branches
- Pull requests to `main` or `develop` branches
- Changes to `backend/**` or workflow files
### Viewing Results
1. Go to the Actions tab in your GitHub repository
2. Click on the latest workflow run
3. View test results, coverage, and artifacts
## Pre-commit Hooks
### Setup Pre-commit Hooks
```bash
# Install pre-commit
pip install pre-commit
# Install hooks
pre-commit install
# Run hooks manually
pre-commit run --all-files
```
### Available Hooks
The `.pre-commit-config.yaml` includes:
1. **Black**: Code formatting
2. **isort**: Import sorting
3. **flake8**: Linting
4. **pytest**: Run tests before committing
5. **mypy**: Type checking
6. **bandit**: Security checks
### Hook Behavior
Hooks run automatically on:
- `git commit`
- Can be skipped with `git commit --no-verify`
## Best Practices
### ✅ DO
1. **Use descriptive test names**
```python
def test_user_creation_with_valid_data(): # Good
def test_user(): # Bad
```
2. **Test both success and failure cases**
```python
def test_login_success(): ...
def test_login_invalid_credentials(): ...
def test_login_missing_fields(): ...
```
3. **Use fixtures for common setup**
```python
def test_something(client, admin_user, products): ...
```
4. **Mock external services**
```python
def test_external_api(mocker):
mock_response = {'data': 'mocked'}
mocker.patch('requests.get', return_value=mock_response)
```
5. **Keep tests independent**
- Each test should be able to run alone
- Don't rely on test execution order
6. **Use markers appropriately**
```python
@pytest.mark.slow
def test_expensive_operation(): ...
```
### ❌ DON'T
1. **Don't share state between tests**
```python
# Bad - shared state
global_user = User(...)
# Good - use fixtures
@pytest.fixture
def user(): return User(...)
```
2. **Don't hardcode sensitive data**
```python
# Bad
password = 'real_password_123'
# Good
password = fake.password()
```
3. **Don't use production database**
- Always use test database (SQLite)
- Fixtures automatically create isolated databases
4. **Don't skip error cases**
```python
# Bad - only tests success
def test_create_product(): ...
# Good - tests both
def test_create_product_success(): ...
def test_create_product_validation_error(): ...
```
5. **Don't ignore slow tests in CI**
- Mark slow tests with `@pytest.mark.slow`
- Run them separately if needed
## Test Coverage Requirements
| Module | Line Coverage | Branch Coverage |
|--------|--------------|-----------------|
| routes.py | >90% | >85% |
| models.py | >85% | >80% |
| schemas.py | >90% | >85% |
| services/ | >80% | >75% |
| utils/ | >70% | >65% |
## Troubleshooting
### Tests Fail with Database Errors
```bash
# Clean up test databases
rm -f backend/*.db
```
### Coverage Not Showing
```bash
# Install coverage separately
pip install coverage
# Clean previous coverage data
coverage erase
# Run tests again
pytest --cov=app
```
### Import Errors
```bash
# Ensure you're in the backend directory
cd backend
# Install in development mode
pip install -e .
```
### Slow Tests
```bash
# Run only specific tests
pytest tests/test_routes.py::TestProductRoutes::test_get_products
# Run in parallel
pytest -n auto
```
## Additional Resources
- [Pytest Documentation](https://docs.pytest.org/)
- [Pytest-Flask Documentation](https://pytest-flask.readthedocs.io/)
- [Pydantic Documentation](https://docs.pydantic.dev/)
- [Flask Testing Documentation](https://flask.palletsprojects.com/en/latest/testing/)

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",
"@types/react": "^18.3.28",
"@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",
"@vitest/ui": "^1.0.4",
"autoprefixer": "^10.4.16",
"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-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^23.0.1",
"postcss": "^8.4.32",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^5.0.8",
@ -1018,6 +1023,18 @@
"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": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -1551,6 +1568,285 @@
"@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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@ -3045,6 +3341,51 @@
"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": {
"version": "7.37.5",
"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==",
"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": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -5293,6 +5640,33 @@
"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": {
"version": "27.5.1",
"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==",
"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": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -6376,6 +6765,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": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",

View file

@ -6,7 +6,10 @@
"dev": "vite",
"build": "vite build",
"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:ui": "vitest --ui"
},
@ -22,15 +25,20 @@
"@testing-library/user-event": "^14.5.1",
"@types/react": "^18.3.28",
"@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",
"@vitest/ui": "^1.0.4",
"autoprefixer": "^10.4.16",
"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-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^23.0.1",
"postcss": "^8.4.32",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^5.0.8",

View file

@ -1,17 +1,17 @@
import { Routes, Route } from 'react-router-dom'
import { ModalProvider } from './context/modals/useModal'
import { ModalRoot } from './context/modals/ModalRoot'
import { ToastProvider } from './context/toasts/useToast'
import { ToastRoot } from './context/toasts/ToastRoot'
import { LoaderProvider } from './context/loaders/useLoader'
import { LoaderRoot } from './context/loaders/LoaderRoot'
import Cart from './pages/Cart'
import { Navbar } from './components/Navbar'
import { Home } from './pages/Home'
import { Products } from './pages/Products'
import Login from './pages/Login'
import { Register } from './pages/Register'
import { Orders } from './pages/Orders'
import { Routes, Route } from 'react-router-dom';
import { ModalProvider } from './context/modals/useModal';
import { ModalRoot } from './context/modals/ModalRoot';
import { ToastProvider } from './context/toasts/useToast';
import { ToastRoot } from './context/toasts/ToastRoot';
import { LoaderProvider } from './context/loaders/useLoader';
import { LoaderRoot } from './context/loaders/LoaderRoot';
import Cart from './pages/Cart';
import { Navbar } from './components/Navbar';
import { Home } from './pages/Home';
import { Products } from './pages/Products';
import Login from './pages/Login';
import { Register } from './pages/Register';
import { Orders } from './pages/Orders';
const App = () => {
return (
@ -38,7 +38,7 @@ const App = () => {
</ModalProvider>
</ToastProvider>
</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 { useApp } from '../context/AppContext'
import { Link } from 'react-router-dom';
import { useApp } from '../context/AppContext';
export function Navbar() {
const { user } = useApp()
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">
<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">
@ -43,9 +46,7 @@ export function Navbar() {
</div>
<div className="flex items-center">
{user ? (
<span className="text-gray-300 px-3 py-2">
{user.username}
</span>
<span className="text-gray-300 px-3 py-2">{user.username}</span>
) : (
<>
<Link
@ -66,5 +67,5 @@ export function Navbar() {
</div>
</div>
</nav>
)
}
);
}

View file

@ -36,17 +36,17 @@ export function AppProvider({ children }: AppProviderProps) {
useEffect(() => {
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
const storedCart = localStorage.getItem('cart');
if (storedCart) {
setCart(JSON.parse(storedCart));
}
setLoading(false);
}, []);
@ -87,9 +87,7 @@ export function AppProvider({ children }: AppProviderProps) {
const existingItem = prevCart.find((item) => item.id === product.id);
if (existingItem) {
return prevCart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prevCart, { ...product, quantity: 1 }];
@ -106,9 +104,7 @@ export function AppProvider({ children }: AppProviderProps) {
return;
}
setCart((prevCart: CartItem[]) =>
prevCart.map((item) =>
item.id === productId ? { ...item, quantity } : item
)
prevCart.map((item) => (item.id === productId ? { ...item, quantity } : item))
);
};

View file

@ -7,12 +7,12 @@ export const LoaderExample = () => {
// Pattern A: Manual Control
const handleManualLoad = async () => {
showLoader("Processing manual task...");
showLoader('Processing manual task...');
try {
// Simulate an async operation
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
addNotification({
type: 'success',
title: 'Manual Task Complete',
@ -20,6 +20,7 @@ export const LoaderExample = () => {
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Manual Task Failed',
@ -34,15 +35,12 @@ export const LoaderExample = () => {
// Pattern B: withLoader Helper (Cleanest)
const handleWithLoader = async () => {
try {
await withLoader(
async () => {
// Simulate an async operation
await new Promise(resolve => setTimeout(resolve, 1500));
return 'Success!';
},
"Processing with withLoader..."
);
await withLoader(async () => {
// Simulate an async operation
await new Promise((resolve) => setTimeout(resolve, 1500));
return 'Success!';
}, 'Processing with withLoader...');
addNotification({
type: 'success',
title: 'withLoader Task Complete',
@ -50,6 +48,7 @@ export const LoaderExample = () => {
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'withLoader Task Failed',
@ -61,12 +60,12 @@ export const LoaderExample = () => {
// Pattern C: Long-running task
const handleLongLoad = async () => {
showLoader("Processing long-running task...");
showLoader('Processing long-running task...');
try {
// Simulate a longer async operation
await new Promise(resolve => setTimeout(resolve, 4000));
await new Promise((resolve) => setTimeout(resolve, 4000));
addNotification({
type: 'success',
title: 'Long Task Complete',
@ -74,6 +73,7 @@ export const LoaderExample = () => {
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Long Task Failed',
@ -87,14 +87,14 @@ export const LoaderExample = () => {
// Pattern D: Error simulation
const handleError = async () => {
showLoader("Processing task that will fail...");
showLoader('Processing task that will fail...');
try {
// Simulate an error
await new Promise((_, reject) =>
await new Promise((_, reject) =>
setTimeout(() => reject(new Error('Simulated error')), 1500)
);
addNotification({
type: 'success',
title: 'Task Complete',
@ -102,6 +102,7 @@ export const LoaderExample = () => {
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Task Failed',
@ -116,10 +117,10 @@ export const LoaderExample = () => {
// Pattern E: Without message
const handleNoMessage = async () => {
showLoader();
try {
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
addNotification({
type: 'success',
title: 'No Message Task Complete',
@ -127,6 +128,7 @@ export const LoaderExample = () => {
duration: 3000,
});
} catch (err) {
console.error(err);
addNotification({
type: 'error',
title: 'Task Failed',
@ -142,9 +144,10 @@ export const LoaderExample = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold text-white mb-4">Loader System Examples</h3>
<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>
<div className="flex flex-wrap gap-3">
<button
onClick={handleManualLoad}
@ -181,12 +184,21 @@ export const LoaderExample = () => {
<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>
<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><strong>withLoader:</strong> Use the helper for automatic cleanup (recommended)</li>
<li><strong>Message:</strong> Optional - provides context about what's happening</li>
<li><strong>Z-Index:</strong> Loader (70) sits above Toasts (60) and Modals (50)</li>
<li>
<strong>Manual Control:</strong> Use showLoader/hideLoader when you need fine-grained
control
</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>
</div>
</div>
);
};
};

View file

@ -7,27 +7,23 @@ export const LoaderRoot = () => {
if (!isLoading) return null;
return createPortal(
<div
<div
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"
aria-busy="true"
aria-label={message || "Loading"}
aria-label={message || 'Loading'}
>
{/* Custom CSS Spinner (No external libs) */}
<div className="relative">
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
{/* Optional inner circle for style */}
<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>
{message && (
<p className="mt-4 text-white font-medium text-lg animate-pulse">
{message}
</p>
)}
{message && <p className="mt-4 text-white font-medium text-lg animate-pulse">{message}</p>}
</div>,
document.body
);
};
};

View file

@ -1,2 +1,2 @@
export { LoaderProvider, useLoader } from './useLoader';
export { LoaderRoot } from './LoaderRoot';
export { LoaderRoot } from './LoaderRoot';

View file

@ -30,17 +30,17 @@ export const LoaderProvider: FC<LoaderProviderProps> = ({ children }) => {
}, []);
// Helper to avoid try/finally blocks everywhere
const withLoader = useCallback(async <T,>(
fn: () => Promise<T>,
message?: string
): Promise<T> => {
showLoader(message);
try {
return await fn();
} finally {
hideLoader();
}
}, [showLoader, hideLoader]);
const withLoader = useCallback(
async <T,>(fn: () => Promise<T>, message?: string): Promise<T> => {
showLoader(message);
try {
return await fn();
} finally {
hideLoader();
}
},
[showLoader, hideLoader]
);
return (
<LoaderContext.Provider value={{ ...state, showLoader, hideLoader, withLoader }}>
@ -53,4 +53,4 @@ export const useLoader = () => {
const context = useContext(LoaderContext);
if (!context) throw new Error('useLoader must be used within a LoaderProvider');
return context;
};
};

View file

@ -3,15 +3,19 @@ import { ReactNode } from 'react';
// Container for the Header section
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">
{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>
);
// Container for the Main Body
export const ModalContent = ({ children }: { children: ReactNode }) => (
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">
{children}
</div>
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">{children}</div>
);
// Container for Actions (Buttons)
@ -26,4 +30,4 @@ export const Modal = {
Header: ModalHeader,
Content: ModalContent,
Actions: ModalActions,
};
};

View file

@ -11,14 +11,16 @@ const DeleteConfirmModal = ({ onClose }: { onClose: () => void }) => {
</p>
</Modal.Content>
<Modal.Actions>
<button
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => { /* Delete logic here */ onClose(); }}
<button
onClick={() => {
/* Delete logic here */ onClose();
}}
className="px-4 py-2 text-white bg-red-600 rounded hover:bg-red-700"
>
Delete
@ -39,7 +41,7 @@ export const ModalExample = () => {
return (
<div className="p-10">
<h1 className="text-2xl font-bold mb-4">Modal System Example</h1>
<button
<button
onClick={handleDeleteClick}
className="px-6 py-3 text-white bg-blue-600 rounded hover:bg-blue-700"
>
@ -47,4 +49,4 @@ export const ModalExample = () => {
</button>
</div>
);
};
};

View file

@ -21,13 +21,15 @@ export const ModalRoot = () => {
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen || !content) return null;
return createPortal(
<div
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={closeModal} // Click outside to close
role="dialog"
@ -35,7 +37,7 @@ export const ModalRoot = () => {
aria-labelledby="modal-title"
>
{/* Stop propagation so clicking modal content doesn't close it */}
<div
<div
className="bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden flex flex-col transform transition-all"
onClick={(e) => e.stopPropagation()}
>

View file

@ -52,9 +52,10 @@ export const ToastExample = () => {
<div className="space-y-4">
<h3 className="text-xl font-semibold text-white mb-4">Toast System Examples</h3>
<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>
<div className="flex flex-wrap gap-3">
<button
onClick={showSuccess}
@ -89,4 +90,4 @@ export const ToastExample = () => {
</div>
</div>
);
};
};

View file

@ -15,22 +15,36 @@ const Icons = {
),
warning: (
<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>
),
info: (
<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>
),
};
const getColors = (type: NotificationType) => {
switch (type) {
case 'success': return 'bg-white border-l-4 border-green-500';
case 'error': 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';
case 'success':
return 'bg-white border-l-4 border-green-500';
case 'error':
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-1">
<h4 className="text-sm font-semibold text-gray-900">{toast.title}</h4>
{toast.message && (
<p className="text-sm text-gray-600 mt-1">{toast.message}</p>
)}
{toast.message && <p className="text-sm text-gray-600 mt-1">{toast.message}</p>}
</div>
<button
onClick={() => removeNotification(toast.id)}
@ -60,7 +72,12 @@ export const ToastRoot = () => {
aria-label="Close notification"
>
<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>
</button>
</div>
@ -68,4 +85,4 @@ export const ToastRoot = () => {
</div>,
document.body
);
};
};

View file

@ -59,4 +59,4 @@ export const useToast = () => {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within a ToastProvider');
return context;
};
};

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 {
RegisterData,
UserData,
ProductData,
OrderData,
AuthResponse
} from '../types'
import axios from 'axios';
import { RegisterData, UserData, ProductData, OrderData, AuthResponse } from '../types';
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')
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
return config;
},
(error) => Promise.reject(error)
)
);
// Handle response errors
api.interceptors.response.use(
@ -33,63 +26,66 @@ api.interceptors.response.use(
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
return Promise.reject(error)
return Promise.reject(error);
}
)
);
export function useApi() {
return {
// Auth
login: async (email: string, password: string): Promise<AuthResponse> => {
const response = await api.post<AuthResponse>('/auth/login', { email, password })
return response.data
const response = await api.post<AuthResponse>('/auth/login', {
email,
password,
});
return response.data;
},
register: async (userData: RegisterData): Promise<AuthResponse> => {
const response = await api.post<AuthResponse>('/auth/register', userData)
return response.data
const response = await api.post<AuthResponse>('/auth/register', userData);
return response.data;
},
getCurrentUser: async (): Promise<UserData> => {
const response = await api.get<UserData>('/users/me')
return response.data
const response = await api.get<UserData>('/users/me');
return response.data;
},
// Products
getProducts: async (): Promise<ProductData[]> => {
const response = await api.get<ProductData[]>('/products')
return response.data
const response = await api.get<ProductData[]>('/products');
return response.data;
},
getProduct: async (id: string): Promise<ProductData> => {
const response = await api.get<ProductData>(`/products/${id}`)
return response.data
const response = await api.get<ProductData>(`/products/${id}`);
return response.data;
},
createProduct: async (productData: Omit<ProductData, 'id'>): Promise<ProductData> => {
const response = await api.post<ProductData>('/products', productData)
return response.data
const response = await api.post<ProductData>('/products', productData);
return response.data;
},
updateProduct: async (id: string, productData: Partial<ProductData>): Promise<ProductData> => {
const response = await api.put<ProductData>(`/products/${id}`, productData)
return response.data
const response = await api.put<ProductData>(`/products/${id}`, productData);
return response.data;
},
deleteProduct: async (id: string): Promise<void> => {
await api.delete(`/products/${id}`)
await api.delete(`/products/${id}`);
},
// Orders
getOrders: async (): Promise<OrderData[]> => {
const response = await api.get<OrderData[]>('/orders')
return response.data
const response = await api.get<OrderData[]>('/orders');
return response.data;
},
getOrder: async (id: string): Promise<OrderData> => {
const response = await api.get<OrderData>(`/orders/${id}`)
return response.data
const response = await api.get<OrderData>(`/orders/${id}`);
return response.data;
},
createOrder: async (orderData: Omit<OrderData, 'id'>): Promise<OrderData> => {
const response = await api.post<OrderData>('/orders', orderData)
return response.data
const response = await api.post<OrderData>('/orders', orderData);
return response.data;
},
}
}
};
}

View file

@ -7,7 +7,7 @@ import { ProductData } from '../types';
export function useProducts() {
const [products, setProducts] = useState<ProductData[]>([]);
const [error, setError] = useState<string | null>(null);
const { getProducts } = useApi();
const { withLoader } = useLoader();
const { addNotification } = useToast();
@ -15,28 +15,25 @@ export function useProducts() {
const fetchProducts = async () => {
try {
setError(null);
// Use withLoader to show loading state and handle errors
const data = await withLoader(
() => getProducts(),
'Loading products...'
);
const data = await withLoader(() => getProducts(), 'Loading products...');
setProducts(data);
// // Show success toast
// addNotification({
// type: 'success',
// title: 'Products Loaded',
// message: `Successfully loaded ${data.length} products.`,
// duration: 3000,
// });
// // Show success toast
// addNotification({
// type: 'success',
// title: 'Products Loaded',
// message: `Successfully loaded ${data.length} products.`,
// duration: 3000,
// });
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load products';
setError(errorMessage);
// Show error toast
addNotification({
type: 'error',
@ -44,7 +41,7 @@ export function useProducts() {
message: errorMessage,
duration: 5000,
});
return [];
}
};
@ -52,6 +49,7 @@ export function useProducts() {
// Optionally auto-fetch on mount
useEffect(() => {
fetchProducts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
@ -60,4 +58,4 @@ export function useProducts() {
loading: false, // Loading is handled by the global loader
refetch: fetchProducts,
};
}
}

View file

@ -1,9 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { AppProvider } from './context/AppContext'
import App from './App.tsx'
import './index.css'
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProvider } from './context/AppContext';
import App from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
@ -12,5 +12,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
</AppProvider>
</BrowserRouter>
</React.StrictMode>,
)
</React.StrictMode>
);

View file

@ -29,6 +29,7 @@ export default function Cart() {
clearCart();
navigate('/orders');
} catch (error) {
console.error(error);
alert('Failed to create order. Please try again.');
}
};
@ -51,7 +52,7 @@ export default function Cart() {
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">Shopping Cart</h1>
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
{cart.map((item) => (
<div
@ -65,12 +66,12 @@ export default function Cart() {
className="w-20 h-20 object-cover rounded"
/>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-white">{item.name}</h3>
<p className="text-blue-400 font-bold">${item.price}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateCartQuantity(item.id, item.quantity - 1)}
@ -86,11 +87,11 @@ export default function Cart() {
+
</button>
</div>
<p className="text-white font-bold min-w-[100px] text-right">
${(item.price * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
className="text-red-400 hover:text-red-300 p-2 transition-colors"
@ -107,7 +108,7 @@ export default function Cart() {
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">${cartTotal.toFixed(2)}</span>
</div>
<div className="flex gap-4">
<button
onClick={clearCart}
@ -125,4 +126,4 @@ export default function Cart() {
</div>
</div>
);
}
}

View file

@ -1,15 +1,13 @@
import { Link } from 'react-router-dom'
import { ModalExample } from '../context/modals/ModalExample'
import { ToastExample } from '../context/toasts/ToastExample'
import { LoaderExample } from '../context/loaders/LoaderExample'
import { Link } from 'react-router-dom';
import { ModalExample } from '../context/modals/ModalExample';
import { ToastExample } from '../context/toasts/ToastExample';
import { LoaderExample } from '../context/loaders/LoaderExample';
export function Home() {
return (
<div className="space-y-12">
<div className="text-center py-12">
<h1 className="text-5xl font-bold text-white mb-4">
Welcome to Crafting Shop
</h1>
<h1 className="text-5xl font-bold text-white mb-4">Welcome to Crafting Shop</h1>
<p className="text-xl text-gray-300 mb-8">
Your one-stop shop for premium crafting supplies
</p>
@ -39,7 +37,8 @@ export function Home() {
<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>
<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>
<ModalExample />
</div>
@ -47,7 +46,8 @@ export function Home() {
<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>
<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>
<ToastExample />
</div>
@ -55,10 +55,11 @@ export function Home() {
<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>
<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>
<LoaderExample />
</div>
</div>
)
);
}

View file

@ -1,52 +1,52 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useApp } from '../context/AppContext'
import { useApi } from '../hooks/useApi'
import { User } from '../types'
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
import { User } from '../types';
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const { login } = useApp()
const { login: loginApi } = useApi()
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useApp();
const { login: loginApi } = useApi();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await loginApi(email, password)
const response = await loginApi(email, password);
// Convert UserData to User type
const user: User = {
id: parseInt(response.user.id),
username: response.user.username,
email: response.user.email,
}
login(user, response.token)
navigate('/')
};
login(user, response.token);
navigate('/');
} 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 {
setLoading(false)
setLoading(false);
}
}
};
return (
<div className="max-w-md mx-auto">
<h1 className="text-3xl font-bold text-white mb-8 text-center">Login</h1>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-2">
Email
@ -60,7 +60,7 @@ export default function Login() {
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
@ -74,7 +74,7 @@ export default function Login() {
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}
@ -83,13 +83,13 @@ export default function Login() {
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<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">
Register
</Link>
</p>
</div>
)
}
);
}

View file

@ -1,34 +1,35 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useApp } from '../context/AppContext'
import { useApi } from '../hooks/useApi'
import { OrderData } from '../types'
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../context/AppContext';
import { useApi } from '../hooks/useApi';
import { OrderData } from '../types';
export function Orders() {
const [orders, setOrders] = useState<OrderData[]>([])
const [loading, setLoading] = useState<boolean>(true)
const navigate = useNavigate()
const { user } = useApp()
const { getOrders } = useApi()
const [orders, setOrders] = useState<OrderData[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const { user } = useApp();
const { getOrders } = useApi();
useEffect(() => {
if (!user) {
navigate('/login')
return
navigate('/login');
return;
}
fetchOrders()
}, [user, navigate])
fetchOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, navigate]);
const fetchOrders = async () => {
try {
const data = await getOrders()
setOrders(data)
const data = await getOrders();
setOrders(data);
} catch (error) {
console.error('Error fetching orders:', error)
console.error('Error fetching orders:', error);
} finally {
setLoading(false)
setLoading(false);
}
}
};
const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
@ -37,22 +38,22 @@ export function Orders() {
shipped: 'bg-purple-900 text-purple-200 border-purple-700',
delivered: 'bg-green-900 text-green-200 border-green-700',
cancelled: 'bg-red-900 text-red-200 border-red-700',
}
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700'
}
};
return colors[status] || 'bg-gray-900 text-gray-200 border-gray-700';
};
if (loading) {
return (
<div className="text-center py-12">
<div className="text-gray-400">Loading orders...</div>
</div>
)
);
}
return (
<div>
<h1 className="text-3xl font-bold text-white mb-8">My Orders</h1>
{orders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400 mb-8">You have no orders yet</p>
@ -72,9 +73,7 @@ export function Orders() {
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-white">
Order #{order.id}
</h3>
<h3 className="text-lg font-semibold text-white">Order #{order.id}</h3>
<p className="text-sm text-gray-400">
{new Date(order.created_at).toLocaleDateString()}
</p>
@ -87,7 +86,7 @@ export function Orders() {
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<div className="p-4">
{order.items.map((item) => (
<div
@ -96,9 +95,7 @@ export function Orders() {
>
<div>
<p className="text-white font-medium">Product #{item.product_id}</p>
<p className="text-sm text-gray-400">
Quantity: {item.quantity}
</p>
<p className="text-sm text-gray-400">Quantity: {item.quantity}</p>
</div>
<p className="text-white font-bold">
${(item.price * item.quantity).toFixed(2)}
@ -106,18 +103,14 @@ export function Orders() {
</div>
))}
</div>
<div className="p-4 bg-gray-750 border-t border-gray-700 flex justify-between items-center">
<div className="text-sm text-gray-400">
{order.shipping_address && (
<span>Ship to: {order.shipping_address}</span>
)}
{order.shipping_address && <span>Ship to: {order.shipping_address}</span>}
</div>
<div className="text-xl">
<span className="text-gray-400">Total:</span>{' '}
<span className="text-white font-bold">
${order.total_amount}
</span>
<span className="text-white font-bold">${order.total_amount}</span>
</div>
</div>
</div>
@ -125,5 +118,5 @@ export function Orders() {
</div>
)}
</div>
)
}
);
}

View file

@ -1,10 +1,10 @@
import { useApp } from '../context/AppContext'
import { useProducts } from '../hooks/useProducts'
import { CartItem } from '../types'
import { useApp } from '../context/AppContext';
import { useProducts } from '../hooks/useProducts';
import { CartItem } from '../types';
export function Products() {
const { products, refetch } = useProducts()
const { addToCart } = useApp()
const { products, refetch } = useProducts();
const { addToCart } = useApp();
return (
<div>
@ -31,19 +31,11 @@ export function Products() {
/>
)}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">
{product.name}
</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">
{product.description}
</p>
<h3 className="text-lg font-semibold text-white mb-2">{product.name}</h3>
<p className="text-gray-400 text-sm mb-3 line-clamp-2">{product.description}</p>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-blue-400">
${product.price}
</span>
<span className="text-sm text-gray-400">
Stock: {product.stock}
</span>
<span className="text-xl font-bold text-blue-400">${product.price}</span>
<span className="text-sm text-gray-400">Stock: {product.stock}</span>
</div>
<button
onClick={() => {
@ -52,7 +44,7 @@ export function Products() {
name: product.name,
price: product.price,
quantity: 1,
image_url: product.image_url
image_url: product.image_url,
};
addToCart(cartItem);
}}
@ -70,5 +62,5 @@ export function Products() {
</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 { useNavigate, Link } from 'react-router-dom'
import { useApi } from '../hooks/useApi'
import { useState, FormEvent, ChangeEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useApi } from '../hooks/useApi';
interface FormData {
email: string
username: string
password: string
confirmPassword: string
first_name: string
last_name: string
email: string;
username: string;
password: string;
confirmPassword: string;
first_name: string;
last_name: string;
}
export function Register() {
@ -19,35 +19,35 @@ export function Register() {
confirmPassword: '',
first_name: '',
last_name: '',
})
const [error, setError] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const navigate = useNavigate()
const { register } = useApi()
});
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const navigate = useNavigate();
const { register } = useApi();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
});
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match')
return
setError('Passwords do not match');
return;
}
if (formData.password.length < 6) {
setError('Password must be at least 6 characters')
return
setError('Password must be at least 6 characters');
return;
}
setLoading(true)
setLoading(true);
try {
await register({
@ -56,26 +56,26 @@ export function Register() {
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
})
navigate('/login')
});
navigate('/login');
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.')
setError(err.response?.data?.error || 'Registration failed. Please try again.');
} finally {
setLoading(false)
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">
@ -90,7 +90,7 @@ export function Register() {
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
@ -105,7 +105,7 @@ export function Register() {
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
Username
@ -120,7 +120,7 @@ export function Register() {
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
@ -135,7 +135,7 @@ export function Register() {
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
@ -150,7 +150,7 @@ export function Register() {
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
@ -165,7 +165,7 @@ export function Register() {
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}
@ -174,7 +174,7 @@ export function Register() {
{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">
@ -182,5 +182,5 @@ export function Register() {
</Link>
</p>
</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',
}
)
)

View file

@ -1,4 +1,4 @@
export interface ApiResponse<T> {
data: T;
message?: string;
}
}

View file

@ -1,3 +1,3 @@
export interface ModalContentProps {
onClose: () => void;
}
}

View file

@ -21,4 +21,4 @@ export interface Order {
total_amount: number;
shipping_address: string;
items: OrderItem[];
}
}

View file

@ -23,4 +23,4 @@ export interface CartItem {
quantity: number;
image_url?: string;
[key: string]: any;
}
}

View file

@ -27,4 +27,4 @@ export interface RegisterData {
export interface AuthResponse {
token: string;
user: UserData;
}
}