diff --git a/.gitignore b/.gitignore index bf38fa5..a9f2576 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ backend/*.pyc backend/.pytest_cache/ backend/.vscode/ backend/instance/ +uploads/ # Frontend frontend/node_modules/ diff --git a/backend/.env.example b/backend/.env.example index a742e65..44eeb90 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,4 @@ -DATABASE_URL=postgresql://user:password@localhost:5432/ecommerce_db +DATABASE_URL=postgresql://ecommerce_user:password@localhost:5432/ecommerce_db JWT_SECRET_KEY=your-secret-key-here-change-this-in-production JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 diff --git a/backend/app/__pycache__/__init__.cpython-314.pyc b/backend/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..5c6f4bf Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/__pycache__/config.cpython-314.pyc b/backend/app/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..2593fb5 Binary files /dev/null and b/backend/app/__pycache__/config.cpython-314.pyc differ diff --git a/backend/app/__pycache__/main.cpython-314.pyc b/backend/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..6d50a86 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-314.pyc differ diff --git a/backend/app/__pycache__/utils.cpython-314.pyc b/backend/app/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..47b6246 Binary files /dev/null and b/backend/app/__pycache__/utils.cpython-314.pyc differ diff --git a/backend/app/config.py b/backend/app/config.py index c8b6539..8ea8a61 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ from pydantic_settings import BaseSettings +from pathlib import Path class Settings(BaseSettings): @@ -9,7 +10,7 @@ class Settings(BaseSettings): frontend_url: str = "http://localhost:5173" class Config: - env_file = ".env" + env_file = str(Path(__file__).parent.parent / ".env") settings = Settings() diff --git a/backend/app/database/__pycache__/__init__.cpython-314.pyc b/backend/app/database/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..fe7c06f Binary files /dev/null and b/backend/app/database/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/database/__pycache__/database.cpython-314.pyc b/backend/app/database/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000..38fb7ba Binary files /dev/null and b/backend/app/database/__pycache__/database.cpython-314.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index acb583f..aabb977 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,23 @@ +import sys +from pathlib import Path + +# Add backend directory to Python path +backend_dir = Path(__file__).resolve().parent.parent +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +import uvicorn from app.database.database import engine, Base from app.config import settings -from app.routers import auth, users, products, categories, cart, orders, wishlist, contact - +from app.routers import auth, users, products, categories, cart, orders, wishlist, contact, models, brands + +# Create uploads directory if it doesn't exist +uploads_dir = Path("uploads") +uploads_dir.mkdir(exist_ok=True) + # Create tables Base.metadata.create_all(bind=engine) @@ -27,11 +41,16 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(products.router) app.include_router(categories.router) +app.include_router(models.router) +app.include_router(brands.router) app.include_router(cart.router) app.include_router(orders.router) app.include_router(wishlist.router) app.include_router(contact.router) +# Mount static files for uploads +app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") + @app.get("/") def read_root(): @@ -45,3 +64,7 @@ def read_root(): @app.get("/health") def health_check(): return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 16c3ceb..5152d9c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from .user import User from .product import Product from .category import Category +from .model import Model from .cart import Cart, CartItem from .order import Order, OrderItem from .wishlist import Wishlist @@ -10,6 +11,7 @@ __all__ = [ "User", "Product", "Category", + "Model", "Cart", "CartItem", "Order", diff --git a/backend/app/models/__pycache__/__init__.cpython-314.pyc b/backend/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..c569d7b Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/brand.cpython-314.pyc b/backend/app/models/__pycache__/brand.cpython-314.pyc new file mode 100644 index 0000000..acee02e Binary files /dev/null and b/backend/app/models/__pycache__/brand.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/cart.cpython-314.pyc b/backend/app/models/__pycache__/cart.cpython-314.pyc new file mode 100644 index 0000000..0310ab8 Binary files /dev/null and b/backend/app/models/__pycache__/cart.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/category.cpython-314.pyc b/backend/app/models/__pycache__/category.cpython-314.pyc new file mode 100644 index 0000000..ccd0420 Binary files /dev/null and b/backend/app/models/__pycache__/category.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/contact_message.cpython-314.pyc b/backend/app/models/__pycache__/contact_message.cpython-314.pyc new file mode 100644 index 0000000..fe230bc Binary files /dev/null and b/backend/app/models/__pycache__/contact_message.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/model.cpython-314.pyc b/backend/app/models/__pycache__/model.cpython-314.pyc new file mode 100644 index 0000000..0632483 Binary files /dev/null and b/backend/app/models/__pycache__/model.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/order.cpython-314.pyc b/backend/app/models/__pycache__/order.cpython-314.pyc new file mode 100644 index 0000000..979fb28 Binary files /dev/null and b/backend/app/models/__pycache__/order.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/product.cpython-314.pyc b/backend/app/models/__pycache__/product.cpython-314.pyc new file mode 100644 index 0000000..f6cedc7 Binary files /dev/null and b/backend/app/models/__pycache__/product.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/user.cpython-314.pyc b/backend/app/models/__pycache__/user.cpython-314.pyc new file mode 100644 index 0000000..50d2931 Binary files /dev/null and b/backend/app/models/__pycache__/user.cpython-314.pyc differ diff --git a/backend/app/models/__pycache__/wishlist.cpython-314.pyc b/backend/app/models/__pycache__/wishlist.cpython-314.pyc new file mode 100644 index 0000000..57e090a Binary files /dev/null and b/backend/app/models/__pycache__/wishlist.cpython-314.pyc differ diff --git a/backend/app/models/brand.py b/backend/app/models/brand.py new file mode 100644 index 0000000..8006783 --- /dev/null +++ b/backend/app/models/brand.py @@ -0,0 +1,8 @@ +from sqlalchemy import Column, Integer, String +from ..database.database import Base + +class Brand(Base): + __tablename__ = "brand" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, nullable=False, index=True) diff --git a/backend/app/models/category.py b/backend/app/models/category.py index 73ce1ef..32ae02d 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -9,3 +9,4 @@ class Category(Base): name = Column(String, unique=True, index=True) slug = Column(String, unique=True, index=True) description = Column(String, nullable=True) + image = Column(String, nullable=True) diff --git a/backend/app/models/model.py b/backend/app/models/model.py new file mode 100644 index 0000000..05f1aa7 --- /dev/null +++ b/backend/app/models/model.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, DECIMAL, ForeignKey, TIMESTAMP, JSON +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database.database import Base + + +class Model(Base): + __tablename__ = "model" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + category_id = Column(Integer, ForeignKey("category.id", ondelete="CASCADE"), nullable=False) + brand = Column(String(100), nullable=False) + base_price = Column(DECIMAL(10, 2), nullable=True) + sizes = Column(JSON, nullable=True) + description = Column(String, nullable=True) + created_at = Column(TIMESTAMP, default=datetime.utcnow) + + # Relationships + category = relationship("Category", backref="models") + products = relationship("Product", back_populates="model") diff --git a/backend/app/models/product.py b/backend/app/models/product.py index fd79036..e068d9a 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey, JSON +from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, ForeignKey, JSON, DECIMAL from sqlalchemy.orm import relationship from datetime import datetime from app.database.database import Base @@ -9,10 +9,12 @@ class Product(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True) + slug = Column(String, unique=True, index=True, nullable=True) description = Column(Text) price = Column(Float) discount_price = Column(Float, nullable=True) category_id = Column(Integer, ForeignKey("category.id")) + model_id = Column(Integer, ForeignKey("model.id", ondelete="SET NULL"), nullable=True) gender = Column(String) # men, women brand = Column(String) sizes = Column(JSON) # ["S", "M", "L", "XL", ...] @@ -21,8 +23,11 @@ class Product(Base): images = Column(JSON) # Array of image URLs is_featured = Column(Boolean, default=False) is_on_sale = Column(Boolean, default=False) + override_price = Column(DECIMAL(10, 2), nullable=True) + override_sizes = Column(JSON, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) category = relationship("Category") + model = relationship("Model", back_populates="products") cart_items = relationship("CartItem", back_populates="product") order_items = relationship("OrderItem", back_populates="product") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2678d2e..2d0c403 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -24,6 +24,7 @@ class User(Base): postal_code = Column(String, nullable=True) country = Column(String, nullable=True) is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) cart = relationship("Cart", back_populates="user", uselist=False) diff --git a/backend/app/routers/__pycache__/__init__.cpython-314.pyc b/backend/app/routers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..bf08c63 Binary files /dev/null and b/backend/app/routers/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/auth.cpython-314.pyc b/backend/app/routers/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000..4fe2a58 Binary files /dev/null and b/backend/app/routers/__pycache__/auth.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/brands.cpython-314.pyc b/backend/app/routers/__pycache__/brands.cpython-314.pyc new file mode 100644 index 0000000..27900a3 Binary files /dev/null and b/backend/app/routers/__pycache__/brands.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/cart.cpython-314.pyc b/backend/app/routers/__pycache__/cart.cpython-314.pyc new file mode 100644 index 0000000..c29d2bc Binary files /dev/null and b/backend/app/routers/__pycache__/cart.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/categories.cpython-314.pyc b/backend/app/routers/__pycache__/categories.cpython-314.pyc new file mode 100644 index 0000000..dc2d0e9 Binary files /dev/null and b/backend/app/routers/__pycache__/categories.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/contact.cpython-314.pyc b/backend/app/routers/__pycache__/contact.cpython-314.pyc new file mode 100644 index 0000000..906e587 Binary files /dev/null and b/backend/app/routers/__pycache__/contact.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/models.cpython-314.pyc b/backend/app/routers/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..74556f0 Binary files /dev/null and b/backend/app/routers/__pycache__/models.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/orders.cpython-314.pyc b/backend/app/routers/__pycache__/orders.cpython-314.pyc new file mode 100644 index 0000000..057319b Binary files /dev/null and b/backend/app/routers/__pycache__/orders.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/products.cpython-314.pyc b/backend/app/routers/__pycache__/products.cpython-314.pyc new file mode 100644 index 0000000..ad066b6 Binary files /dev/null and b/backend/app/routers/__pycache__/products.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/users.cpython-314.pyc b/backend/app/routers/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000..73554f4 Binary files /dev/null and b/backend/app/routers/__pycache__/users.cpython-314.pyc differ diff --git a/backend/app/routers/__pycache__/wishlist.cpython-314.pyc b/backend/app/routers/__pycache__/wishlist.cpython-314.pyc new file mode 100644 index 0000000..65e3836 Binary files /dev/null and b/backend/app/routers/__pycache__/wishlist.cpython-314.pyc differ diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 8aebd1f..a70ab97 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -47,7 +47,7 @@ def login(email: str, password: str, db: Session = Depends(get_db)): access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) access_token = create_access_token( - data={"sub": user.id}, expires_delta=access_token_expires + data={"sub": str(user.id)}, expires_delta=access_token_expires ) return { diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py new file mode 100644 index 0000000..c8a9314 --- /dev/null +++ b/backend/app/routers/brands.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from ..database.database import get_db +from ..models.brand import Brand +from ..models.user import User +from ..schemas.brand import BrandCreate, BrandUpdate, BrandResponse +from ..services.auth import get_current_user + +router = APIRouter(prefix="/api/brands", tags=["brands"]) + +@router.get("", response_model=List[BrandResponse]) +def list_brands(db: Session = Depends(get_db)): + """Get all brands""" + brands = db.query(Brand).order_by(Brand.name).all() + return brands + +@router.get("/{brand_id}", response_model=BrandResponse) +def get_brand(brand_id: int, db: Session = Depends(get_db)): + """Get a specific brand""" + brand = db.query(Brand).filter(Brand.id == brand_id).first() + if not brand: + raise HTTPException(status_code=404, detail="Brand not found") + return brand + +@router.post("", response_model=BrandResponse) +def create_brand( + brand_data: BrandCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Create a new brand (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + # Check if brand already exists + existing = db.query(Brand).filter(Brand.name == brand_data.name).first() + if existing: + raise HTTPException(status_code=400, detail="Brand already exists") + + brand = Brand(name=brand_data.name) + db.add(brand) + db.commit() + db.refresh(brand) + return brand + +@router.put("/{brand_id}", response_model=BrandResponse) +def update_brand( + brand_id: int, + brand_data: BrandUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update a brand (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + brand = db.query(Brand).filter(Brand.id == brand_id).first() + if not brand: + raise HTTPException(status_code=404, detail="Brand not found") + + # Check if new name conflicts with existing brand + if brand_data.name != brand.name: + existing = db.query(Brand).filter(Brand.name == brand_data.name).first() + if existing: + raise HTTPException(status_code=400, detail="Brand name already exists") + + brand.name = brand_data.name + db.commit() + db.refresh(brand) + return brand + +@router.delete("/{brand_id}") +def delete_brand( + brand_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Delete a brand (admin only)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + + brand = db.query(Brand).filter(Brand.id == brand_id).first() + if not brand: + raise HTTPException(status_code=404, detail="Brand not found") + + db.delete(brand) + db.commit() + return {"message": "Brand deleted successfully"} diff --git a/backend/app/routers/cart.py b/backend/app/routers/cart.py index 4ab6d38..66f6b95 100644 --- a/backend/app/routers/cart.py +++ b/backend/app/routers/cart.py @@ -9,42 +9,40 @@ from app.services.cart import ( remove_from_cart, clear_cart, ) -from app.services.auth import verify_token +from app.services.auth import get_current_user +from app.models import User router = APIRouter(prefix="/api/cart", tags=["cart"]) -def get_user_id_from_token(token: str) -> int: - user_id = verify_token(token) - if user_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - return user_id - - @router.get("", response_model=CartResponse) -def get_user_cart(token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - cart = get_cart(db, user_id) +def get_user_cart( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + cart = get_cart(db, current_user.id) if not cart: raise HTTPException(status_code=404, detail="Cart not found") return cart @router.post("/add", response_model=dict) -def add_item_to_cart(token: str, item: CartItemCreate, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - cart_item = add_to_cart(db, user_id, item) +def add_item_to_cart( + item: CartItemCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + cart_item = add_to_cart(db, current_user.id, item) return {"message": "Item added to cart", "item_id": cart_item.id} @router.put("/{cart_item_id}", response_model=dict) def update_item( - cart_item_id: int, token: str, update: CartItemUpdate, db: Session = Depends(get_db) + cart_item_id: int, + update: CartItemUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), ): - user_id = get_user_id_from_token(token) cart_item = update_cart_item(db, cart_item_id, update) if not cart_item: raise HTTPException(status_code=404, detail="Cart item not found") @@ -52,16 +50,21 @@ def update_item( @router.delete("/{cart_item_id}") -def remove_item(cart_item_id: int, token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) +def remove_item( + cart_item_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): if not remove_from_cart(db, cart_item_id): raise HTTPException(status_code=404, detail="Cart item not found") return {"message": "Item removed from cart"} @router.delete("") -def clear_user_cart(token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - if not clear_cart(db, user_id): +def clear_user_cart( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not clear_cart(db, current_user.id): raise HTTPException(status_code=404, detail="Cart not found") return {"message": "Cart cleared"} diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index 898f384..ef8cb73 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from typing import List from app.database.database import get_db -from app.models import Category +from app.models import Category, User from app.schemas.category import CategoryCreate, CategoryResponse, CategoryUpdate +from app.services.auth import get_current_admin_user router = APIRouter(prefix="/api/categories", tags=["categories"]) @@ -22,8 +23,11 @@ def get_category(category_id: int, db: Session = Depends(get_db)): @router.post("", response_model=CategoryResponse) -def create_category(category: CategoryCreate, db: Session = Depends(get_db)): - # TODO: Add admin check +def create_category( + category: CategoryCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) +): db_category = Category(**category.dict()) db.add(db_category) db.commit() @@ -33,9 +37,11 @@ def create_category(category: CategoryCreate, db: Session = Depends(get_db)): @router.put("/{category_id}", response_model=CategoryResponse) def update_category( - category_id: int, category_update: CategoryUpdate, db: Session = Depends(get_db) + category_id: int, + category_update: CategoryUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) ): - # TODO: Add admin check category = db.query(Category).filter(Category.id == category_id).first() if not category: raise HTTPException(status_code=404, detail="Category not found") @@ -49,8 +55,11 @@ def update_category( @router.delete("/{category_id}") -def delete_category(category_id: int, db: Session = Depends(get_db)): - # TODO: Add admin check +def delete_category( + category_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) +): category = db.query(Category).filter(Category.id == category_id).first() if not category: raise HTTPException(status_code=404, detail="Category not found") diff --git a/backend/app/routers/models.py b/backend/app/routers/models.py new file mode 100644 index 0000000..6ee78d4 --- /dev/null +++ b/backend/app/routers/models.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from app.database.database import get_db +from app.models import Model, User +from app.schemas.model import ModelCreate, ModelUpdate, ModelResponse +from app.services.auth import get_current_admin_user + +router = APIRouter(prefix="/api/models", tags=["models"]) + + +@router.get("", response_model=List[ModelResponse]) +def get_models( + category_id: Optional[int] = None, + brand: Optional[str] = None, + db: Session = Depends(get_db) +): + """Get all models with optional filtering by category and brand""" + query = db.query(Model) + + if category_id: + query = query.filter(Model.category_id == category_id) + if brand: + query = query.filter(Model.brand.ilike(f"%{brand}%")) + + return query.all() + + +@router.get("/{model_id}", response_model=ModelResponse) +def get_model(model_id: int, db: Session = Depends(get_db)): + """Get a specific model by ID""" + model = db.query(Model).filter(Model.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + return model + + +@router.post("", response_model=ModelResponse) +def create_model( + model_data: ModelCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) +): + """Create a new model (admin only)""" + # Check if model with same name, category, and brand already exists + existing = db.query(Model).filter( + Model.name == model_data.name, + Model.category_id == model_data.category_id, + Model.brand == model_data.brand + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail=f"Model '{model_data.name}' already exists for brand '{model_data.brand}' in this category" + ) + + db_model = Model(**model_data.dict()) + db.add(db_model) + db.commit() + db.refresh(db_model) + return db_model + + +@router.put("/{model_id}", response_model=ModelResponse) +def update_model( + model_id: int, + model_update: ModelUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) +): + """Update a model (admin only)""" + model = db.query(Model).filter(Model.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + + for field, value in model_update.dict(exclude_unset=True).items(): + setattr(model, field, value) + + db.commit() + db.refresh(model) + return model + + +@router.delete("/{model_id}") +def delete_model( + model_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user) +): + """Delete a model (admin only)""" + model = db.query(Model).filter(Model.id == model_id).first() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + + db.delete(model) + db.commit() + return {"message": "Model deleted successfully"} + + +@router.get("/brands/list") +def get_brands(db: Session = Depends(get_db)): + """Get list of unique brands""" + brands = db.query(Model.brand).distinct().all() + return [brand[0] for brand in brands if brand[0]] diff --git a/backend/app/routers/orders.py b/backend/app/routers/orders.py index a3de381..446ed80 100644 --- a/backend/app/routers/orders.py +++ b/backend/app/routers/orders.py @@ -9,25 +9,19 @@ from app.services.order import ( get_user_orders, update_order_status, ) -from app.services.auth import verify_token +from app.services.auth import get_current_user +from app.models import User router = APIRouter(prefix="/api/orders", tags=["orders"]) -def get_user_id_from_token(token: str) -> int: - user_id = verify_token(token) - if user_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - return user_id - - @router.post("", response_model=OrderResponse) -def create_new_order(token: str, order_data: OrderCreate, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - order = create_order(db, user_id, order_data) +def create_new_order( + order_data: OrderCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + order = create_order(db, current_user.id, order_data) if not order: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -36,16 +30,21 @@ def create_new_order(token: str, order_data: OrderCreate, db: Session = Depends( return order -@router.get("/user/orders", response_model=List[OrderResponse]) -def get_user_order_history(token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - return get_user_orders(db, user_id) +@router.get("", response_model=List[OrderResponse]) +def get_user_order_history( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + return get_user_orders(db, current_user.id) @router.get("/{order_id}", response_model=OrderResponse) -def get_order(order_id: int, token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) +def get_order( + order_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): order = get_order_by_id(db, order_id) - if not order or order.user_id != user_id: + if not order or order.user_id != current_user.id: raise HTTPException(status_code=404, detail="Order not found") return order diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 1915c1b..db359bc 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -1,8 +1,10 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from typing import List, Optional +from pathlib import Path from app.database.database import get_db -from app.models import Product, Category +from app.models import Product, Category, User from app.schemas.product import ( ProductCreate, ProductResponse, @@ -16,6 +18,8 @@ from app.services.product import ( delete_product, search_products, ) +from app.services.auth import get_current_admin_user +from app.utils import save_upload_file, generate_slug router = APIRouter(prefix="/api/products", tags=["products"]) @@ -23,22 +27,37 @@ router = APIRouter(prefix="/api/products", tags=["products"]) @router.get("", response_model=List[ProductResponse]) def list_products( category_id: Optional[int] = None, + model_id: Optional[int] = None, + brand: Optional[str] = None, gender: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, on_sale: Optional[bool] = None, featured: Optional[bool] = None, skip: int = 0, limit: int = 20, db: Session = Depends(get_db), ): - return get_products( - db, - category_id=category_id, - gender=gender, - on_sale=on_sale, - featured=featured, - skip=skip, - limit=limit, - ) + query = db.query(Product) + + if category_id: + query = query.filter(Product.category_id == category_id) + if model_id: + query = query.filter(Product.model_id == model_id) + if brand: + query = query.filter(Product.brand.ilike(f"%{brand}%")) + if gender: + query = query.filter(Product.gender == gender) + if min_price is not None: + query = query.filter(Product.price >= min_price) + if max_price is not None: + query = query.filter(Product.price <= max_price) + if on_sale is not None: + query = query.filter(Product.is_on_sale == on_sale) + if featured is not None: + query = query.filter(Product.is_featured == featured) + + return query.offset(skip).limit(limit).all() @router.get("/search", response_model=List[ProductResponse]) @@ -55,16 +74,32 @@ def get_product(product_id: int, db: Session = Depends(get_db)): @router.post("", response_model=ProductResponse) -def create_new_product(product: ProductCreate, db: Session = Depends(get_db)): - # TODO: Add admin check +def create_new_product( + product: ProductCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user), +): + # Auto-generate slug if not provided + if not product.slug: + base_slug = generate_slug(f"{product.brand} {product.name}") + # Check if slug exists and make it unique + slug = base_slug + counter = 1 + while db.query(Product).filter(Product.slug == slug).first(): + slug = f"{base_slug}-{counter}" + counter += 1 + product.slug = slug + return create_product(db, product) @router.put("/{product_id}", response_model=ProductResponse) def update_existing_product( - product_id: int, product_update: ProductUpdate, db: Session = Depends(get_db) + product_id: int, + product_update: ProductUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user), ): - # TODO: Add admin check product = update_product(db, product_id, product_update) if not product: raise HTTPException(status_code=404, detail="Product not found") @@ -72,8 +107,82 @@ def update_existing_product( @router.delete("/{product_id}") -def delete_existing_product(product_id: int, db: Session = Depends(get_db)): - # TODO: Add admin check +def delete_existing_product( + product_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_current_admin_user), +): if not delete_product(db, product_id): raise HTTPException(status_code=404, detail="Product not found") return {"message": "Product deleted successfully"} + + +@router.post("/upload-image") +async def upload_product_image( + file: UploadFile = File(...), + admin: User = Depends(get_current_admin_user), +): + """Upload a product image and return the URL""" + # Validate file type + allowed_types = ["image/jpeg", "image/png", "image/jpg", "image/webp", "image/gif"] + if file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"File type {file.content_type} not allowed. Allowed types: {', '.join(allowed_types)}" + ) + + try: + # Save file and get path + file_path = save_upload_file(file, folder="products") + # Return full URL + return {"url": file_path, "message": "Image uploaded successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}") + + +@router.post("/upload-images") +async def upload_multiple_images( + files: List[UploadFile] = File(...), + admin: User = Depends(get_current_admin_user), +): + """Upload multiple product images and return the URLs""" + allowed_types = ["image/jpeg", "image/png", "image/jpg", "image/webp", "image/gif"] + uploaded_urls = [] + errors = [] + + for file in files: + if file.content_type not in allowed_types: + errors.append(f"File {file.filename}: type {file.content_type} not allowed") + continue + + try: + file_path = save_upload_file(file, folder="products") + uploaded_urls.append(file_path) + except Exception as e: + errors.append(f"File {file.filename}: {str(e)}") + + return { + "urls": uploaded_urls, + "uploaded_count": len(uploaded_urls), + "errors": errors if errors else None, + "message": f"Successfully uploaded {len(uploaded_urls)} of {len(files)} images" + } + + +@router.get("/filters/brands") +def get_brands(db: Session = Depends(get_db)): + """Get list of unique brands from products""" + brands = db.query(Product.brand).distinct().filter(Product.brand.isnot(None)).all() + return [brand[0] for brand in brands if brand[0]] + + +@router.get("/filters/sizes") +def get_sizes(db: Session = Depends(get_db)): + """Get list of unique sizes from products""" + products = db.query(Product.sizes).filter(Product.sizes.isnot(None)).all() + sizes_set = set() + for product in products: + if product.sizes: + for size in product.sizes: + sizes_set.add(size) + return sorted(list(sizes_set)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 8a8cd64..6f678b2 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -3,33 +3,30 @@ from sqlalchemy.orm import Session from app.database.database import get_db from app.models import User from app.schemas.user import UserResponse, UserUpdate -from app.services.auth import verify_token +from app.services.auth import get_current_user router = APIRouter(prefix="/api/users", tags=["users"]) -def get_current_user(token: str, db: Session = Depends(get_db)) -> User: - user_id = verify_token(token) - if user_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - user = db.query(User).filter(User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - return user - - @router.get("/me", response_model=UserResponse) -def get_current_user_profile(token: str, db: Session = Depends(get_db)): - user = get_current_user(token, db) - return user +def get_current_user_profile( + current_user: User = Depends(get_current_user), +): + return current_user @router.put("/me", response_model=UserResponse) -def update_user_profile(token: str, user_update: UserUpdate, db: Session = Depends(get_db)): - user = get_current_user(token, db) +def update_user_profile( + user_update: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + for field, value in user_update.dict(exclude_unset=True).items(): + setattr(current_user, field, value) + + db.commit() + db.refresh(current_user) + return current_user for field, value in user_update.dict(exclude_unset=True).items(): setattr(user, field, value) diff --git a/backend/app/routers/wishlist.py b/backend/app/routers/wishlist.py index 7d18cb5..a4fcd6f 100644 --- a/backend/app/routers/wishlist.py +++ b/backend/app/routers/wishlist.py @@ -2,28 +2,20 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List from app.database.database import get_db -from app.models import Wishlist, Product +from app.models import Wishlist, Product, User from app.schemas.product import ProductResponse -from app.services.auth import verify_token +from app.services.auth import get_current_user router = APIRouter(prefix="/api/wishlist", tags=["wishlist"]) -def get_user_id_from_token(token: str) -> int: - user_id = verify_token(token) - if user_id is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - return user_id - - @router.get("", response_model=List[ProductResponse]) -def get_wishlist(token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) +def get_wishlist( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): wishlist_items = ( - db.query(Wishlist).filter(Wishlist.user_id == user_id).all() + db.query(Wishlist).filter(Wishlist.user_id == current_user.id).all() ) products = [ db.query(Product).filter(Product.id == item.product_id).first() @@ -33,12 +25,14 @@ def get_wishlist(token: str, db: Session = Depends(get_db)): @router.post("/{product_id}") -def add_to_wishlist(product_id: int, token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - +def add_to_wishlist( + product_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): existing = ( db.query(Wishlist) - .filter(Wishlist.user_id == user_id, Wishlist.product_id == product_id) + .filter(Wishlist.user_id == current_user.id, Wishlist.product_id == product_id) .first() ) if existing: @@ -47,19 +41,21 @@ def add_to_wishlist(product_id: int, token: str, db: Session = Depends(get_db)): detail="Product already in wishlist", ) - wishlist_item = Wishlist(user_id=user_id, product_id=product_id) + wishlist_item = Wishlist(user_id=current_user.id, product_id=product_id) db.add(wishlist_item) db.commit() return {"message": "Product added to wishlist"} @router.delete("/{product_id}") -def remove_from_wishlist(product_id: int, token: str, db: Session = Depends(get_db)): - user_id = get_user_id_from_token(token) - +def remove_from_wishlist( + product_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): wishlist_item = ( db.query(Wishlist) - .filter(Wishlist.user_id == user_id, Wishlist.product_id == product_id) + .filter(Wishlist.user_id == current_user.id, Wishlist.product_id == product_id) .first() ) if not wishlist_item: diff --git a/backend/app/schemas/__pycache__/__init__.cpython-314.pyc b/backend/app/schemas/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..c9ee5d2 Binary files /dev/null and b/backend/app/schemas/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/brand.cpython-314.pyc b/backend/app/schemas/__pycache__/brand.cpython-314.pyc new file mode 100644 index 0000000..dd60bf0 Binary files /dev/null and b/backend/app/schemas/__pycache__/brand.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/cart.cpython-314.pyc b/backend/app/schemas/__pycache__/cart.cpython-314.pyc new file mode 100644 index 0000000..589f677 Binary files /dev/null and b/backend/app/schemas/__pycache__/cart.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/category.cpython-314.pyc b/backend/app/schemas/__pycache__/category.cpython-314.pyc new file mode 100644 index 0000000..b57743d Binary files /dev/null and b/backend/app/schemas/__pycache__/category.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/contact.cpython-314.pyc b/backend/app/schemas/__pycache__/contact.cpython-314.pyc new file mode 100644 index 0000000..3e6531e Binary files /dev/null and b/backend/app/schemas/__pycache__/contact.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/model.cpython-314.pyc b/backend/app/schemas/__pycache__/model.cpython-314.pyc new file mode 100644 index 0000000..eee4462 Binary files /dev/null and b/backend/app/schemas/__pycache__/model.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/order.cpython-314.pyc b/backend/app/schemas/__pycache__/order.cpython-314.pyc new file mode 100644 index 0000000..83b6f44 Binary files /dev/null and b/backend/app/schemas/__pycache__/order.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/product.cpython-314.pyc b/backend/app/schemas/__pycache__/product.cpython-314.pyc new file mode 100644 index 0000000..e8e6a81 Binary files /dev/null and b/backend/app/schemas/__pycache__/product.cpython-314.pyc differ diff --git a/backend/app/schemas/__pycache__/user.cpython-314.pyc b/backend/app/schemas/__pycache__/user.cpython-314.pyc new file mode 100644 index 0000000..01355a0 Binary files /dev/null and b/backend/app/schemas/__pycache__/user.cpython-314.pyc differ diff --git a/backend/app/schemas/brand.py b/backend/app/schemas/brand.py new file mode 100644 index 0000000..134e7b3 --- /dev/null +++ b/backend/app/schemas/brand.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +class BrandBase(BaseModel): + name: str + +class BrandCreate(BrandBase): + pass + +class BrandUpdate(BrandBase): + pass + +class BrandResponse(BrandBase): + id: int + + class Config: + from_attributes = True diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py index a354ebb..99b2c56 100644 --- a/backend/app/schemas/category.py +++ b/backend/app/schemas/category.py @@ -7,11 +7,13 @@ class CategoryCreate(BaseModel): name: str slug: str description: Optional[str] = None + image: Optional[str] = None class CategoryUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None + image: Optional[str] = None class CategoryResponse(BaseModel): @@ -19,6 +21,7 @@ class CategoryResponse(BaseModel): name: str slug: str description: Optional[str] + image: Optional[str] class Config: from_attributes = True diff --git a/backend/app/schemas/model.py b/backend/app/schemas/model.py new file mode 100644 index 0000000..722f59d --- /dev/null +++ b/backend/app/schemas/model.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from decimal import Decimal + + +class ModelCreate(BaseModel): + name: str + category_id: int + brand: str + base_price: Optional[Decimal] = None + sizes: Optional[List[str]] = [] + description: Optional[str] = None + + +class ModelUpdate(BaseModel): + name: Optional[str] = None + category_id: Optional[int] = None + brand: Optional[str] = None + base_price: Optional[Decimal] = None + sizes: Optional[List[str]] = None + description: Optional[str] = None + + +class ModelResponse(BaseModel): + id: int + name: str + category_id: int + brand: str + base_price: Optional[Decimal] + sizes: Optional[List[str]] + description: Optional[str] + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index 7b65d74..6015b63 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -1,30 +1,37 @@ from pydantic import BaseModel from typing import Optional, List from datetime import datetime +from decimal import Decimal class ProductCreate(BaseModel): name: str + slug: Optional[str] = None description: str price: float discount_price: Optional[float] = None category_id: int + model_id: Optional[int] = None gender: str # men, women brand: str sizes: List[str] - colors: List[str] + colors: Optional[List[str]] = [] stock: int images: List[str] is_featured: bool = False is_on_sale: bool = False + override_price: Optional[Decimal] = None + override_sizes: Optional[List[str]] = None class ProductUpdate(BaseModel): name: Optional[str] = None + slug: Optional[str] = None description: Optional[str] = None price: Optional[float] = None discount_price: Optional[float] = None category_id: Optional[int] = None + model_id: Optional[int] = None gender: Optional[str] = None brand: Optional[str] = None sizes: Optional[List[str]] = None @@ -33,23 +40,29 @@ class ProductUpdate(BaseModel): images: Optional[List[str]] = None is_featured: Optional[bool] = None is_on_sale: Optional[bool] = None + override_price: Optional[Decimal] = None + override_sizes: Optional[List[str]] = None class ProductResponse(BaseModel): id: int name: str + slug: Optional[str] description: str price: float discount_price: Optional[float] category_id: int + model_id: Optional[int] gender: str brand: str sizes: List[str] - colors: List[str] + colors: Optional[List[str]] stock: int images: List[str] is_featured: bool is_on_sale: bool + override_price: Optional[Decimal] + override_sizes: Optional[List[str]] created_at: datetime class Config: diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 16c8a33..1ddc7f5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -29,6 +29,7 @@ class UserResponse(UserBase): postal_code: Optional[str] country: Optional[str] is_active: bool + is_admin: bool created_at: datetime class Config: diff --git a/backend/app/services/__pycache__/__init__.cpython-314.pyc b/backend/app/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..07afa10 Binary files /dev/null and b/backend/app/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/backend/app/services/__pycache__/auth.cpython-314.pyc b/backend/app/services/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000..0bfafeb Binary files /dev/null and b/backend/app/services/__pycache__/auth.cpython-314.pyc differ diff --git a/backend/app/services/__pycache__/cart.cpython-314.pyc b/backend/app/services/__pycache__/cart.cpython-314.pyc new file mode 100644 index 0000000..038f8c2 Binary files /dev/null and b/backend/app/services/__pycache__/cart.cpython-314.pyc differ diff --git a/backend/app/services/__pycache__/order.cpython-314.pyc b/backend/app/services/__pycache__/order.cpython-314.pyc new file mode 100644 index 0000000..f6681f4 Binary files /dev/null and b/backend/app/services/__pycache__/order.cpython-314.pyc differ diff --git a/backend/app/services/__pycache__/product.cpython-314.pyc b/backend/app/services/__pycache__/product.cpython-314.pyc new file mode 100644 index 0000000..885da87 Binary files /dev/null and b/backend/app/services/__pycache__/product.cpython-314.pyc differ diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index a5db368..74743e5 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -2,11 +2,15 @@ from datetime import datetime, timedelta from typing import Optional from passlib.context import CryptContext from jose import JWTError, jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from app.config import settings from app.models import User from sqlalchemy.orm import Session +from app.database.database import get_db pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() def verify_password(plain_password: str, hashed_password: str) -> bool: @@ -37,11 +41,17 @@ def verify_token(token: str) -> Optional[int]: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] ) - user_id: int = payload.get("sub") - if user_id is None: + user_id_str: str = payload.get("sub") + if user_id_str is None: return None + # Convert string to integer + user_id = int(user_id_str) return user_id - except JWTError: + except JWTError as e: + return None + except (ValueError, TypeError) as e: + return None + except Exception as e: return None @@ -50,3 +60,32 @@ def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: if not user or not verify_password(password, user.hashed_password): return None return user + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + token = credentials.credentials + user_id = verify_token(token) + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + ) + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + return user + + +def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + return current_user diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..c8f0f41 --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,32 @@ +import re +import os +import uuid +from pathlib import Path + +def generate_slug(text: str) -> str: + """Generate a URL-friendly slug from text""" + # Convert to lowercase + slug = text.lower() + # Replace spaces and special characters with hyphens + slug = re.sub(r'[^\w\s-]', '', slug) + slug = re.sub(r'[\s_-]+', '-', slug) + slug = re.sub(r'^-+|-+$', '', slug) + return slug + +def save_upload_file(upload_file, folder: str = "products") -> str: + """Save uploaded file and return the file path""" + # Create uploads directory if it doesn't exist + upload_dir = Path("uploads") / folder + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = os.path.splitext(upload_file.filename)[1] + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = upload_dir / unique_filename + + # Save file + with open(file_path, "wb") as buffer: + buffer.write(upload_file.file.read()) + + # Return relative path for URL + return f"/uploads/{folder}/{unique_filename}" diff --git a/backend/migrations/001_add_model_table.sql b/backend/migrations/001_add_model_table.sql new file mode 100644 index 0000000..a026d85 --- /dev/null +++ b/backend/migrations/001_add_model_table.sql @@ -0,0 +1,24 @@ +-- Migration: Add model table and update product table +-- Create model table +CREATE TABLE IF NOT EXISTS model ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + category_id INTEGER NOT NULL, + brand VARCHAR(100) NOT NULL, + base_price DECIMAL(10, 2), + sizes JSONB, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE, + UNIQUE (name, category_id, brand) +); + +CREATE INDEX IF NOT EXISTS idx_model_category ON model(category_id); +CREATE INDEX IF NOT EXISTS idx_model_brand ON model(brand); + +-- Add new columns to product table +ALTER TABLE product ADD COLUMN IF NOT EXISTS model_id INTEGER REFERENCES model(id) ON DELETE SET NULL; +ALTER TABLE product ADD COLUMN IF NOT EXISTS override_price DECIMAL(10, 2); +ALTER TABLE product ADD COLUMN IF NOT EXISTS override_sizes JSONB; + +CREATE INDEX IF NOT EXISTS idx_product_model ON product(model_id); diff --git a/backend/migrations/002_add_brands_table.sql b/backend/migrations/002_add_brands_table.sql new file mode 100644 index 0000000..191e105 --- /dev/null +++ b/backend/migrations/002_add_brands_table.sql @@ -0,0 +1,24 @@ +-- Migration: Add brands table +-- Date: 2026-05-01 +-- Description: Create brands table for storing brand names + +CREATE TABLE IF NOT EXISTS brand ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE +); + +CREATE INDEX IF NOT EXISTS idx_brand_name ON brand(name); + +-- Insert existing brands from products +INSERT INTO brand (name) +SELECT DISTINCT brand +FROM product +WHERE brand IS NOT NULL AND brand != '' +ON CONFLICT (name) DO NOTHING; + +-- Insert existing brands from model table +INSERT INTO brand (name) +SELECT DISTINCT brand +FROM model +WHERE brand IS NOT NULL AND brand != '' +ON CONFLICT (name) DO NOTHING; diff --git a/backend/requirements.txt b/backend/requirements.txt index 669064b..6a093da 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,11 +1,11 @@ -fastapi==0.104.1 -uvicorn==0.24.0 -sqlalchemy==2.0.23 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 -pydantic==2.5.0 -pydantic-settings==2.1.0 -python-multipart==0.0.6 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -email-validator==2.1.0 +fastapi>=0.104.1 +uvicorn>=0.24.0 +sqlalchemy>=2.0.23 +psycopg2-binary>=2.9.11 +python-dotenv>=1.0.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-multipart>=0.0.6 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +email-validator>=2.1.0 diff --git a/backend/schema.sql b/backend/schema.sql index 8079bfb..26976d6 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -1,6 +1,48 @@ -- E-Commerce Database Schema --- Run this file to create tables and populate initial data --- psql -U ecommerce_user -d ecommerce_db -h localhost -f schema.sql +-- Run this file to create database, user, tables and populate initial data +-- Usage: psql -U postgres -h localhost -f schema.sql + +-- ============================================ +-- CREATE DATABASE AND USER +-- ============================================ + +-- Drop existing database and user if they exist (for clean setup) +DROP DATABASE IF EXISTS ecommerce_db; +DROP USER IF EXISTS ecommerce_user; + +-- Create database (Windows compatible - uses system defaults) +CREATE DATABASE ecommerce_db + WITH + OWNER = postgres + ENCODING = 'UTF8' + TABLESPACE = pg_default + CONNECTION LIMIT = -1; + +-- Create user with password +CREATE USER ecommerce_user WITH PASSWORD 'password'; + +-- Grant all privileges on database to user +GRANT ALL PRIVILEGES ON DATABASE ecommerce_db TO ecommerce_user; + +-- Connect to the ecommerce_db database +\c ecommerce_db + +-- Grant schema privileges including CREATE permission +GRANT ALL ON SCHEMA public TO ecommerce_user; +GRANT CREATE ON SCHEMA public TO ecommerce_user; + +-- Make ecommerce_user owner of public schema to avoid permission issues +ALTER SCHEMA public OWNER TO ecommerce_user; + +-- Grant privileges on all current and future tables +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ecommerce_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ecommerce_user; +GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ecommerce_user; + +-- Grant privileges on future objects +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ecommerce_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ecommerce_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ecommerce_user; -- ============================================ -- CREATE TABLES @@ -11,9 +53,27 @@ CREATE TABLE category ( id SERIAL PRIMARY KEY, name VARCHAR(100) UNIQUE NOT NULL, slug VARCHAR(100) UNIQUE NOT NULL, - description TEXT + description TEXT, + image VARCHAR(500) ); +-- Model Table (e.g., New Balance 9060) +CREATE TABLE model ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + category_id INTEGER NOT NULL, + brand VARCHAR(100) NOT NULL, + base_price DECIMAL(10, 2), + sizes JSONB, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE, + UNIQUE (name, category_id, brand) +); + +CREATE INDEX idx_model_category ON model(category_id); +CREATE INDEX idx_model_brand ON model(brand); + -- User Table CREATE TABLE "user" ( id SERIAL PRIMARY KEY, @@ -26,6 +86,7 @@ CREATE TABLE "user" ( postal_code VARCHAR(20), country VARCHAR(100), is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -33,10 +94,12 @@ CREATE TABLE "user" ( CREATE TABLE product ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE, description TEXT, price DECIMAL(10, 2) NOT NULL, discount_price DECIMAL(10, 2), category_id INTEGER NOT NULL, + model_id INTEGER, gender VARCHAR(50), brand VARCHAR(100), sizes JSONB, @@ -45,10 +108,16 @@ CREATE TABLE product ( images JSONB, is_featured BOOLEAN DEFAULT FALSE, is_on_sale BOOLEAN DEFAULT FALSE, + override_price DECIMAL(10, 2), + override_sizes JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE, + FOREIGN KEY (model_id) REFERENCES model(id) ON DELETE SET NULL ); +CREATE INDEX idx_product_slug ON product(slug); +CREATE INDEX idx_product_model ON product(model_id); + -- Cart Table CREATE TABLE cart ( id SERIAL PRIMARY KEY, @@ -284,61 +353,88 @@ INSERT INTO product (name, description, price, discount_price, category_id, gend ); -- Insert Sample Users (for testing) -INSERT INTO "user" (email, hashed_password, full_name, phone, address, city, postal_code, country, is_active) VALUES +INSERT INTO "user" (email, hashed_password, full_name, phone, address, city, postal_code, country, is_active, is_admin) VALUES + ( + 'admin@example.com', + '$2b$12$jNWZdZNMbKEkOjA8Gq3ZUOTml23zfqhFDPJ8AlZQ51WhUyi04AH7C', -- password: password123 + 'Admin User', + '1234567890', + '123 Admin Street', + 'New York', + '10001', + 'USA', + TRUE, + TRUE + ), ( 'user@example.com', - '$2b$12$KIXxPfROLqWHYIgC5FCOO.7yqKU8RvOmOhP7kJZnYLh6pJn2FSfKy', -- password: password123 + '$2b$12$jNWZdZNMbKEkOjA8Gq3ZUOTml23zfqhFDPJ8AlZQ51WhUyi04AH7C', -- password: password123 'John Doe', '1234567890', '123 Main Street', 'New York', '10001', 'USA', - TRUE + TRUE, + FALSE ), ( 'jane@example.com', - '$2b$12$KIXxPfROLqWHYIgC5FCOO.7yqKU8RvOmOhP7kJZnYLh6pJn2FSfKy', -- password: password123 + '$2b$12$jNWZdZNMbKEkOjA8Gq3ZUOTml23zfqhFDPJ8AlZQ51WhUyi04AH7C', -- password: password123 'Jane Smith', '9876543210', '456 Oak Avenue', 'Los Angeles', '90001', 'USA', - TRUE + TRUE, + FALSE ); -- Create carts for users INSERT INTO cart (user_id) VALUES (1), - (2); + (2), + (3); -- ============================================ --- SET PERMISSIONS +-- SET PERMISSIONS (After all tables are created) -- ============================================ +-- Grant all privileges on all tables, sequences, and functions to the user GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ecommerce_user; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ecommerce_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ecommerce_user; GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO ecommerce_user; +-- Ensure future objects also get permissions +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ecommerce_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ecommerce_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO ecommerce_user; + -- ============================================ -- COMPLETE -- ============================================ -- Schema created successfully! -- --- User: ecommerce_user -- Database: ecommerce_db +-- User: ecommerce_user +-- Password: password -- Host: localhost:5432 -- +-- Connection string for .env file: +-- DATABASE_URL=postgresql://ecommerce_user:password@localhost:5432/ecommerce_db +-- -- Tables created: 9 -- - category, user, product, cart, cart_item -- - order, order_item, user_wishlist, contact_message -- -- Demo accounts: --- - user@example.com / password123 --- - jane@example.com / password123 +-- - ADMIN: admin@example.com / password123 (is_admin: true) +-- - USER: user@example.com / password123 +-- - USER: jane@example.com / password123 -- --- Sample data: 5 categories, 9 products, 2 users +-- Sample data: 5 categories, 9 products, 3 users -- -- Next steps: --- 1. Update backend/.env with your credentials --- 2. Run: uvicorn app.main:app --reload --port 8000 +-- 1. Update backend/.env with: DATABASE_URL=postgresql://ecommerce_user:password@localhost:5432/ecommerce_db +-- 2. Run: python backend/app/main.py +-- 3. Login as admin to create/edit/delete products diff --git a/ecommerce.db b/ecommerce.db new file mode 100644 index 0000000..8521be7 Binary files /dev/null and b/ecommerce.db differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index febeb1b..9430c8a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,8 @@ import Wishlist from './pages/Wishlist' import About from './pages/About' import Contact from './pages/Contact' import Sales from './pages/Sales' +import Admin from './pages/Admin' +import Models from './pages/Models' function App() { return ( @@ -42,6 +44,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index 3ba4d48..bdae2db 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -6,12 +6,11 @@ const api = axios.create({ baseURL: API_URL, }) -// Add token to requests +// Add token to requests as Bearer token in Authorization header api.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { - config.params = config.params || {} - config.params.token = token + config.headers.Authorization = `Bearer ${token}` } return config }) diff --git a/frontend/src/components/CategoryCard.jsx b/frontend/src/components/CategoryCard.jsx index 24165d9..06c8ba8 100644 --- a/frontend/src/components/CategoryCard.jsx +++ b/frontend/src/components/CategoryCard.jsx @@ -2,7 +2,7 @@ import React from 'react' import { Link } from 'react-router-dom' export default function CategoryCard({ category }) { - const categoryImage = `https://via.placeholder.com/300x300?text=${category.name}` + const categoryImage = category.image || `https://via.placeholder.com/300x300?text=${category.name}` return ( diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 8fe840d..cf91dc3 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -32,6 +32,9 @@ export default function Navbar() {
  • Sales
  • About
  • Contact
  • + {user?.is_admin && ( +
  • Admin
  • + )}
    diff --git a/frontend/src/components/ProductFilters.jsx b/frontend/src/components/ProductFilters.jsx index 71139a3..8ef933e 100644 --- a/frontend/src/components/ProductFilters.jsx +++ b/frontend/src/components/ProductFilters.jsx @@ -1,12 +1,62 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' +import api from '../api' import '../styles/global.css' export default function ProductFilters({ onFilter }) { const [filters, setFilters] = useState({ gender: '', - priceRange: 'all', + brand: '', + model_id: '', + size: '', + min_price: '', + max_price: '', onSale: false, }) + + const [brands, setBrands] = useState([]) + const [models, setModels] = useState([]) + const [sizes, setSizes] = useState([]) + + useEffect(() => { + fetchBrands() + fetchSizes() + }, []) + + useEffect(() => { + if (filters.brand) { + fetchModels(filters.brand) + } else { + setModels([]) + setFilters(prev => ({ ...prev, model_id: '' })) + } + }, [filters.brand]) + + const fetchBrands = async () => { + try { + const response = await api.get('/products/filters/brands') + setBrands(response.data) + } catch (error) { + console.error('Error fetching brands:', error) + } + } + + const fetchModels = async (brand) => { + try { + const response = await api.get(`/models?brand=${brand}`) + setModels(response.data) + } catch (error) { + console.error('Error fetching models:', error) + } + } + + const fetchSizes = async () => { + try { + const response = await api.get('/products/filters/sizes') + setSizes(response.data) + } catch (error) { + console.error('Error fetching sizes:', error) + } + } const handleFilterChange = (key, value) => { const newFilters = { ...filters, [key]: value } @@ -14,6 +64,20 @@ export default function ProductFilters({ onFilter }) { onFilter(newFilters) } + const resetFilters = () => { + const resetFilters = { + gender: '', + brand: '', + model_id: '', + size: '', + min_price: '', + max_price: '', + onSale: false, + } + setFilters(resetFilters) + onFilter(resetFilters) + } + return (

    Filters

    @@ -31,19 +95,73 @@ export default function ProductFilters({ onFilter }) {
    - +
    + {filters.brand && models.length > 0 && ( +
    + + +
    + )} + +
    + + +
    + +
    + +
    + handleFilterChange('min_price', e.target.value)} + style={{ width: '80px', padding: '0.5rem' }} + /> + - + handleFilterChange('max_price', e.target.value)} + style={{ width: '80px', padding: '0.5rem' }} + /> +
    +
    +
    -
    diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 49f4ba6..563bd7e 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,20 +1,37 @@ import React, { createContext, useState, useEffect } from 'react' +import api from '../api' export const AuthContext = createContext() export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null) const [token, setToken] = useState(localStorage.getItem('token')) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) useEffect(() => { if (token) { localStorage.setItem('token', token) + fetchUserData() } else { localStorage.removeItem('token') + setLoading(false) } }, [token]) + const fetchUserData = async () => { + try { + const response = await api.get('/users/me') + setUser(response.data) + } catch (error) { + console.error('Error fetching user data:', error) + // If token is invalid, clear it + setToken(null) + setUser(null) + } finally { + setLoading(false) + } + } + const logout = () => { setUser(null) setToken(null) @@ -23,7 +40,7 @@ export const AuthProvider = ({ children }) => { return ( - {children} + {!loading && children} ) } diff --git a/frontend/src/context/CartContext.jsx b/frontend/src/context/CartContext.jsx index 1a3aebb..97c672d 100644 --- a/frontend/src/context/CartContext.jsx +++ b/frontend/src/context/CartContext.jsx @@ -18,12 +18,11 @@ export const CartProvider = ({ children }) => { setTotal(newTotal) } - const addToCart = (product, quantity = 1, size = null, color = null) => { + const addToCart = (product, quantity = 1, size = null) => { const existingItem = cart.find( (item) => item.product.id === product.id && - item.size === size && - item.color === color + item.size === size ) if (existingItem) { @@ -35,7 +34,7 @@ export const CartProvider = ({ children }) => { ) ) } else { - setCart([...cart, { product, quantity, size, color }]) + setCart([...cart, { product, quantity, size }]) } } diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..970d141 --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,1224 @@ +import React, { useState, useEffect, useContext } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import api from '../api' +import { AuthContext } from '../context/AuthContext' +import '../styles/global.css' + +export default function Admin() { + const navigate = useNavigate() + const { user, token } = useContext(AuthContext) + const [products, setProducts] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [formData, setFormData] = useState({ + name: '', + slug: '', + description: '', + price: '', + discount_price: '', + category_id: '', + model_id: '', + gender: 'men', + brand: '', + sizes: '', + stock: '', + images: '', + is_featured: false, + is_on_sale: false, + override_price: '', + override_sizes: '', + }) + const [uploadingImage, setUploadingImage] = useState(false) + const [uploadedImages, setUploadedImages] = useState([]) + const [models, setModels] = useState([]) + const [activeTab, setActiveTab] = useState('products') // products or categories + const [showCategoryForm, setShowCategoryForm] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [categoryFormData, setCategoryFormData] = useState({ + name: '', + slug: '', + description: '', + image: '', + }) + const [searchQuery, setSearchQuery] = useState('') + const [filterBrand, setFilterBrand] = useState('') + const [filterCategory, setFilterCategory] = useState('') + const [filterModel, setFilterModel] = useState('') + const [brands, setBrands] = useState([]) + const [allProducts, setAllProducts] = useState([]) // Store all products for filtering + const [showBrandForm, setShowBrandForm] = useState(false) + const [editingBrand, setEditingBrand] = useState(null) + const [brandFormData, setBrandFormData] = useState({ name: '' }) + const [brandsList, setBrandsList] = useState([]) // Separate list for brand management + + // Redirect if not admin + useEffect(() => { + if (!user?.is_admin) { + navigate('/') + } + }, [user, navigate]) + + useEffect(() => { + fetchProducts() + fetchCategories() + fetchModels() + fetchBrands() + }, []) + + const fetchProducts = async () => { + try { + const response = await api.get('/products') + setAllProducts(response.data) + setProducts(response.data) + } catch (error) { + console.error('Error fetching products:', error) + } finally { + setLoading(false) + } + } + + const fetchCategories = async () => { + try { + const response = await api.get('/categories') + setCategories(response.data) + } catch (error) { + console.error('Error fetching categories:', error) + } + } + + const fetchModels = async () => { + try { + const response = await api.get('/models') + setModels(response.data) + } catch (error) { + console.error('Error fetching models:', error) + } + } + + const fetchBrands = async () => { + try { + const response = await api.get('/brands') + setBrands(response.data.map(b => b.name).sort()) + setBrandsList(response.data) + } catch (error) { + console.error('Error fetching brands:', error) + } + } + + // Filter products based on search and filters + useEffect(() => { + let filtered = [...allProducts] + + // Search filter + if (searchQuery) { + filtered = filtered.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.brand?.toLowerCase().includes(searchQuery.toLowerCase()) || + p.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ) + } + + // Brand filter + if (filterBrand) { + filtered = filtered.filter(p => p.brand === filterBrand) + } + + // Category filter + if (filterCategory) { + filtered = filtered.filter(p => p.category_id === parseInt(filterCategory)) + } + + // Model filter + if (filterModel) { + filtered = filtered.filter(p => p.model_id === parseInt(filterModel)) + } + + setProducts(filtered) + }, [searchQuery, filterBrand, filterCategory, filterModel, allProducts]) + + const handleChange = (e) => { + const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value + setFormData({ + ...formData, + [e.target.name]: value, + }) + } + + const handleImageUpload = async (e) => { + const files = e.target.files + if (!files || files.length === 0) return + + setUploadingImage(true) + const newImages = [...uploadedImages] + + try { + for (let i = 0; i < files.length; i++) { + const file = files[i] + const formDataUpload = new FormData() + formDataUpload.append('file', file) + + const response = await api.post('/products/upload-image', formDataUpload, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + + // Add the full URL + const imageUrl = `http://localhost:8000${response.data.url}` + newImages.push(imageUrl) + } + + setUploadedImages(newImages) + // Update form data with new images + const currentImages = formData.images ? formData.images.split(',').map(s => s.trim()).filter(s => s) : [] + const allImages = [...currentImages, ...newImages.map(url => url)] + setFormData({ ...formData, images: allImages.join(', ') }) + + alert('Images uploaded successfully!') + } catch (error) { + console.error('Error uploading image:', error) + alert('Error uploading image: ' + (error.response?.data?.detail || 'Unknown error')) + } finally { + setUploadingImage(false) + } + } + + const removeImage = (imageUrl) => { + const currentImages = formData.images.split(',').map(s => s.trim()).filter(s => s) + const filtered = currentImages.filter(img => img !== imageUrl) + setFormData({ ...formData, images: filtered.join(', ') }) + setUploadedImages(uploadedImages.filter(img => img !== imageUrl)) + } + + const handleSubmit = async (e) => { + e.preventDefault() + + const productData = { + ...formData, + price: parseFloat(formData.price), + discount_price: formData.discount_price ? parseFloat(formData.discount_price) : null, + category_id: parseInt(formData.category_id), + model_id: formData.model_id ? parseInt(formData.model_id) : null, + stock: parseInt(formData.stock), + sizes: formData.sizes ? formData.sizes.split(',').map(s => s.trim()) : null, + images: formData.images.split(',').map(i => i.trim()).filter(i => i), + override_price: formData.override_price ? parseFloat(formData.override_price) : null, + override_sizes: formData.override_sizes ? formData.override_sizes.split(',').map(s => s.trim()) : null, + } + + try { + if (editingProduct) { + await api.put(`/products/${editingProduct.id}`, productData) + alert('Product updated successfully!') + } else { + await api.post('/products', productData) + alert('Product created successfully!') + } + + setShowForm(false) + setEditingProduct(null) + resetForm() + fetchProducts() + } catch (error) { + console.error('Error saving product:', error) + alert('Error saving product: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + const handleEdit = (product) => { + console.log('Edit clicked for product:', product) + try { + setEditingProduct(product) + const imageList = Array.isArray(product.images) ? product.images : [] + console.log('Setting form data...') + setFormData({ + name: product.name || '', + slug: product.slug || '', + description: product.description || '', + price: product.price || '', + discount_price: product.discount_price || '', + category_id: product.category_id || '', + model_id: product.model_id || '', + gender: product.gender || 'men', + brand: product.brand || '', + sizes: Array.isArray(product.sizes) ? product.sizes.join(', ') : '', + stock: product.stock || '', + images: imageList.join(', '), + is_featured: product.is_featured || false, + is_on_sale: product.is_on_sale || false, + override_price: product.override_price || '', + override_sizes: Array.isArray(product.override_sizes) ? product.override_sizes.join(', ') : '', + }) + setUploadedImages(imageList) + console.log('Showing form...') + setShowForm(true) + console.log('Form should now be visible') + + // Scroll to top to show the form + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, 100) + } catch (error) { + console.error('Error in handleEdit:', error) + alert('Error loading product: ' + error.message) + } + } + + const handleDelete = async (id) => { + if (!confirm('Are you sure you want to delete this product?')) return + + try { + await api.delete(`/products/${id}`) + alert('Product deleted successfully!') + fetchProducts() + } catch (error) { + console.error('Error deleting product:', error) + alert('Error deleting product') + } + } + + const resetForm = () => { + setFormData({ + name: '', + slug: '', + description: '', + price: '', + discount_price: '', + category_id: '', + model_id: '', + gender: 'men', + brand: '', + sizes: '', + stock: '', + images: '', + is_featured: false, + is_on_sale: false, + override_price: '', + override_sizes: '', + }) + setUploadedImages([]) + } + + const handleCancel = () => { + setShowForm(false) + setEditingProduct(null) + resetForm() + } + + // Category Management Functions + const handleCategoryChange = (e) => { + const { name, value } = e.target + setCategoryFormData({ ...categoryFormData, [name]: value }) + } + + const handleCategoryImageUpload = async (e) => { + const file = e.target.files[0] + if (!file) return + + setUploadingImage(true) + const formData = new FormData() + formData.append('file', file) + + try { + const response = await api.post('/products/upload-image', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + // Prepend backend URL for image display + const imageUrl = response.data.url.startsWith('http') + ? response.data.url + : `http://localhost:8000${response.data.url}` + setCategoryFormData({ ...categoryFormData, image: imageUrl }) + alert('Image uploaded successfully!') + } catch (error) { + console.error('Error uploading image:', error) + alert('Error uploading image') + } finally { + setUploadingImage(false) + } + } + + const handleCategorySubmit = async (e) => { + e.preventDefault() + + try { + if (editingCategory) { + await api.put(`/categories/${editingCategory.id}`, categoryFormData) + alert('Category updated successfully!') + } else { + await api.post('/categories', categoryFormData) + alert('Category created successfully!') + } + setShowCategoryForm(false) + setEditingCategory(null) + resetCategoryForm() + fetchCategories() + } catch (error) { + console.error('Error saving category:', error) + alert('Error saving category: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + const handleEditCategory = (category) => { + setEditingCategory(category) + setCategoryFormData({ + name: category.name || '', + slug: category.slug || '', + description: category.description || '', + image: category.image || '', + }) + setShowCategoryForm(true) + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, 100) + } + + const handleDeleteCategory = async (id) => { + if (!confirm('Are you sure you want to delete this category?')) return + + try { + await api.delete(`/categories/${id}`) + alert('Category deleted successfully!') + fetchCategories() + } catch (error) { + console.error('Error deleting category:', error) + alert('Error deleting category: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + const resetCategoryForm = () => { + setCategoryFormData({ + name: '', + slug: '', + description: '', + image: '', + }) + } + + // Brand management handlers + const handleAddBrand = async () => { + const newBrand = brandFormData.name.trim() + + if (!newBrand) { + alert('Please enter a brand name') + return + } + + try { + await api.post('/brands', { name: newBrand }) + setShowBrandForm(false) + setBrandFormData({ name: '' }) + fetchBrands() + alert('Brand added successfully!') + } catch (error) { + console.error('Error adding brand:', error) + alert('Error adding brand: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + const handleUpdateBrand = async () => { + const oldBrand = editingBrand + const newBrand = brandFormData.name.trim() + + if (!newBrand) { + alert('Please enter a brand name') + return + } + + if (oldBrand === newBrand) { + setShowBrandForm(false) + setEditingBrand(null) + return + } + + if (!confirm(`Update brand from "${oldBrand}" to "${newBrand}"?`)) { + return + } + + try { + // Find the brand ID + const brandToUpdate = brandsList.find(b => b.name === oldBrand) + if (!brandToUpdate) { + alert('Brand not found') + return + } + + // Update the brand + await api.put(`/brands/${brandToUpdate.id}`, { name: newBrand }) + + // Update all products with this brand + const productsToUpdate = allProducts.filter(p => p.brand === oldBrand) + for (const product of productsToUpdate) { + await api.put(`/products/${product.id}`, { ...product, brand: newBrand }) + } + + // Update all models with this brand + const modelsToUpdate = models.filter(m => m.brand === oldBrand) + for (const model of modelsToUpdate) { + await api.put(`/models/${model.id}`, { ...model, brand: newBrand }) + } + + setShowBrandForm(false) + setEditingBrand(null) + setBrandFormData({ name: '' }) + + // Refresh data + fetchBrands() + fetchProducts() + fetchModels() + + alert('Brand updated successfully!') + } catch (error) { + console.error('Error updating brand:', error) + alert('Error updating brand: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + const handleDeleteBrand = async (brand) => { + const productCount = allProducts.filter(p => p.brand === brand).length + const modelCount = models.filter(m => m.brand === brand).length + + if (productCount > 0 || modelCount > 0) { + alert(`Cannot delete "${brand}". It is used in ${productCount} product(s) and ${modelCount} model(s).`) + return + } + + if (!confirm(`Are you sure you want to delete the brand "${brand}"?`)) { + return + } + + try { + // Find the brand ID + const brandToDelete = brandsList.find(b => b.name === brand) + if (!brandToDelete) { + alert('Brand not found') + return + } + + await api.delete(`/brands/${brandToDelete.id}`) + fetchBrands() + alert('Brand deleted successfully!') + } catch (error) { + console.error('Error deleting brand:', error) + alert('Error deleting brand: ' + (error.response?.data?.detail || 'Unknown error')) + } + } + + if (!user?.is_admin) { + return null + } + + return ( +
    +

    Admin Dashboard

    + + {/* Tab Navigation */} +
    + + + + + Models + +
    + + {/* Products Section */} + {activeTab === 'products' && ( + <> +
    +

    Manage Products

    + +
    + + {/* Search and Filters */} +
    +
    + + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '0.5rem', + border: '1px solid #ddd', + borderRadius: '4px' + }} + /> +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + {showForm && ( +
    +

    {editingProduct ? 'Edit Product' : 'Create New Product'}

    +
    +
    +
    + + +
    + +
    + + +
    + +
    + + + {(formData.brand || formData.name) && !formData.slug && ( + + Will generate: {(formData.brand + ' ' + formData.name).toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s_-]+/g, '-')} + + )} +
    + +
    + +