feat: Add Findagram and FindADispo consumer frontends
- Add findagram.co React frontend with product search, brands, categories - Add findadispo.com React frontend with dispensary locator - Wire findagram to backend /api/az/* endpoints - Update category/brand links to route to /products with filters - Add k8s manifests for both frontends - Add multi-domain user support migrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
9
findagram/backend/.dockerignore
Normal file
9
findagram/backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.env.local
|
||||
.venv
|
||||
venv
|
||||
*.log
|
||||
.DS_Store
|
||||
13
findagram/backend/.env.example
Normal file
13
findagram/backend/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Find a Gram Backend Environment Variables
|
||||
|
||||
# Application
|
||||
DEBUG=false
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://findagram:findagram_pass@localhost:5432/findagram
|
||||
|
||||
# JWT Secret (generate with: openssl rand -hex 32)
|
||||
SECRET_KEY=your-super-secret-key-change-in-production
|
||||
|
||||
# CORS Origins (comma-separated)
|
||||
CORS_ORIGINS=["http://localhost:3001","http://localhost:3000"]
|
||||
25
findagram/backend/Dockerfile
Normal file
25
findagram/backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# Find a Gram Backend - FastAPI
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8001
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
1
findagram/backend/app/__init__.py
Normal file
1
findagram/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Find a Gram Backend
|
||||
29
findagram/backend/app/config.py
Normal file
29
findagram/backend/app/config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Application
|
||||
app_name: str = "Find a Gram API"
|
||||
app_version: str = "1.0.0"
|
||||
debug: bool = False
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://findagram:findagram_pass@localhost:5432/findagram"
|
||||
|
||||
# JWT
|
||||
secret_key: str = "your-super-secret-key-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3001", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
30
findagram/backend/app/database.py
Normal file
30
findagram/backend/app/database.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
4
findagram/backend/app/models/__init__.py
Normal file
4
findagram/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.models.user import User
|
||||
from app.models.product import Product, Dispensary, ProductPrice, Category, Brand
|
||||
|
||||
__all__ = ["User", "Product", "Dispensary", "ProductPrice", "Category", "Brand"]
|
||||
120
findagram/backend/app/models/product.py
Normal file
120
findagram/backend/app/models/product.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
slug = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
icon = Column(String(50))
|
||||
image_url = Column(Text)
|
||||
product_count = Column(Integer, default=0)
|
||||
|
||||
products = relationship("Product", back_populates="category")
|
||||
|
||||
|
||||
class Brand(Base):
|
||||
__tablename__ = "brands"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
slug = Column(String(255), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
logo_url = Column(Text)
|
||||
website = Column(String(500))
|
||||
product_count = Column(Integer, default=0)
|
||||
|
||||
products = relationship("Product", back_populates="brand")
|
||||
|
||||
|
||||
class Dispensary(Base):
|
||||
__tablename__ = "dispensaries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
slug = Column(String(255), unique=True, nullable=False)
|
||||
address = Column(Text)
|
||||
city = Column(String(100))
|
||||
state = Column(String(50))
|
||||
zip_code = Column(String(20))
|
||||
phone = Column(String(50))
|
||||
website = Column(String(500))
|
||||
logo_url = Column(Text)
|
||||
latitude = Column(Float)
|
||||
longitude = Column(Float)
|
||||
rating = Column(Float)
|
||||
review_count = Column(Integer, default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
prices = relationship("ProductPrice", back_populates="dispensary")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(500), nullable=False, index=True)
|
||||
slug = Column(String(500), nullable=False)
|
||||
description = Column(Text)
|
||||
image_url = Column(Text)
|
||||
|
||||
# Category and Brand
|
||||
category_id = Column(Integer, ForeignKey("categories.id"))
|
||||
brand_id = Column(Integer, ForeignKey("brands.id"))
|
||||
|
||||
# Cannabis-specific fields
|
||||
strain_type = Column(String(50)) # indica, sativa, hybrid
|
||||
thc_percentage = Column(Float)
|
||||
cbd_percentage = Column(Float)
|
||||
weight = Column(String(50)) # 1g, 3.5g, 7g, etc.
|
||||
|
||||
# Pricing (lowest/avg for display)
|
||||
lowest_price = Column(Float)
|
||||
avg_price = Column(Float)
|
||||
|
||||
# Deal info
|
||||
has_deal = Column(Boolean, default=False)
|
||||
deal_text = Column(String(255))
|
||||
original_price = Column(Float)
|
||||
|
||||
# Metrics
|
||||
dispensary_count = Column(Integer, default=0)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
category = relationship("Category", back_populates="products")
|
||||
brand = relationship("Brand", back_populates="products")
|
||||
prices = relationship("ProductPrice", back_populates="product")
|
||||
|
||||
|
||||
class ProductPrice(Base):
|
||||
__tablename__ = "product_prices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
dispensary_id = Column(Integer, ForeignKey("dispensaries.id"), nullable=False)
|
||||
|
||||
price = Column(Float, nullable=False)
|
||||
original_price = Column(Float) # For deals
|
||||
in_stock = Column(Boolean, default=True)
|
||||
|
||||
# Deal info
|
||||
has_deal = Column(Boolean, default=False)
|
||||
deal_text = Column(String(255))
|
||||
deal_expires = Column(DateTime(timezone=True))
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="prices")
|
||||
dispensary = relationship("Dispensary", back_populates="prices")
|
||||
26
findagram/backend/app/models/user.py
Normal file
26
findagram/backend/app/models/user.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
name = Column(String(255))
|
||||
phone = Column(String(50))
|
||||
location = Column(String(255))
|
||||
avatar_url = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_verified = Column(Boolean, default=False)
|
||||
|
||||
# Notification preferences (stored as JSON or separate columns)
|
||||
notify_price_alerts = Column(Boolean, default=True)
|
||||
notify_new_products = Column(Boolean, default=True)
|
||||
notify_deals = Column(Boolean, default=True)
|
||||
notify_newsletter = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
11
findagram/backend/app/routes/__init__.py
Normal file
11
findagram/backend/app/routes/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.routes import auth, products, categories, brands, users
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(products.router, prefix="/products", tags=["products"])
|
||||
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
|
||||
api_router.include_router(brands.router, prefix="/brands", tags=["brands"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
77
findagram/backend/app/routes/auth.py
Normal file
77
findagram/backend/app/routes/auth.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from passlib.context import CryptContext
|
||||
from jose import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
@router.post("/register", response_model=Token)
|
||||
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
# Check if user exists
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=hashed_password,
|
||||
name=user_data.name,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token({"sub": str(user.id)})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(user_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User account is disabled"
|
||||
)
|
||||
|
||||
access_token = create_access_token({"sub": str(user.id)})
|
||||
return Token(access_token=access_token)
|
||||
32
findagram/backend/app/routes/brands.py
Normal file
32
findagram/backend/app/routes/brands.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.product import Brand
|
||||
from app.schemas.product import BrandResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[BrandResponse])
|
||||
async def get_brands(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Brand).order_by(Brand.name.asc())
|
||||
)
|
||||
brands = result.scalars().all()
|
||||
return [BrandResponse.model_validate(b) for b in brands]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=BrandResponse)
|
||||
async def get_brand(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Brand).where(Brand.slug == slug)
|
||||
)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if not brand:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
return BrandResponse.model_validate(brand)
|
||||
32
findagram/backend/app/routes/categories.py
Normal file
32
findagram/backend/app/routes/categories.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.product import Category
|
||||
from app.schemas.product import CategoryResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[CategoryResponse])
|
||||
async def get_categories(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Category).order_by(Category.name.asc())
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
return [CategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=CategoryResponse)
|
||||
async def get_category(slug: str, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Category).where(Category.slug == slug)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
return CategoryResponse.model_validate(category)
|
||||
135
findagram/backend/app/routes/products.py
Normal file
135
findagram/backend/app/routes/products.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.product import Product, ProductPrice, Category, Brand
|
||||
from app.schemas.product import ProductResponse, ProductListResponse, ProductDetailResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=ProductListResponse)
|
||||
async def get_products(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
category: Optional[str] = None,
|
||||
brand: Optional[str] = None,
|
||||
strain_type: Optional[str] = None,
|
||||
min_price: Optional[float] = None,
|
||||
max_price: Optional[float] = None,
|
||||
min_thc: Optional[float] = None,
|
||||
has_deal: Optional[bool] = None,
|
||||
search: Optional[str] = None,
|
||||
sort: str = Query("name", enum=["name", "price_low", "price_high", "newest", "thc"]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Product).where(Product.is_active == True)
|
||||
|
||||
# Filters
|
||||
if category:
|
||||
query = query.join(Category).where(Category.slug == category)
|
||||
if brand:
|
||||
query = query.join(Brand).where(Brand.slug == brand)
|
||||
if strain_type:
|
||||
query = query.where(Product.strain_type == strain_type)
|
||||
if min_price is not None:
|
||||
query = query.where(Product.lowest_price >= min_price)
|
||||
if max_price is not None:
|
||||
query = query.where(Product.lowest_price <= max_price)
|
||||
if min_thc is not None:
|
||||
query = query.where(Product.thc_percentage >= min_thc)
|
||||
if has_deal:
|
||||
query = query.where(Product.has_deal == True)
|
||||
if search:
|
||||
query = query.where(Product.name.ilike(f"%{search}%"))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Sorting
|
||||
if sort == "price_low":
|
||||
query = query.order_by(Product.lowest_price.asc())
|
||||
elif sort == "price_high":
|
||||
query = query.order_by(Product.lowest_price.desc())
|
||||
elif sort == "newest":
|
||||
query = query.order_by(Product.created_at.desc())
|
||||
elif sort == "thc":
|
||||
query = query.order_by(Product.thc_percentage.desc().nullslast())
|
||||
else:
|
||||
query = query.order_by(Product.name.asc())
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
query = query.options(selectinload(Product.category), selectinload(Product.brand))
|
||||
|
||||
result = await db.execute(query)
|
||||
products = result.scalars().all()
|
||||
|
||||
return ProductListResponse(
|
||||
products=[ProductResponse.model_validate(p) for p in products],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=(total + per_page - 1) // per_page,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/deals", response_model=ProductListResponse)
|
||||
async def get_deals(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
select(Product)
|
||||
.where(Product.is_active == True, Product.has_deal == True)
|
||||
.order_by(Product.updated_at.desc())
|
||||
)
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
query = query.options(selectinload(Product.category), selectinload(Product.brand))
|
||||
|
||||
result = await db.execute(query)
|
||||
products = result.scalars().all()
|
||||
|
||||
return ProductListResponse(
|
||||
products=[ProductResponse.model_validate(p) for p in products],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=(total + per_page - 1) // per_page,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductDetailResponse)
|
||||
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
|
||||
query = (
|
||||
select(Product)
|
||||
.where(Product.id == product_id)
|
||||
.options(
|
||||
selectinload(Product.category),
|
||||
selectinload(Product.brand),
|
||||
selectinload(Product.prices).selectinload(ProductPrice.dispensary),
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
product = result.scalar_one_or_none()
|
||||
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
61
findagram/backend/app/routes/users.py
Normal file
61
findagram/backend/app/routes/users.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from jose import jwt, JWTError
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserResponse, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.algorithm],
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == int(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User account is disabled")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return UserResponse.model_validate(current_user)
|
||||
20
findagram/backend/app/schemas/__init__.py
Normal file
20
findagram/backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
|
||||
from app.schemas.product import (
|
||||
ProductResponse,
|
||||
ProductListResponse,
|
||||
CategoryResponse,
|
||||
BrandResponse,
|
||||
DispensaryResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserCreate",
|
||||
"UserLogin",
|
||||
"UserResponse",
|
||||
"Token",
|
||||
"ProductResponse",
|
||||
"ProductListResponse",
|
||||
"CategoryResponse",
|
||||
"BrandResponse",
|
||||
"DispensaryResponse",
|
||||
]
|
||||
96
findagram/backend/app/schemas/product.py
Normal file
96
findagram/backend/app/schemas/product.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
product_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BrandResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
product_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DispensaryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
rating: Optional[float] = None
|
||||
review_count: int = 0
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductPriceResponse(BaseModel):
|
||||
dispensary: DispensaryResponse
|
||||
price: float
|
||||
original_price: Optional[float] = None
|
||||
in_stock: bool = True
|
||||
has_deal: bool = False
|
||||
deal_text: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
category: Optional[CategoryResponse] = None
|
||||
brand: Optional[BrandResponse] = None
|
||||
strain_type: Optional[str] = None
|
||||
thc_percentage: Optional[float] = None
|
||||
cbd_percentage: Optional[float] = None
|
||||
weight: Optional[str] = None
|
||||
lowest_price: Optional[float] = None
|
||||
avg_price: Optional[float] = None
|
||||
has_deal: bool = False
|
||||
deal_text: Optional[str] = None
|
||||
original_price: Optional[float] = None
|
||||
dispensary_count: int = 0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductDetailResponse(ProductResponse):
|
||||
prices: List[ProductPriceResponse] = []
|
||||
|
||||
|
||||
class ProductListResponse(BaseModel):
|
||||
products: List[ProductResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
total_pages: int
|
||||
49
findagram/backend/app/schemas/user.py
Normal file
49
findagram/backend/app/schemas/user.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
notify_price_alerts: Optional[bool] = None
|
||||
notify_new_products: Optional[bool] = None
|
||||
notify_deals: Optional[bool] = None
|
||||
notify_newsletter: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
phone: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
44
findagram/backend/main.py
Normal file
44
findagram/backend/main.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.routes import api_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
description="Find a Gram - Cannabis Product Search & Price Comparison API",
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": settings.app_version,
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)
|
||||
13
findagram/backend/requirements.txt
Normal file
13
findagram/backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
asyncpg==0.29.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
alembic==1.13.1
|
||||
httpx==0.26.0
|
||||
email-validator==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
7
findagram/frontend/.dockerignore
Normal file
7
findagram/frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
build
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
*.log
|
||||
3
findagram/frontend/.env.development
Normal file
3
findagram/frontend/.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# Development environment
|
||||
# API URL for local development
|
||||
REACT_APP_API_URL=http://localhost:3010
|
||||
7
findagram/frontend/.env.example
Normal file
7
findagram/frontend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# Findagram Frontend Environment Variables
|
||||
# Copy this file to .env.development or .env.production
|
||||
|
||||
# API URL for backend endpoints
|
||||
# Local development: http://localhost:3010
|
||||
# Production: leave empty (uses relative path via ingress)
|
||||
REACT_APP_API_URL=http://localhost:3010
|
||||
3
findagram/frontend/.env.production
Normal file
3
findagram/frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# Production environment
|
||||
# Empty = uses relative path (proxied via ingress)
|
||||
REACT_APP_API_URL=
|
||||
23
findagram/frontend/.gitignore
vendored
Normal file
23
findagram/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environment files with local overrides
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.production.local
|
||||
|
||||
# Editor directories
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
||||
# Debug logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
52
findagram/frontend/Dockerfile
Normal file
52
findagram/frontend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (using npm install since package-lock.json may not exist)
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variable for API URL (CRA uses REACT_APP_ prefix)
|
||||
ENV REACT_APP_API_URL=https://api.findagram.co
|
||||
|
||||
# Build the app (CRA produces /build, not /dist)
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage (CRA outputs to /build)
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
server_name _; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
\
|
||||
# Gzip compression \
|
||||
gzip on; \
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
|
||||
\
|
||||
# Cache static assets \
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \
|
||||
expires 1y; \
|
||||
add_header Cache-Control "public, immutable"; \
|
||||
} \
|
||||
\
|
||||
# SPA fallback - serve index.html for all routes \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
17809
findagram/frontend/package-lock.json
generated
Normal file
17809
findagram/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
findagram/frontend/package.json
Normal file
52
findagram/frontend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "findagram-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.312.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "PORT=3001 react-scripts start",
|
||||
"dev": "REACT_APP_API_URL=http://localhost:3010 PORT=3001 react-scripts start",
|
||||
"prod": "REACT_APP_API_URL=https://findagram.co PORT=3001 react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
6
findagram/frontend/postcss.config.js
Normal file
6
findagram/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
20
findagram/frontend/public/index.html
Normal file
20
findagram/frontend/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#8B5CF6" />
|
||||
<meta name="description" content="Find a Gram - Cannabis Product Search & Price Comparison. Find the best prices on cannabis products near you." />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Find a Gram - Cannabis Product Finder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
28
findagram/frontend/public/manifest.json
Normal file
28
findagram/frontend/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"short_name": "Find a Gram",
|
||||
"name": "Find a Gram - Cannabis Product Finder",
|
||||
"description": "Find cannabis products, compare prices, and get deals",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#8B5CF6",
|
||||
"background_color": "#ffffff",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["lifestyle", "shopping"]
|
||||
}
|
||||
112
findagram/frontend/public/service-worker.js
Normal file
112
findagram/frontend/public/service-worker.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
const CACHE_NAME = 'findagram-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Install service worker
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip chrome-extension requests
|
||||
if (event.request.url.startsWith('chrome-extension://')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Clone the response
|
||||
const responseClone = response.clone();
|
||||
|
||||
// Cache successful responses
|
||||
if (response.status === 200) {
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to cache
|
||||
return caches.match(event.request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// Return offline page for navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
return new Response('Offline', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((cacheName) => cacheName !== CACHE_NAME)
|
||||
.map((cacheName) => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Push notification handling
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || 'New notification from Find a Gram',
|
||||
icon: '/logo192.png',
|
||||
badge: '/logo192.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/',
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Find a Gram', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handling
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// If there's already a window open, focus it
|
||||
for (const client of clientList) {
|
||||
if (client.url === event.notification.data.url && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(event.notification.data.url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
86
findagram/frontend/src/App.js
Normal file
86
findagram/frontend/src/App.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Header from './components/findagram/Header';
|
||||
import Footer from './components/findagram/Footer';
|
||||
|
||||
// Pages
|
||||
import Home from './pages/findagram/Home';
|
||||
import Products from './pages/findagram/Products';
|
||||
import ProductDetail from './pages/findagram/ProductDetail';
|
||||
import Deals from './pages/findagram/Deals';
|
||||
import Brands from './pages/findagram/Brands';
|
||||
import BrandDetail from './pages/findagram/BrandDetail';
|
||||
import Categories from './pages/findagram/Categories';
|
||||
import CategoryDetail from './pages/findagram/CategoryDetail';
|
||||
import About from './pages/findagram/About';
|
||||
import Contact from './pages/findagram/Contact';
|
||||
import Login from './pages/findagram/Login';
|
||||
import Signup from './pages/findagram/Signup';
|
||||
import Dashboard from './pages/findagram/Dashboard';
|
||||
import Favorites from './pages/findagram/Favorites';
|
||||
import Alerts from './pages/findagram/Alerts';
|
||||
import SavedSearches from './pages/findagram/SavedSearches';
|
||||
import Profile from './pages/findagram/Profile';
|
||||
|
||||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// Mock login function
|
||||
const handleLogin = (email, password) => {
|
||||
// In a real app, this would make an API call
|
||||
setUser({
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: email,
|
||||
avatar: null,
|
||||
});
|
||||
setIsLoggedIn(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Mock logout function
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header isLoggedIn={isLoggedIn} user={user} onLogout={handleLogout} />
|
||||
|
||||
<main className="flex-grow">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/products/:id" element={<ProductDetail />} />
|
||||
<Route path="/deals" element={<Deals />} />
|
||||
<Route path="/brands" element={<Brands />} />
|
||||
<Route path="/brands/:slug" element={<BrandDetail />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/categories/:slug" element={<CategoryDetail />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
|
||||
{/* Auth Routes */}
|
||||
<Route path="/login" element={<Login onLogin={handleLogin} />} />
|
||||
<Route path="/signup" element={<Signup onLogin={handleLogin} />} />
|
||||
|
||||
{/* Dashboard Routes */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/favorites" element={<Favorites />} />
|
||||
<Route path="/dashboard/alerts" element={<Alerts />} />
|
||||
<Route path="/dashboard/searches" element={<SavedSearches />} />
|
||||
<Route path="/dashboard/settings" element={<Profile />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
417
findagram/frontend/src/api/client.js
Normal file
417
findagram/frontend/src/api/client.js
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Findagram API Client
|
||||
*
|
||||
* Connects to the backend /api/az/* endpoints which are publicly accessible.
|
||||
* Uses REACT_APP_API_URL environment variable for the base URL.
|
||||
*
|
||||
* Local development: http://localhost:3010
|
||||
* Production: https://findagram.co (proxied to backend via ingress)
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
/**
|
||||
* Make a fetch request to the API
|
||||
*/
|
||||
async function request(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from params object
|
||||
*/
|
||||
function buildQueryString(params) {
|
||||
const filtered = Object.entries(params).filter(([_, v]) => v !== undefined && v !== null && v !== '');
|
||||
if (filtered.length === 0) return '';
|
||||
return '?' + new URLSearchParams(filtered).toString();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PRODUCTS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Search/filter products across all dispensaries
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} [params.search] - Search term (name or brand)
|
||||
* @param {string} [params.type] - Category type (flower, concentrates, edibles, etc.)
|
||||
* @param {string} [params.subcategory] - Subcategory
|
||||
* @param {string} [params.brandName] - Brand name filter
|
||||
* @param {string} [params.stockStatus] - Stock status (in_stock, out_of_stock, unknown)
|
||||
* @param {number} [params.storeId] - Filter to specific store
|
||||
* @param {number} [params.limit=50] - Page size
|
||||
* @param {number} [params.offset=0] - Offset for pagination
|
||||
*/
|
||||
export async function getProducts(params = {}) {
|
||||
const queryString = buildQueryString({
|
||||
search: params.search,
|
||||
type: params.type,
|
||||
subcategory: params.subcategory,
|
||||
brandName: params.brandName,
|
||||
stockStatus: params.stockStatus,
|
||||
storeId: params.storeId,
|
||||
limit: params.limit || 50,
|
||||
offset: params.offset || 0,
|
||||
});
|
||||
|
||||
return request(`/api/az/products${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single product by ID
|
||||
*/
|
||||
export async function getProduct(id) {
|
||||
return request(`/api/az/products/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product availability - which dispensaries carry this product
|
||||
*
|
||||
* @param {number|string} productId - Product ID
|
||||
* @param {Object} params
|
||||
* @param {number} params.lat - User latitude
|
||||
* @param {number} params.lng - User longitude
|
||||
* @param {number} [params.maxRadiusMiles=50] - Maximum search radius in miles
|
||||
* @returns {Promise<{productId: number, productName: string, brandName: string, totalCount: number, offers: Array}>}
|
||||
*/
|
||||
export async function getProductAvailability(productId, params = {}) {
|
||||
const { lat, lng, maxRadiusMiles = 50 } = params;
|
||||
|
||||
if (!lat || !lng) {
|
||||
throw new Error('lat and lng are required');
|
||||
}
|
||||
|
||||
const queryString = buildQueryString({
|
||||
lat,
|
||||
lng,
|
||||
max_radius_miles: maxRadiusMiles,
|
||||
});
|
||||
|
||||
return request(`/api/az/products/${productId}/availability${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get similar products (same brand + category)
|
||||
*
|
||||
* @param {number|string} productId - Product ID
|
||||
* @returns {Promise<{similarProducts: Array<{productId: number, name: string, brandName: string, imageUrl: string, price: number}>}>}
|
||||
*/
|
||||
export async function getSimilarProducts(productId) {
|
||||
return request(`/api/az/products/${productId}/similar`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products for a specific store with filters
|
||||
*/
|
||||
export async function getStoreProducts(storeId, params = {}) {
|
||||
const queryString = buildQueryString({
|
||||
search: params.search,
|
||||
type: params.type,
|
||||
subcategory: params.subcategory,
|
||||
brandName: params.brandName,
|
||||
stockStatus: params.stockStatus,
|
||||
limit: params.limit || 50,
|
||||
offset: params.offset || 0,
|
||||
});
|
||||
|
||||
return request(`/api/az/stores/${storeId}/products${queryString}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DISPENSARIES (STORES)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all dispensaries
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} [params.city] - Filter by city
|
||||
* @param {boolean} [params.hasPlatformId] - Filter by platform ID presence
|
||||
* @param {number} [params.limit=100] - Page size
|
||||
* @param {number} [params.offset=0] - Offset
|
||||
*/
|
||||
export async function getDispensaries(params = {}) {
|
||||
const queryString = buildQueryString({
|
||||
city: params.city,
|
||||
hasPlatformId: params.hasPlatformId,
|
||||
limit: params.limit || 100,
|
||||
offset: params.offset || 0,
|
||||
});
|
||||
|
||||
return request(`/api/az/stores${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single dispensary by ID
|
||||
*/
|
||||
export async function getDispensary(id) {
|
||||
return request(`/api/az/stores/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dispensary by slug or platform ID
|
||||
*/
|
||||
export async function getDispensaryBySlug(slug) {
|
||||
return request(`/api/az/stores/slug/${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dispensary summary (product counts, categories, brands)
|
||||
*/
|
||||
export async function getDispensarySummary(id) {
|
||||
return request(`/api/az/stores/${id}/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brands available at a specific dispensary
|
||||
*/
|
||||
export async function getDispensaryBrands(id) {
|
||||
return request(`/api/az/stores/${id}/brands`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories available at a specific dispensary
|
||||
*/
|
||||
export async function getDispensaryCategories(id) {
|
||||
return request(`/api/az/stores/${id}/categories`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CATEGORIES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all categories with product counts
|
||||
*/
|
||||
export async function getCategories() {
|
||||
return request('/api/az/categories');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BRANDS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all brands with product counts
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} [params.limit=100] - Page size
|
||||
* @param {number} [params.offset=0] - Offset
|
||||
*/
|
||||
export async function getBrands(params = {}) {
|
||||
const queryString = buildQueryString({
|
||||
limit: params.limit || 100,
|
||||
offset: params.offset || 0,
|
||||
});
|
||||
|
||||
return request(`/api/az/brands${queryString}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DEALS / SPECIALS
|
||||
// Note: The /api/az routes don't have a dedicated specials endpoint yet.
|
||||
// For now, we can filter products with sale prices or use dispensary-specific specials.
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get products on sale (products where sale_price exists)
|
||||
* This is a client-side filter until a dedicated endpoint is added.
|
||||
*/
|
||||
export async function getDeals(params = {}) {
|
||||
// For now, get products and we'll need to filter client-side
|
||||
// or we could use the /api/dispensaries/:slug/specials endpoint if we have a dispensary context
|
||||
const result = await getProducts({
|
||||
...params,
|
||||
limit: params.limit || 100,
|
||||
});
|
||||
|
||||
// Filter to only products with a sale price
|
||||
// Note: This is a temporary solution - ideally the backend would support this filter
|
||||
return {
|
||||
...result,
|
||||
products: result.products.filter(p => p.sale_price || p.med_sale_price),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SEARCH (convenience wrapper)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Search products by term
|
||||
*/
|
||||
export async function searchProducts(searchTerm, params = {}) {
|
||||
return getProducts({
|
||||
...params,
|
||||
search: searchTerm,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// FIELD MAPPING HELPERS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Map API product to UI-compatible format
|
||||
* Backend returns snake_case, UI expects camelCase with specific field names
|
||||
*
|
||||
* @param {Object} apiProduct - Product from API
|
||||
* @returns {Object} - Product formatted for UI components
|
||||
*/
|
||||
export function mapProductForUI(apiProduct) {
|
||||
// Handle both direct product and transformed product formats
|
||||
const p = apiProduct;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
brand: p.brand || p.brand_name,
|
||||
category: p.type || p.category,
|
||||
subcategory: p.subcategory,
|
||||
strainType: p.strain_type || null,
|
||||
// Images
|
||||
image: p.image_url || p.primary_image_url || null,
|
||||
// Potency
|
||||
thc: p.thc_percentage || p.thc_content || null,
|
||||
cbd: p.cbd_percentage || p.cbd_content || null,
|
||||
// Prices (API returns dollars as numbers or null)
|
||||
price: p.regular_price || null,
|
||||
priceRange: p.regular_price_max && p.regular_price
|
||||
? { min: p.regular_price, max: p.regular_price_max }
|
||||
: null,
|
||||
onSale: !!(p.sale_price || p.med_sale_price),
|
||||
salePrice: p.sale_price || null,
|
||||
medPrice: p.med_price || null,
|
||||
medSalePrice: p.med_sale_price || null,
|
||||
// Stock
|
||||
inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock',
|
||||
stockStatus: p.stock_status,
|
||||
// Store info (if available)
|
||||
storeName: p.store_name,
|
||||
storeCity: p.store_city,
|
||||
storeSlug: p.store_slug,
|
||||
dispensaryId: p.dispensary_id,
|
||||
// Options/variants
|
||||
options: p.options,
|
||||
totalQuantity: p.total_quantity,
|
||||
// Timestamps
|
||||
updatedAt: p.updated_at || p.snapshot_at,
|
||||
// For compatibility with ProductCard expectations
|
||||
rating: null, // Not available from API
|
||||
reviewCount: null, // Not available from API
|
||||
dispensaries: [], // Not populated in list view
|
||||
dispensaryCount: p.store_name ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API category to UI-compatible format
|
||||
*/
|
||||
export function mapCategoryForUI(apiCategory) {
|
||||
return {
|
||||
id: apiCategory.type,
|
||||
name: formatCategoryName(apiCategory.type),
|
||||
slug: apiCategory.type?.toLowerCase().replace(/\s+/g, '-'),
|
||||
subcategory: apiCategory.subcategory,
|
||||
productCount: parseInt(apiCategory.product_count || 0, 10),
|
||||
dispensaryCount: parseInt(apiCategory.dispensary_count || 0, 10),
|
||||
brandCount: parseInt(apiCategory.brand_count || 0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API brand to UI-compatible format
|
||||
*/
|
||||
export function mapBrandForUI(apiBrand) {
|
||||
return {
|
||||
id: apiBrand.brand_name,
|
||||
name: apiBrand.brand_name,
|
||||
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
logo: apiBrand.brand_logo_url || null,
|
||||
productCount: parseInt(apiBrand.product_count || 0, 10),
|
||||
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
||||
productTypes: apiBrand.product_types || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API dispensary to UI-compatible format
|
||||
*/
|
||||
export function mapDispensaryForUI(apiDispensary) {
|
||||
return {
|
||||
id: apiDispensary.id,
|
||||
name: apiDispensary.dba_name || apiDispensary.name,
|
||||
slug: apiDispensary.slug,
|
||||
city: apiDispensary.city,
|
||||
state: apiDispensary.state,
|
||||
address: apiDispensary.address,
|
||||
zip: apiDispensary.zip,
|
||||
latitude: apiDispensary.latitude,
|
||||
longitude: apiDispensary.longitude,
|
||||
website: apiDispensary.website,
|
||||
menuUrl: apiDispensary.menu_url,
|
||||
// Summary data (if fetched with summary)
|
||||
productCount: apiDispensary.totalProducts,
|
||||
brandCount: apiDispensary.brandCount,
|
||||
categoryCount: apiDispensary.categoryCount,
|
||||
inStockCount: apiDispensary.inStockCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format category name for display
|
||||
*/
|
||||
function formatCategoryName(type) {
|
||||
if (!type) return '';
|
||||
// Convert "FLOWER" to "Flower", "PRE_ROLLS" to "Pre Rolls", etc.
|
||||
return type
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
const api = {
|
||||
// Products
|
||||
getProducts,
|
||||
getProduct,
|
||||
getProductAvailability,
|
||||
getSimilarProducts,
|
||||
getStoreProducts,
|
||||
searchProducts,
|
||||
// Dispensaries
|
||||
getDispensaries,
|
||||
getDispensary,
|
||||
getDispensaryBySlug,
|
||||
getDispensarySummary,
|
||||
getDispensaryBrands,
|
||||
getDispensaryCategories,
|
||||
// Categories & Brands
|
||||
getCategories,
|
||||
getBrands,
|
||||
// Deals
|
||||
getDeals,
|
||||
// Mappers
|
||||
mapProductForUI,
|
||||
mapCategoryForUI,
|
||||
mapBrandForUI,
|
||||
mapDispensaryForUI,
|
||||
};
|
||||
|
||||
export default api;
|
||||
174
findagram/frontend/src/components/findagram/Footer.jsx
Normal file
174
findagram/frontend/src/components/findagram/Footer.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Leaf, Mail, MapPin, Phone, Facebook, Twitter, Instagram } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const productLinks = [
|
||||
{ name: 'Browse All', href: '/products' },
|
||||
{ name: 'Flower', href: '/categories/flower' },
|
||||
{ name: 'Concentrates', href: '/categories/concentrates' },
|
||||
{ name: 'Edibles', href: '/categories/edibles' },
|
||||
{ name: 'Vapes', href: '/categories/vapes' },
|
||||
{ name: 'Pre-Rolls', href: '/categories/pre-rolls' },
|
||||
];
|
||||
|
||||
const companyLinks = [
|
||||
{ name: 'About Us', href: '/about' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
{ name: 'Careers', href: '/careers' },
|
||||
{ name: 'Press', href: '/press' },
|
||||
{ name: 'Blog', href: '/blog' },
|
||||
];
|
||||
|
||||
const supportLinks = [
|
||||
{ name: 'Help Center', href: '/help' },
|
||||
{ name: 'FAQs', href: '/faqs' },
|
||||
{ name: 'Privacy Policy', href: '/privacy' },
|
||||
{ name: 'Terms of Service', href: '/terms' },
|
||||
{ name: 'Cookie Policy', href: '/cookies' },
|
||||
];
|
||||
|
||||
const socialLinks = [
|
||||
{ name: 'Facebook', icon: Facebook, href: '#' },
|
||||
{ name: 'Twitter', icon: Twitter, href: '#' },
|
||||
{ name: 'Instagram', icon: Instagram, href: '#' },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white">
|
||||
{/* Main Footer Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{/* Brand Column */}
|
||||
<div className="space-y-4">
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<div className="w-10 h-10 rounded-full gradient-purple flex items-center justify-center">
|
||||
<Leaf className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">
|
||||
Find a <span className="text-primary-400">Gram</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Find the best cannabis products and prices near you. Compare prices,
|
||||
discover new brands, and get deals delivered to your inbox.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
key={social.name}
|
||||
href={social.href}
|
||||
className="text-gray-400 hover:text-primary-400 transition-colors"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<social.icon className="h-5 w-5" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Column */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
|
||||
Products
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{productLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company Column */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
|
||||
Company
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{companyLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support Column */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
|
||||
Support
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{supportLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 space-y-2">
|
||||
<a
|
||||
href="mailto:support@findagram.co"
|
||||
className="flex items-center text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
support@findagram.co
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-gray-400 text-sm">
|
||||
© {currentYear} Find a Gram. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-400">
|
||||
<span className="flex items-center">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
Arizona
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>21+ Only</span>
|
||||
<span>|</span>
|
||||
<span>Medical & Recreational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Age Verification Notice */}
|
||||
<div className="bg-gray-800 py-3">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
This website is intended for adults 21 years of age and older.
|
||||
Leaf products are for use by adults only. Please consume responsibly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
272
findagram/frontend/src/components/findagram/Header.jsx
Normal file
272
findagram/frontend/src/components/findagram/Header.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||
import {
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
Heart,
|
||||
Bell,
|
||||
User,
|
||||
LogOut,
|
||||
Settings,
|
||||
Bookmark,
|
||||
Leaf,
|
||||
Tag,
|
||||
LayoutGrid,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Products', href: '/products', icon: LayoutGrid },
|
||||
{ name: 'Deals', href: '/deals', icon: Tag },
|
||||
{ name: 'Brands', href: '/brands', icon: Leaf },
|
||||
{ name: 'Categories', href: '/categories', icon: Store },
|
||||
];
|
||||
|
||||
const isActive = (path) => location.pathname === path;
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
window.location.href = `/products?search=${encodeURIComponent(searchQuery)}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
|
||||
{/* Top bar with purple gradient */}
|
||||
<div className="gradient-purple h-1" />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<div className="w-10 h-10 rounded-full gradient-purple flex items-center justify-center">
|
||||
<Leaf className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">
|
||||
Find a <span className="text-primary">Gram</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center space-x-1 text-sm font-medium transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'text-primary'
|
||||
: 'text-gray-600 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Search Bar (Desktop) */}
|
||||
<form onSubmit={handleSearch} className="hidden lg:flex items-center flex-1 max-w-md mx-8">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products, brands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 w-full bg-gray-50 border-gray-200 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
{/* Favorites */}
|
||||
<Link to="/dashboard/favorites" className="hidden sm:block">
|
||||
<Button variant="ghost" size="icon" className="text-gray-600 hover:text-primary">
|
||||
<Heart className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Alerts */}
|
||||
<Link to="/dashboard/alerts" className="hidden sm:block">
|
||||
<Button variant="ghost" size="icon" className="relative text-gray-600 hover:text-primary">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-0 right-0 h-2 w-2 bg-pink-500 rounded-full" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* User Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10 border-2 border-primary">
|
||||
<AvatarImage src={user?.avatar} alt={user?.name} />
|
||||
<AvatarFallback className="bg-primary text-white">
|
||||
{user?.name?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user?.name || 'User'}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user?.email || 'user@example.com'}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard" className="flex items-center">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard/favorites" className="flex items-center">
|
||||
<Heart className="mr-2 h-4 w-4" />
|
||||
Favorites
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard/alerts" className="flex items-center">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Price Alerts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard/searches" className="flex items-center">
|
||||
<Bookmark className="mr-2 h-4 w-4" />
|
||||
Saved Searches
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard/settings" className="flex items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="hidden sm:block">
|
||||
<Button variant="ghost" className="text-gray-600">
|
||||
Log in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button className="gradient-purple text-white hover:opacity-90">
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Search */}
|
||||
<form onSubmit={handleSearch} className="lg:hidden pb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products, brands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 w-full bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||
<div className="px-4 py-4 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-lg ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
<Link
|
||||
to="/dashboard/favorites"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
<span className="font-medium">Favorites</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/alerts"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="font-medium">Price Alerts</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
161
findagram/frontend/src/components/findagram/ProductCard.jsx
Normal file
161
findagram/frontend/src/components/findagram/ProductCard.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Heart, Star, MapPin, TrendingDown } from 'lucide-react';
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
onFavorite,
|
||||
isFavorite = false,
|
||||
showDispensaryCount = true
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
brand,
|
||||
category,
|
||||
image,
|
||||
thc,
|
||||
cbd,
|
||||
price,
|
||||
priceRange,
|
||||
rating,
|
||||
reviewCount,
|
||||
strainType,
|
||||
dispensaries = [],
|
||||
onSale,
|
||||
salePrice,
|
||||
} = product;
|
||||
|
||||
const strainColors = {
|
||||
sativa: 'bg-yellow-100 text-yellow-800',
|
||||
indica: 'bg-purple-100 text-purple-800',
|
||||
hybrid: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
const savings = onSale && salePrice ? ((price - salePrice) / price * 100).toFixed(0) : 0;
|
||||
|
||||
return (
|
||||
<Card className="product-card group overflow-hidden">
|
||||
<Link to={`/products/${id}`}>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={image || '/placeholder-product.jpg'}
|
||||
alt={name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-3 left-3 flex flex-col gap-2">
|
||||
{onSale && (
|
||||
<Badge variant="deal" className="flex items-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
{savings}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
{strainType && (
|
||||
<Badge className={strainColors[strainType.toLowerCase()] || strainColors.hybrid}>
|
||||
{strainType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`absolute top-3 right-3 h-8 w-8 rounded-full bg-white/80 hover:bg-white ${
|
||||
isFavorite ? 'text-red-500' : 'text-gray-400'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFavorite?.(id);
|
||||
}}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<CardContent className="p-4">
|
||||
{/* Brand */}
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
|
||||
{brand}
|
||||
</p>
|
||||
|
||||
{/* Product Name */}
|
||||
<Link to={`/products/${id}`}>
|
||||
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
|
||||
{name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Category & THC/CBD */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3">
|
||||
<span>{category}</span>
|
||||
{thc && (
|
||||
<>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span>THC {thc}%</span>
|
||||
</>
|
||||
)}
|
||||
{cbd > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span>CBD {cbd}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
{rating && (
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="text-sm font-medium">{rating}</span>
|
||||
{reviewCount && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({reviewCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
{onSale && salePrice ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-pink-600">
|
||||
${salePrice.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 line-through">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : priceRange ? (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dispensary Count */}
|
||||
{showDispensaryCount && dispensaries.length > 0 && (
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<MapPin className="h-4 w-4 mr-1" />
|
||||
Available at {dispensaries.length} {dispensaries.length === 1 ? 'dispensary' : 'dispensaries'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
38
findagram/frontend/src/components/ui/avatar.jsx
Normal file
38
findagram/frontend/src/components/ui/avatar.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
37
findagram/frontend/src/components/ui/badge.jsx
Normal file
37
findagram/frontend/src/components/ui/badge.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white hover:bg-green-600",
|
||||
warning:
|
||||
"border-transparent bg-amber-500 text-white hover:bg-amber-600",
|
||||
deal:
|
||||
"border-transparent bg-pink-500 text-white hover:bg-pink-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
49
findagram/frontend/src/components/ui/button.jsx
Normal file
49
findagram/frontend/src/components/ui/button.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
60
findagram/frontend/src/components/ui/card.jsx
Normal file
60
findagram/frontend/src/components/ui/card.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
24
findagram/frontend/src/components/ui/checkbox.jsx
Normal file
24
findagram/frontend/src/components/ui/checkbox.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
100
findagram/frontend/src/components/ui/dialog.jsx
Normal file
100
findagram/frontend/src/components/ui/dialog.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
175
findagram/frontend/src/components/ui/dropdown-menu.jsx
Normal file
175
findagram/frontend/src/components/ui/dropdown-menu.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(
|
||||
({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(
|
||||
({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(
|
||||
({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(
|
||||
({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(
|
||||
({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
19
findagram/frontend/src/components/ui/input.jsx
Normal file
19
findagram/frontend/src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
143
findagram/frontend/src/components/ui/select.jsx
Normal file
143
findagram/frontend/src/components/ui/select.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef(
|
||||
({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
22
findagram/frontend/src/components/ui/slider.jsx
Normal file
22
findagram/frontend/src/components/ui/slider.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
43
findagram/frontend/src/components/ui/tabs.jsx
Normal file
43
findagram/frontend/src/components/ui/tabs.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
74
findagram/frontend/src/index.css
Normal file
74
findagram/frontend/src/index.css
Normal file
@@ -0,0 +1,74 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 262 83% 66%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 239 84% 67%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 330 81% 60%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 262 83% 66%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom gradient backgrounds */
|
||||
.gradient-purple {
|
||||
background: linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%);
|
||||
}
|
||||
|
||||
.gradient-purple-light {
|
||||
background: linear-gradient(135deg, #F5F3FF 0%, #EEF2FF 100%);
|
||||
}
|
||||
|
||||
/* Product card hover effect */
|
||||
.product-card {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
@apply shadow-lg -translate-y-1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-primary-300 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-primary-400;
|
||||
}
|
||||
11
findagram/frontend/src/index.js
Normal file
11
findagram/frontend/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
6
findagram/frontend/src/lib/utils.js
Normal file
6
findagram/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
343
findagram/frontend/src/mockData.js
Normal file
343
findagram/frontend/src/mockData.js
Normal file
@@ -0,0 +1,343 @@
|
||||
// Mock product data for Find a Gram
|
||||
export const mockProducts = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Blue Dream",
|
||||
brand: "Green Gardens",
|
||||
category: "Flower",
|
||||
subcategory: "Hybrid",
|
||||
strainType: "Hybrid",
|
||||
thc: 22,
|
||||
cbd: 1,
|
||||
price: 45.00,
|
||||
priceRange: { min: 42.00, max: 55.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1603909075271-76c12e66bc99?w=400",
|
||||
description: "A legendary sativa-dominant hybrid that delivers a balanced high with full-body relaxation paired with gentle cerebral invigoration.",
|
||||
effects: ["Happy", "Relaxed", "Euphoric", "Uplifted", "Creative"],
|
||||
flavors: ["Sweet", "Berry", "Blueberry", "Herbal"],
|
||||
terpenes: ["Myrcene", "Pinene", "Caryophyllene"],
|
||||
dispensaryCount: 8,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 45.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 48.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 42.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "OG Kush",
|
||||
brand: "Premium Cultivators",
|
||||
category: "Flower",
|
||||
subcategory: "Indica",
|
||||
strainType: "Indica",
|
||||
thc: 24,
|
||||
cbd: 0.5,
|
||||
price: 52.00,
|
||||
priceRange: { min: 48.00, max: 58.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1603909075271-76c12e66bc99?w=400",
|
||||
description: "The legendary OG Kush is a classic strain known for its stress-relieving and euphoric effects.",
|
||||
effects: ["Relaxed", "Happy", "Euphoric", "Sleepy", "Hungry"],
|
||||
flavors: ["Earthy", "Pine", "Woody", "Lemon"],
|
||||
terpenes: ["Limonene", "Myrcene", "Caryophyllene"],
|
||||
dispensaryCount: 12,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 52.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 4, dispensaryName: "Emerald City", price: 48.00, distance: 4.1, inStock: true, rating: 4.6 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Sour Diesel",
|
||||
brand: "Artisan Cannabis",
|
||||
category: "Flower",
|
||||
subcategory: "Sativa",
|
||||
strainType: "Sativa",
|
||||
thc: 26,
|
||||
cbd: 0.3,
|
||||
price: 55.00,
|
||||
priceRange: { min: 50.00, max: 62.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1603909075271-76c12e66bc99?w=400",
|
||||
description: "Fast-acting strain that delivers energizing, dreamy cerebral effects. A pungent diesel aroma.",
|
||||
effects: ["Energetic", "Happy", "Uplifted", "Euphoric", "Creative"],
|
||||
flavors: ["Diesel", "Pungent", "Earthy", "Citrus"],
|
||||
terpenes: ["Caryophyllene", "Limonene", "Myrcene"],
|
||||
dispensaryCount: 6,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 55.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 50.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Strawberry Gummies 10mg",
|
||||
brand: "Sweet Leaf Edibles",
|
||||
category: "Edibles",
|
||||
subcategory: "Gummies",
|
||||
strainType: "Hybrid",
|
||||
thc: 10,
|
||||
cbd: 0,
|
||||
price: 25.00,
|
||||
priceRange: { min: 22.00, max: 28.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1611091333792-03b1ac8ef92b?w=400",
|
||||
description: "Delicious strawberry-flavored gummies with 10mg THC per piece. Perfect for micro-dosing.",
|
||||
effects: ["Relaxed", "Happy", "Euphoric"],
|
||||
flavors: ["Strawberry", "Sweet", "Fruity"],
|
||||
terpenes: [],
|
||||
dispensaryCount: 15,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 25.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 24.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
{ dispensaryId: 4, dispensaryName: "Emerald City", price: 22.00, distance: 4.1, inStock: true, rating: 4.6 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Live Resin - Gorilla Glue",
|
||||
brand: "Extract Masters",
|
||||
category: "Concentrates",
|
||||
subcategory: "Live Resin",
|
||||
strainType: "Hybrid",
|
||||
thc: 78,
|
||||
cbd: 0.2,
|
||||
price: 65.00,
|
||||
priceRange: { min: 60.00, max: 72.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1621812958527-50048b6d66e8?w=400",
|
||||
description: "Premium live resin made from fresh frozen Gorilla Glue flower. Incredibly potent.",
|
||||
effects: ["Relaxed", "Euphoric", "Happy", "Sleepy"],
|
||||
flavors: ["Earthy", "Pine", "Pungent"],
|
||||
terpenes: ["Caryophyllene", "Limonene", "Myrcene"],
|
||||
dispensaryCount: 4,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 65.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Northern Lights Vape Cart",
|
||||
brand: "Cloud Nine",
|
||||
category: "Vapes",
|
||||
subcategory: "Cartridges",
|
||||
strainType: "Indica",
|
||||
thc: 85,
|
||||
cbd: 0,
|
||||
price: 40.00,
|
||||
priceRange: { min: 38.00, max: 45.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1560065680-dbeb8cf8ea1f?w=400",
|
||||
description: "Classic Northern Lights in a convenient vape cartridge. Perfect for relaxation.",
|
||||
effects: ["Relaxed", "Happy", "Sleepy", "Euphoric"],
|
||||
flavors: ["Sweet", "Earthy", "Spicy"],
|
||||
terpenes: ["Myrcene", "Pinene"],
|
||||
dispensaryCount: 10,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 40.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 42.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "CBD Relief Balm",
|
||||
brand: "Nature's Remedy",
|
||||
category: "Topicals",
|
||||
subcategory: "Balms",
|
||||
strainType: null,
|
||||
thc: 0,
|
||||
cbd: 500,
|
||||
price: 35.00,
|
||||
priceRange: { min: 32.00, max: 38.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?w=400",
|
||||
description: "Soothing CBD balm for targeted relief. Perfect for sore muscles and joints.",
|
||||
effects: ["Pain Relief", "Relaxed"],
|
||||
flavors: ["Lavender", "Eucalyptus"],
|
||||
terpenes: [],
|
||||
dispensaryCount: 8,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 35.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 4, dispensaryName: "Emerald City", price: 32.00, distance: 4.1, inStock: true, rating: 4.6 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Full Spectrum Tincture 1000mg",
|
||||
brand: "Healing Harvest",
|
||||
category: "Tinctures",
|
||||
subcategory: "Oil",
|
||||
strainType: "Hybrid",
|
||||
thc: 500,
|
||||
cbd: 500,
|
||||
price: 75.00,
|
||||
priceRange: { min: 70.00, max: 82.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1616690248758-3ad58e5f73c4?w=400",
|
||||
description: "Full spectrum 1:1 THC:CBD tincture for balanced effects. Sublingual or add to food.",
|
||||
effects: ["Relaxed", "Pain Relief", "Calm"],
|
||||
flavors: ["Natural", "Herbal"],
|
||||
terpenes: ["Myrcene", "Linalool"],
|
||||
dispensaryCount: 5,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 75.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Girl Scout Cookies",
|
||||
brand: "Green Gardens",
|
||||
category: "Flower",
|
||||
subcategory: "Hybrid",
|
||||
strainType: "Hybrid",
|
||||
thc: 25,
|
||||
cbd: 0.5,
|
||||
price: 48.00,
|
||||
priceRange: { min: 45.00, max: 55.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1603909075271-76c12e66bc99?w=400",
|
||||
description: "The famous GSC delivers euphoric, full-body relaxation with sweet and earthy flavors.",
|
||||
effects: ["Euphoric", "Happy", "Relaxed", "Creative"],
|
||||
flavors: ["Sweet", "Earthy", "Mint", "Chocolate"],
|
||||
terpenes: ["Caryophyllene", "Limonene", "Humulene"],
|
||||
dispensaryCount: 11,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 48.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 50.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 45.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Chocolate Brownies 50mg",
|
||||
brand: "Baked Bros",
|
||||
category: "Edibles",
|
||||
subcategory: "Baked Goods",
|
||||
strainType: "Indica",
|
||||
thc: 50,
|
||||
cbd: 0,
|
||||
price: 18.00,
|
||||
priceRange: { min: 15.00, max: 20.00 },
|
||||
inStock: false,
|
||||
image: "https://images.unsplash.com/photo-1611091333792-03b1ac8ef92b?w=400",
|
||||
description: "Rich, fudgy brownies infused with 50mg THC. Perfect for experienced users.",
|
||||
effects: ["Relaxed", "Sleepy", "Happy", "Hungry"],
|
||||
flavors: ["Chocolate", "Sweet"],
|
||||
terpenes: [],
|
||||
dispensaryCount: 3,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 4, dispensaryName: "Emerald City", price: 18.00, distance: 4.1, inStock: false, rating: 4.6 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: "Pineapple Express",
|
||||
brand: "Tropical Gardens",
|
||||
category: "Flower",
|
||||
subcategory: "Hybrid",
|
||||
strainType: "Hybrid",
|
||||
thc: 21,
|
||||
cbd: 0.5,
|
||||
price: 42.00,
|
||||
priceRange: { min: 38.00, max: 48.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1603909075271-76c12e66bc99?w=400",
|
||||
description: "Tropical and fruity, this energizing strain is perfect for productive afternoons.",
|
||||
effects: ["Energetic", "Happy", "Uplifted", "Creative"],
|
||||
flavors: ["Pineapple", "Tropical", "Citrus", "Sweet"],
|
||||
terpenes: ["Caryophyllene", "Limonene", "Pinene"],
|
||||
dispensaryCount: 7,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 1, dispensaryName: "Green Haven", price: 42.00, distance: 1.2, inStock: true, rating: 4.8 },
|
||||
{ dispensaryId: 2, dispensaryName: "Purple Lotus", price: 38.00, distance: 2.5, inStock: true, rating: 4.5 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: "Shatter - Wedding Cake",
|
||||
brand: "Extract Masters",
|
||||
category: "Concentrates",
|
||||
subcategory: "Shatter",
|
||||
strainType: "Indica",
|
||||
thc: 82,
|
||||
cbd: 0,
|
||||
price: 55.00,
|
||||
priceRange: { min: 50.00, max: 60.00 },
|
||||
inStock: true,
|
||||
image: "https://images.unsplash.com/photo-1621812958527-50048b6d66e8?w=400",
|
||||
description: "Crystal clear shatter with intense Wedding Cake flavor profile. Very potent.",
|
||||
effects: ["Relaxed", "Happy", "Euphoric", "Sleepy"],
|
||||
flavors: ["Sweet", "Vanilla", "Earthy"],
|
||||
terpenes: ["Limonene", "Caryophyllene"],
|
||||
dispensaryCount: 5,
|
||||
dispensaries: [
|
||||
{ dispensaryId: 3, dispensaryName: "High Society", price: 55.00, distance: 3.8, inStock: true, rating: 4.9 },
|
||||
{ dispensaryId: 4, dispensaryName: "Emerald City", price: 50.00, distance: 4.1, inStock: true, rating: 4.6 },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const mockBrands = [
|
||||
{ id: 1, name: "Green Gardens", productCount: 45, logo: "", description: "Premium flower cultivators since 2015" },
|
||||
{ id: 2, name: "Premium Cultivators", productCount: 38, logo: "", description: "Craft cannabis for connoisseurs" },
|
||||
{ id: 3, name: "Sweet Leaf Edibles", productCount: 25, logo: "", description: "Delicious cannabis-infused treats" },
|
||||
{ id: 4, name: "Extract Masters", productCount: 18, logo: "", description: "High-potency concentrates" },
|
||||
{ id: 5, name: "Cloud Nine", productCount: 22, logo: "", description: "Premium vape cartridges" },
|
||||
{ id: 6, name: "Nature's Remedy", productCount: 15, logo: "", description: "Natural CBD products" },
|
||||
{ id: 7, name: "Healing Harvest", productCount: 12, logo: "", description: "Therapeutic cannabis solutions" },
|
||||
{ id: 8, name: "Artisan Cannabis", productCount: 30, logo: "", description: "Small batch artisan flower" },
|
||||
{ id: 9, name: "Baked Bros", productCount: 20, logo: "", description: "Cannabis-infused baked goods" },
|
||||
{ id: 10, name: "Tropical Gardens", productCount: 28, logo: "", description: "Exotic tropical strains" },
|
||||
];
|
||||
|
||||
export const mockCategories = [
|
||||
{ id: 1, name: "Flower", slug: "flower", count: 156, icon: "Flower2", description: "Premium cannabis buds" },
|
||||
{ id: 2, name: "Edibles", slug: "edibles", count: 89, icon: "Cookie", description: "Cannabis-infused food & drinks" },
|
||||
{ id: 3, name: "Concentrates", slug: "concentrates", count: 67, icon: "Droplets", description: "Potent extracts & dabs" },
|
||||
{ id: 4, name: "Vapes", slug: "vapes", count: 54, icon: "Wind", description: "Vape cartridges & pens" },
|
||||
{ id: 5, name: "Topicals", slug: "topicals", count: 32, icon: "Hand", description: "Lotions, balms & creams" },
|
||||
{ id: 6, name: "Tinctures", slug: "tinctures", count: 28, icon: "TestTube", description: "Sublingual oils & drops" },
|
||||
];
|
||||
|
||||
export const mockFavorites = [
|
||||
{ id: 1, productId: 1, productName: "Blue Dream", addedAt: "2024-01-15" },
|
||||
{ id: 2, productId: 4, productName: "Strawberry Gummies 10mg", addedAt: "2024-01-18" },
|
||||
{ id: 3, productId: 6, productName: "Northern Lights Vape Cart", addedAt: "2024-01-20" },
|
||||
];
|
||||
|
||||
export const mockAlerts = [
|
||||
{ id: 1, productId: 1, productName: "Blue Dream", targetPrice: 40.00, currentPrice: 45.00, isActive: true, notifyVia: ["email"] },
|
||||
{ id: 2, productId: 5, productName: "Live Resin - Gorilla Glue", targetPrice: 55.00, currentPrice: 65.00, isActive: true, notifyVia: ["email", "sms"] },
|
||||
{ id: 3, productId: 9, productName: "Girl Scout Cookies", targetPrice: 42.00, currentPrice: 48.00, isActive: false, notifyVia: ["email"] },
|
||||
];
|
||||
|
||||
export const mockSavedSearches = [
|
||||
{ id: 1, query: "indica flower", filters: { category: "Flower", strainType: "Indica" }, createdAt: "2024-01-10" },
|
||||
{ id: 2, query: "cheap edibles", filters: { category: "Edibles", priceMax: 30 }, createdAt: "2024-01-12" },
|
||||
{ id: 3, query: "high thc", filters: { thcMin: 25 }, createdAt: "2024-01-15" },
|
||||
];
|
||||
|
||||
export const mockDispensaries = [
|
||||
{ id: 1, name: "Green Haven", address: "123 Main St, Phoenix, AZ", rating: 4.8, distance: 1.2 },
|
||||
{ id: 2, name: "Purple Lotus", address: "456 Oak Ave, Scottsdale, AZ", rating: 4.5, distance: 2.5 },
|
||||
{ id: 3, name: "High Society", address: "789 Pine Rd, Tempe, AZ", rating: 4.9, distance: 3.8 },
|
||||
{ id: 4, name: "Emerald City", address: "321 Elm St, Mesa, AZ", rating: 4.6, distance: 4.1 },
|
||||
];
|
||||
|
||||
export const trendingSearches = [
|
||||
"Blue Dream", "OG Kush", "Sour Diesel", "Girl Scout Cookies",
|
||||
"Edibles", "Live Resin", "Vape Carts", "CBD Products"
|
||||
];
|
||||
|
||||
export const strainTypes = ["Indica", "Sativa", "Hybrid"];
|
||||
|
||||
export const effectsList = [
|
||||
"Relaxed", "Happy", "Euphoric", "Uplifted", "Creative",
|
||||
"Energetic", "Sleepy", "Hungry", "Focused", "Talkative"
|
||||
];
|
||||
|
||||
export const flavorsList = [
|
||||
"Sweet", "Earthy", "Citrus", "Berry", "Pine", "Diesel",
|
||||
"Tropical", "Woody", "Spicy", "Herbal", "Pungent"
|
||||
];
|
||||
200
findagram/frontend/src/pages/findagram/About.jsx
Normal file
200
findagram/frontend/src/pages/findagram/About.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import {
|
||||
Leaf,
|
||||
Search,
|
||||
Bell,
|
||||
MapPin,
|
||||
Heart,
|
||||
Shield,
|
||||
Users,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
const About = () => {
|
||||
const features = [
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Product Search',
|
||||
description: 'Search thousands of cannabis products from brands across Arizona.',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Price Comparison',
|
||||
description: 'Compare prices across dispensaries to find the best deals.',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: 'Price Alerts',
|
||||
description: 'Get notified when your favorite products go on sale.',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Find Nearby',
|
||||
description: 'Discover dispensaries near you that carry the products you want.',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'Save Favorites',
|
||||
description: 'Build a collection of your favorite products for easy access.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Verified Data',
|
||||
description: 'Accurate, up-to-date product and pricing information.',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '10,000+', label: 'Products' },
|
||||
{ value: '500+', label: 'Brands' },
|
||||
{ value: '200+', label: 'Dispensaries' },
|
||||
{ value: 'Daily', label: 'Updates' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="gradient-purple text-white py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-6">
|
||||
<Leaf className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">About Find a Gram</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">
|
||||
Your Guide to Leaf Products in Arizona
|
||||
</h1>
|
||||
<p className="text-lg text-purple-100">
|
||||
We help cannabis consumers find the best products and prices at
|
||||
dispensaries near them. Our mission is to make cannabis shopping
|
||||
easier, more informed, and more affordable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<p className="text-3xl font-bold text-primary">{stat.value}</p>
|
||||
<p className="text-gray-600">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-6">Our Mission</h2>
|
||||
<div className="space-y-4 text-gray-600">
|
||||
<p>
|
||||
Find a Gram was created to solve a common problem: finding the
|
||||
right cannabis products at the best prices. With hundreds of
|
||||
dispensaries and thousands of products in Arizona alone, it can
|
||||
be overwhelming to find what you're looking for.
|
||||
</p>
|
||||
<p>
|
||||
We aggregate product information and prices from dispensaries
|
||||
across the state, making it easy to compare options and find
|
||||
the best deals. Whether you're looking for a specific strain,
|
||||
trying a new brand, or hunting for discounts, Find a Gram is
|
||||
your go-to resource.
|
||||
</p>
|
||||
<p>
|
||||
Our team is dedicated to providing accurate, up-to-date
|
||||
information so you can make informed decisions about your
|
||||
cannabis purchases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-100 to-indigo-100 rounded-2xl p-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl p-6 text-center shadow-sm">
|
||||
<Users className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold">Consumer First</h3>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 text-center shadow-sm">
|
||||
<Shield className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold">Data Integrity</h3>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 text-center shadow-sm">
|
||||
<TrendingUp className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold">Daily Updates</h3>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 text-center shadow-sm">
|
||||
<Heart className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h3 className="font-semibold">Community Focus</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">What We Offer</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Find a Gram provides the tools you need to discover, compare, and
|
||||
save on cannabis products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title} className="text-center">
|
||||
<CardContent className="pt-6">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full gradient-purple flex items-center justify-center">
|
||||
<feature.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-gray-600">{feature.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 gradient-purple text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">Ready to Find Your Perfect Product?</h2>
|
||||
<p className="text-purple-100 mb-8 max-w-2xl mx-auto">
|
||||
Start exploring thousands of cannabis products and find the best deals near you.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/products">
|
||||
<Button size="lg" className="bg-white text-primary hover:bg-gray-100 px-8">
|
||||
Browse Products
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-white text-white hover:bg-white hover:text-primary px-8"
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
207
findagram/frontend/src/pages/findagram/Alerts.jsx
Normal file
207
findagram/frontend/src/pages/findagram/Alerts.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockAlerts, mockProducts } from '../../mockData';
|
||||
import { Bell, Trash2, Pause, Play, TrendingDown } from 'lucide-react';
|
||||
|
||||
const Alerts = () => {
|
||||
const [alerts, setAlerts] = useState(mockAlerts);
|
||||
|
||||
const toggleAlert = (alertId) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((alert) =>
|
||||
alert.id === alertId ? { ...alert, active: !alert.active } : alert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const deleteAlert = (alertId) => {
|
||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter((a) => a.active);
|
||||
const pausedAlerts = alerts.filter((a) => !a.active);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Bell className="h-8 w-8 text-primary" />
|
||||
Price Alerts
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{activeAlerts.length} active alerts
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/products">
|
||||
<Button className="gradient-purple">
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Set New Alert
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-8">
|
||||
{/* Active Alerts */}
|
||||
{activeAlerts.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
Active Alerts ({activeAlerts.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{activeAlerts.map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
const priceDiff = product ? product.price - alert.targetPrice : 0;
|
||||
const isTriggered = priceDiff <= 0;
|
||||
|
||||
return (
|
||||
<Card key={alert.id} className={isTriggered ? 'border-green-500 bg-green-50' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={`/products/${product?.id}`}>
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/products/${product?.id}`}
|
||||
className="font-medium text-gray-900 hover:text-primary truncate block"
|
||||
>
|
||||
{product?.name}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">{product?.brand}</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-sm">
|
||||
Current: <span className="font-medium">${product?.price.toFixed(2)}</span>
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
Target: <span className="font-medium text-primary">${alert.targetPrice.toFixed(2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isTriggered && (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
Price Dropped!
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
title="Pause alert"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete alert"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paused Alerts */}
|
||||
{pausedAlerts.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full" />
|
||||
Paused Alerts ({pausedAlerts.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{pausedAlerts.map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
|
||||
return (
|
||||
<Card key={alert.id} className="opacity-75">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
className="w-16 h-16 rounded-lg object-cover grayscale"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product?.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{product?.brand}</p>
|
||||
<span className="text-sm">
|
||||
Target: <span className="font-medium">${alert.targetPrice.toFixed(2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">Paused</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
title="Resume alert"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete alert"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No price alerts set
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
||||
Set price alerts on products you're interested in and we'll notify you when they drop to your target price.
|
||||
</p>
|
||||
<Link to="/products">
|
||||
<Button className="gradient-purple">Browse Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alerts;
|
||||
205
findagram/frontend/src/pages/findagram/BrandDetail.jsx
Normal file
205
findagram/frontend/src/pages/findagram/BrandDetail.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { getBrands, getProducts, mapBrandForUI, mapProductForUI } from '../../api/client';
|
||||
import { Leaf, ChevronRight, Globe, MapPin, Loader2 } from 'lucide-react';
|
||||
|
||||
const BrandDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [brand, setBrand] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBrandData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch brands to find the one matching our slug
|
||||
const brandsRes = await getBrands({ limit: 500 });
|
||||
const brands = (brandsRes.brands || brandsRes || []).map(mapBrandForUI);
|
||||
|
||||
// Find matching brand by slug
|
||||
const matchingBrand = brands.find(
|
||||
(b) => b.slug === slug || b.name?.toLowerCase().replace(/\s+/g, '-') === slug
|
||||
);
|
||||
|
||||
if (matchingBrand) {
|
||||
setBrand(matchingBrand);
|
||||
|
||||
// Fetch products for this brand
|
||||
const productsRes = await getProducts({
|
||||
brandName: matchingBrand.name,
|
||||
limit: 100,
|
||||
});
|
||||
const mappedProducts = (productsRes.products || []).map(mapProductForUI);
|
||||
setProducts(mappedProducts);
|
||||
} else {
|
||||
setBrand(null);
|
||||
setProducts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching brand:', err);
|
||||
setError(err.message || 'Failed to load brand');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBrandData();
|
||||
}, [slug]);
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading brand...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !brand) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Brand Not Found</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{error || "The brand you're looking for doesn't exist."}
|
||||
</p>
|
||||
<Link to="/brands">
|
||||
<Button>Browse Brands</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<nav className="flex items-center space-x-2 text-sm">
|
||||
<Link to="/" className="text-gray-500 hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<Link to="/brands" className="text-gray-500 hover:text-primary">
|
||||
Brands
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-900">{brand.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
{/* Brand Logo */}
|
||||
<div className="w-24 h-24 rounded-xl bg-gray-100 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{brand.logo ? (
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`w-full h-full items-center justify-center ${brand.logo ? 'hidden' : 'flex'}`}>
|
||||
<Leaf className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand Info */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{brand.name}</h1>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="secondary">{brand.productCount || products.length} Products</Badge>
|
||||
{brand.productTypes && brand.productTypes.length > 0 && brand.productTypes.map((type) => (
|
||||
<Badge key={type} variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{brand.description && (
|
||||
<p className="text-gray-600 mb-4">{brand.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
{brand.website && (
|
||||
<a
|
||||
href={brand.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-primary"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Arizona
|
||||
</span>
|
||||
{brand.dispensaryCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
Available at {brand.dispensaryCount} dispensaries
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Products by {brand.name}
|
||||
</h2>
|
||||
|
||||
{products.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg">
|
||||
<p className="text-gray-500 mb-4">No products found for this brand.</p>
|
||||
<Link to="/products">
|
||||
<Button>Browse All Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandDetail;
|
||||
188
findagram/frontend/src/pages/findagram/Brands.jsx
Normal file
188
findagram/frontend/src/pages/findagram/Brands.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { getBrands, mapBrandForUI } from '../../api/client';
|
||||
import { Search, Leaf, TrendingUp, Loader2 } from 'lucide-react';
|
||||
|
||||
const Brands = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch brands from API
|
||||
useEffect(() => {
|
||||
const fetchBrands = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getBrands({ limit: 500 });
|
||||
setBrands((res.brands || []).map(mapBrandForUI));
|
||||
} catch (err) {
|
||||
console.error('Error fetching brands:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchBrands();
|
||||
}, []);
|
||||
|
||||
const filteredBrands = brands.filter((brand) =>
|
||||
brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Group brands alphabetically
|
||||
const groupedBrands = filteredBrands.reduce((acc, brand) => {
|
||||
const letter = brand.name[0].toUpperCase();
|
||||
if (!acc[letter]) acc[letter] = [];
|
||||
acc[letter].push(brand);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sortedLetters = Object.keys(groupedBrands).sort();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="gradient-purple text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-4">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Discover Top Brands</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Browse by Brand</h1>
|
||||
<p className="text-lg text-purple-100 max-w-2xl mx-auto mb-8">
|
||||
Explore products from {loading ? '...' : `${brands.length}+`} cannabis brands in Arizona.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search brands..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Featured Brands */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Featured Brands</h2>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{brands.slice(0, 6).map((brand) => (
|
||||
<Link key={brand.id} to={`/products?brand=${encodeURIComponent(brand.name)}`} className="group">
|
||||
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{brand.logo ? (
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Leaf className="h-8 w-8 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 group-hover:text-primary transition-colors">
|
||||
{brand.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{brand.productCount} products
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* All Brands A-Z */}
|
||||
<section>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">All Brands</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Alphabet Navigation */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{sortedLetters.map((letter) => (
|
||||
<a
|
||||
key={letter}
|
||||
href={`#brand-${letter}`}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg bg-white border hover:bg-primary hover:text-white transition-colors font-medium text-sm"
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Brands by Letter */}
|
||||
<div className="space-y-8">
|
||||
{sortedLetters.map((letter) => (
|
||||
<div key={letter} id={`brand-${letter}`}>
|
||||
<h3 className="text-lg font-bold text-primary mb-4 border-b pb-2">
|
||||
{letter}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{groupedBrands[letter].map((brand) => (
|
||||
<Link key={brand.id} to={`/products?brand=${encodeURIComponent(brand.name)}`} className="group">
|
||||
<Card className="h-full transition-all hover:shadow-md">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-2 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{brand.logo ? (
|
||||
<img
|
||||
src={brand.logo}
|
||||
alt={brand.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Leaf className="h-6 w-6 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm text-gray-900 group-hover:text-primary transition-colors line-clamp-1">
|
||||
{brand.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500">
|
||||
{brand.productCount} products
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{filteredBrands.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No brands found matching "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Brands;
|
||||
137
findagram/frontend/src/pages/findagram/Categories.jsx
Normal file
137
findagram/frontend/src/pages/findagram/Categories.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { getCategories, mapCategoryForUI } from '../../api/client';
|
||||
import { Leaf, Sparkles, Cookie, Droplet, Wind, Package, Loader2 } from 'lucide-react';
|
||||
|
||||
const Categories = () => {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch categories from API
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getCategories();
|
||||
setCategories((res.categories || []).map(mapCategoryForUI));
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCategories();
|
||||
}, []);
|
||||
// Category icons mapping
|
||||
const categoryIcons = {
|
||||
flower: Leaf,
|
||||
concentrates: Droplet,
|
||||
edibles: Cookie,
|
||||
vapes: Wind,
|
||||
'pre-rolls': Leaf,
|
||||
topicals: Sparkles,
|
||||
tinctures: Droplet,
|
||||
accessories: Package,
|
||||
};
|
||||
|
||||
// Category descriptions
|
||||
const categoryDescriptions = {
|
||||
flower: 'Premium cannabis flower including indica, sativa, and hybrid strains.',
|
||||
concentrates: 'High-potency extracts like wax, shatter, live resin, and rosin.',
|
||||
edibles: 'Cannabis-infused food and beverages for longer-lasting effects.',
|
||||
vapes: 'Convenient vaporizer cartridges and disposable pens.',
|
||||
'pre-rolls': 'Ready-to-smoke joints and blunts for convenience.',
|
||||
topicals: 'Cannabis-infused creams, balms, and lotions for localized relief.',
|
||||
tinctures: 'Sublingual drops for precise dosing and fast absorption.',
|
||||
accessories: 'Pipes, papers, grinders, and other cannabis accessories.',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="gradient-purple text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-4">
|
||||
<Leaf className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Browse by Category</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Product Categories</h1>
|
||||
<p className="text-lg text-purple-100 max-w-2xl mx-auto">
|
||||
Explore cannabis products by category to find exactly what you're looking for.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Category Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = categoryIcons[category.slug] || Leaf;
|
||||
const description = categoryDescriptions[category.slug] || `Browse ${category.name} products.`;
|
||||
|
||||
return (
|
||||
<Link key={category.id} to={`/products?category=${category.id}`} className="group">
|
||||
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1 overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl gradient-purple flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
|
||||
<IconComponent className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-primary transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{category.productCount} products
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popular Products Section */}
|
||||
<section className="mt-12">
|
||||
<div className="bg-white rounded-xl p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Not Sure What to Try?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6 max-w-xl mx-auto">
|
||||
Check out our featured products or browse deals to discover something new.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/products">
|
||||
<button className="gradient-purple text-white px-6 py-3 rounded-lg font-medium hover:opacity-90 transition-opacity">
|
||||
Browse All Products
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/deals">
|
||||
<button className="bg-white border border-primary text-primary px-6 py-3 rounded-lg font-medium hover:bg-primary/5 transition-colors">
|
||||
View Today's Deals
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
259
findagram/frontend/src/pages/findagram/CategoryDetail.jsx
Normal file
259
findagram/frontend/src/pages/findagram/CategoryDetail.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { getCategories, getProducts, mapCategoryForUI, mapProductForUI } from '../../api/client';
|
||||
import { ChevronRight, Grid3X3, List, Loader2 } from 'lucide-react';
|
||||
|
||||
const CategoryDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [sortBy, setSortBy] = useState('featured');
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [strainFilter, setStrainFilter] = useState('all');
|
||||
const [category, setCategory] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategoryData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch categories to find the one matching our slug
|
||||
const categoriesRes = await getCategories();
|
||||
const categories = (categoriesRes.categories || categoriesRes || []).map(mapCategoryForUI);
|
||||
|
||||
// Find matching category (slug is the lowercase type)
|
||||
const matchingCategory = categories.find(
|
||||
(c) => c.slug === slug || c.id?.toLowerCase() === slug
|
||||
);
|
||||
|
||||
if (matchingCategory) {
|
||||
setCategory(matchingCategory);
|
||||
|
||||
// Fetch products for this category type
|
||||
const productsRes = await getProducts({
|
||||
type: matchingCategory.id || slug,
|
||||
limit: 100,
|
||||
});
|
||||
const mappedProducts = (productsRes.products || []).map(mapProductForUI);
|
||||
setProducts(mappedProducts);
|
||||
} else {
|
||||
setCategory(null);
|
||||
setProducts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching category:', err);
|
||||
setError(err.message || 'Failed to load category');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategoryData();
|
||||
}, [slug]);
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
// Filter by strain type
|
||||
const filteredProducts = strainFilter === 'all'
|
||||
? products
|
||||
: products.filter(p => p.strainType?.toLowerCase() === strainFilter.toLowerCase());
|
||||
|
||||
// Sort products
|
||||
const sortedProducts = [...filteredProducts].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'price-low':
|
||||
return (a.salePrice || a.price || 0) - (b.salePrice || b.price || 0);
|
||||
case 'price-high':
|
||||
return (b.salePrice || b.price || 0) - (a.salePrice || a.price || 0);
|
||||
case 'thc-high':
|
||||
return (b.thc || 0) - (a.thc || 0);
|
||||
case 'rating':
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
case 'name':
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading category...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !category) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Category Not Found</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{error || "The category you're looking for doesn't exist."}
|
||||
</p>
|
||||
<Link to="/categories">
|
||||
<Button>Browse Categories</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<nav className="flex items-center space-x-2 text-sm">
|
||||
<Link to="/" className="text-gray-500 hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<Link to="/categories" className="text-gray-500 hover:text-primary">
|
||||
Categories
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-900">{category.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Header */}
|
||||
<section className="gradient-purple text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">{category.name}</h1>
|
||||
<p className="text-lg text-purple-100 max-w-2xl mx-auto">
|
||||
{category.productCount || products.length} products available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<p className="text-gray-600">
|
||||
Showing {sortedProducts.length} {category.name.toLowerCase()} products
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="featured">Featured</SelectItem>
|
||||
<SelectItem value="price-low">Price: Low to High</SelectItem>
|
||||
<SelectItem value="price-high">Price: High to Low</SelectItem>
|
||||
<SelectItem value="thc-high">THC: High to Low</SelectItem>
|
||||
<SelectItem value="rating">Highest Rated</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center border rounded-lg">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strain Type Filter */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Badge
|
||||
variant={strainFilter === 'all' ? 'secondary' : 'outline'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white"
|
||||
onClick={() => setStrainFilter('all')}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={strainFilter === 'sativa' ? 'secondary' : 'outline'}
|
||||
className="cursor-pointer hover:bg-yellow-100"
|
||||
onClick={() => setStrainFilter('sativa')}
|
||||
>
|
||||
Sativa
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={strainFilter === 'indica' ? 'secondary' : 'outline'}
|
||||
className="cursor-pointer hover:bg-purple-100"
|
||||
onClick={() => setStrainFilter('indica')}
|
||||
>
|
||||
Indica
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={strainFilter === 'hybrid' ? 'secondary' : 'outline'}
|
||||
className="cursor-pointer hover:bg-green-100"
|
||||
onClick={() => setStrainFilter('hybrid')}
|
||||
>
|
||||
Hybrid
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{sortedProducts.length > 0 ? (
|
||||
<div
|
||||
className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6'
|
||||
: 'space-y-4'
|
||||
}
|
||||
>
|
||||
{sortedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg">
|
||||
<p className="text-gray-500 mb-4">No {category.name.toLowerCase()} products found.</p>
|
||||
<Link to="/products">
|
||||
<Button>Browse All Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryDetail;
|
||||
220
findagram/frontend/src/pages/findagram/Contact.jsx
Normal file
220
findagram/frontend/src/pages/findagram/Contact.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { Mail, MessageSquare, HelpCircle, Send, Leaf } from 'lucide-react';
|
||||
|
||||
const Contact = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
// In a real app, this would send the form data to a backend
|
||||
console.log('Form submitted:', formData);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'How often is pricing data updated?',
|
||||
answer:
|
||||
'We update our pricing data multiple times per day from dispensary menus across Arizona to ensure accuracy.',
|
||||
},
|
||||
{
|
||||
question: 'Is Find a Gram affiliated with any dispensaries?',
|
||||
answer:
|
||||
'No, we are an independent platform that aggregates data from dispensaries to help consumers find the best products and prices.',
|
||||
},
|
||||
{
|
||||
question: 'How do I report incorrect information?',
|
||||
answer:
|
||||
'Use the contact form on this page or email us at support@findagram.co with details about the incorrect information.',
|
||||
},
|
||||
{
|
||||
question: 'Can dispensaries list their products on Find a Gram?',
|
||||
answer:
|
||||
'We automatically pull data from dispensary menus. Contact us to discuss partnership opportunities.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="gradient-purple text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-4">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Get in Touch</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Contact Us</h1>
|
||||
<p className="text-lg text-purple-100 max-w-2xl mx-auto">
|
||||
Have questions, feedback, or need assistance? We're here to help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid lg:grid-cols-2 gap-12">
|
||||
{/* Contact Form */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="h-5 w-5 text-primary" />
|
||||
Send us a Message
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Leaf className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Message Sent!
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Thank you for reaching out. We'll get back to you as soon as possible.
|
||||
</p>
|
||||
<Button onClick={() => setSubmitted(false)} variant="outline">
|
||||
Send Another Message
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<Select
|
||||
value={formData.subject}
|
||||
onValueChange={(value) => handleChange('subject', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a subject" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="general">General Inquiry</SelectItem>
|
||||
<SelectItem value="feedback">Product Feedback</SelectItem>
|
||||
<SelectItem value="data">Data Correction</SelectItem>
|
||||
<SelectItem value="partnership">Partnership</SelectItem>
|
||||
<SelectItem value="technical">Technical Issue</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={(e) => handleChange('message', e.target.value)}
|
||||
placeholder="How can we help?"
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full gradient-purple">
|
||||
Send Message
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="mt-8 grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Mail className="h-8 w-8 text-primary mx-auto mb-2" />
|
||||
<h4 className="font-medium text-gray-900">Email</h4>
|
||||
<a
|
||||
href="mailto:support@findagram.co"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
support@findagram.co
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<HelpCircle className="h-8 w-8 text-primary mx-auto mb-2" />
|
||||
<h4 className="font-medium text-gray-900">Help Center</h4>
|
||||
<p className="text-sm text-gray-600">Coming soon</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQs */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{faq.question}</h3>
|
||||
<p className="text-sm text-gray-600">{faq.answer}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Contact;
|
||||
300
findagram/frontend/src/pages/findagram/Dashboard.jsx
Normal file
300
findagram/frontend/src/pages/findagram/Dashboard.jsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
mockFavorites,
|
||||
mockAlerts,
|
||||
mockSavedSearches,
|
||||
mockProducts,
|
||||
} from '../../mockData';
|
||||
import {
|
||||
Heart,
|
||||
Bell,
|
||||
Bookmark,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
TrendingDown,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Dashboard = () => {
|
||||
// Get favorite products
|
||||
const favoriteProducts = mockProducts.filter((p) =>
|
||||
mockFavorites.includes(p.id)
|
||||
);
|
||||
|
||||
// Get active alerts
|
||||
const activeAlerts = mockAlerts.filter((a) => a.active);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your favorites, alerts, and saved searches
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Favorites</p>
|
||||
<p className="text-2xl font-bold">{mockFavorites.length}</p>
|
||||
</div>
|
||||
<Heart className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Active Alerts</p>
|
||||
<p className="text-2xl font-bold">{activeAlerts.length}</p>
|
||||
</div>
|
||||
<Bell className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Saved Searches</p>
|
||||
<p className="text-2xl font-bold">{mockSavedSearches.length}</p>
|
||||
</div>
|
||||
<Bookmark className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Price Drops</p>
|
||||
<p className="text-2xl font-bold">3</p>
|
||||
</div>
|
||||
<TrendingDown className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-8">
|
||||
{/* Favorites Preview */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Heart className="h-5 w-5 text-red-500" />
|
||||
Recent Favorites
|
||||
</CardTitle>
|
||||
<Link to="/dashboard/favorites">
|
||||
<Button variant="ghost" size="sm">
|
||||
View All
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{favoriteProducts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{favoriteProducts.slice(0, 3).map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
to={`/products/${product.id}`}
|
||||
className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={product.image || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{product.brand}</p>
|
||||
</div>
|
||||
<p className="font-bold text-primary">
|
||||
${product.price.toFixed(2)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Heart className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">No favorites yet</p>
|
||||
<Link to="/products">
|
||||
<Button variant="outline">Browse Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price Alerts Preview */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Price Alerts
|
||||
</CardTitle>
|
||||
<Link to="/dashboard/alerts">
|
||||
<Button variant="ghost" size="sm">
|
||||
View All
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mockAlerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{mockAlerts.slice(0, 3).map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg bg-gray-50"
|
||||
>
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product?.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Alert at ${alert.targetPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={alert.active ? 'default' : 'secondary'}>
|
||||
{alert.active ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">No price alerts set</p>
|
||||
<Link to="/products">
|
||||
<Button variant="outline">Set an Alert</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Saved Searches Preview */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bookmark className="h-5 w-5 text-indigo-500" />
|
||||
Saved Searches
|
||||
</CardTitle>
|
||||
<Link to="/dashboard/searches">
|
||||
<Button variant="ghost" size="sm">
|
||||
View All
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mockSavedSearches.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{mockSavedSearches.slice(0, 4).map((search) => (
|
||||
<Link
|
||||
key={search.id}
|
||||
to={`/products?${new URLSearchParams(search.filters).toString()}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{search.name}</p>
|
||||
<p className="text-sm text-gray-500">{search.resultCount} results</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Bookmark className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">No saved searches</p>
|
||||
<Link to="/products">
|
||||
<Button variant="outline">Search Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-gray-500" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
to="/dashboard/settings"
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">Account Settings</p>
|
||||
<p className="text-sm text-gray-500">Update your profile and preferences</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/products"
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">Browse Products</p>
|
||||
<p className="text-sm text-gray-500">Discover new cannabis products</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/deals"
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<TrendingDown className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">Today's Deals</p>
|
||||
<p className="text-sm text-gray-500">Find the best prices</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
262
findagram/frontend/src/pages/findagram/Deals.jsx
Normal file
262
findagram/frontend/src/pages/findagram/Deals.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { getDeals, getProducts, mapProductForUI } from '../../api/client';
|
||||
import { Tag, TrendingDown, Clock, Flame, Loader2 } from 'lucide-react';
|
||||
|
||||
const Deals = () => {
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
// API state
|
||||
const [allProducts, setAllProducts] = useState([]);
|
||||
const [dealsProducts, setDealsProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [dealsRes, productsRes] = await Promise.all([
|
||||
getDeals({ limit: 50 }),
|
||||
getProducts({ limit: 50 }),
|
||||
]);
|
||||
|
||||
// Set deals products (products with sale_price)
|
||||
setDealsProducts((dealsRes.products || []).map(mapProductForUI));
|
||||
|
||||
// Set all products for fallback display
|
||||
setAllProducts((productsRes.products || []).map(mapProductForUI));
|
||||
} catch (err) {
|
||||
console.error('Error fetching deals data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
// Use dealsProducts if available, otherwise fall back to allProducts
|
||||
const displayProducts = dealsProducts.length > 0 ? dealsProducts : allProducts;
|
||||
|
||||
// Create some "deal categories" from available products
|
||||
const hotDeals = displayProducts.slice(0, 4);
|
||||
const todayOnly = displayProducts.slice(4, 8);
|
||||
const weeklySpecials = displayProducts.slice(0, 8);
|
||||
|
||||
const filterOptions = [
|
||||
{ id: 'all', label: 'All Deals', icon: Tag },
|
||||
{ id: 'hot', label: 'Hot Deals', icon: Flame },
|
||||
{ id: 'today', label: 'Today Only', icon: Clock },
|
||||
{ id: 'weekly', label: 'Weekly Specials', icon: TrendingDown },
|
||||
];
|
||||
|
||||
const getFilteredProducts = () => {
|
||||
switch (filter) {
|
||||
case 'hot':
|
||||
return hotDeals;
|
||||
case 'today':
|
||||
return todayOnly;
|
||||
case 'weekly':
|
||||
return weeklySpecials;
|
||||
default:
|
||||
return displayProducts;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-pink-500 to-purple-600 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-4">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Daily Deals & Discounts</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4">Today's Best Deals</h1>
|
||||
<p className="text-lg text-pink-100 max-w-2xl mx-auto">
|
||||
Save big on top cannabis products. Prices updated daily from dispensaries near you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">{loading ? '...' : dealsProducts.length > 0 ? `${dealsProducts.length}+` : `${allProducts.length}+`}</p>
|
||||
<p className="text-sm text-gray-600">Products on Sale</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">Up to 40%</p>
|
||||
<p className="text-sm text-gray-600">Savings</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">200+</p>
|
||||
<p className="text-sm text-gray-600">Dispensaries</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">Daily</p>
|
||||
<p className="text-sm text-gray-600">Price Updates</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{filterOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={filter === option.id ? 'default' : 'outline'}
|
||||
onClick={() => setFilter(option.id)}
|
||||
className={filter === option.id ? 'gradient-purple' : ''}
|
||||
>
|
||||
<option.icon className="h-4 w-4 mr-2" />
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Featured Deal Banner */}
|
||||
{filter === 'all' && (
|
||||
<div className="bg-gradient-to-r from-purple-600 to-pink-500 rounded-xl p-6 mb-8 text-white">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<Badge className="bg-white/20 text-white mb-2">Featured Deal</Badge>
|
||||
<h2 className="text-2xl font-bold mb-2">20% Off All Flower This Week</h2>
|
||||
<p className="text-purple-100">
|
||||
Use code FLOWER20 at select dispensaries. Limited time offer.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/products?category=flower">
|
||||
<Button size="lg" className="bg-white text-purple-600 hover:bg-gray-100">
|
||||
Shop Flower
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hot Deals Section */}
|
||||
{(filter === 'all' || filter === 'hot') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Flame className="h-6 w-6 text-orange-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Hot Deals</h2>
|
||||
<Badge variant="deal">Limited Time</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'hot' ? getFilteredProducts() : hotDeals).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Today Only Section */}
|
||||
{(filter === 'all' || filter === 'today') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Clock className="h-6 w-6 text-red-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Today Only</h2>
|
||||
<Badge className="bg-red-100 text-red-800">Ends at Midnight</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'today' ? getFilteredProducts() : todayOnly).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Weekly Specials Section */}
|
||||
{(filter === 'all' || filter === 'weekly') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<TrendingDown className="h-6 w-6 text-green-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Weekly Specials</h2>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'weekly' ? getFilteredProducts() : weeklySpecials).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-white rounded-xl p-8 text-center">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Never Miss a Deal
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-xl mx-auto">
|
||||
Sign up for price alerts and get notified when your favorite products go on sale.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/signup">
|
||||
<Button size="lg" className="gradient-purple text-white">
|
||||
Create Free Account
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/products">
|
||||
<Button size="lg" variant="outline">
|
||||
Browse All Products
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Deals;
|
||||
85
findagram/frontend/src/pages/findagram/Favorites.jsx
Normal file
85
findagram/frontend/src/pages/findagram/Favorites.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { mockFavorites, mockProducts } from '../../mockData';
|
||||
import { Heart, Trash2 } from 'lucide-react';
|
||||
|
||||
const Favorites = () => {
|
||||
const [favorites, setFavorites] = useState(mockFavorites);
|
||||
|
||||
const favoriteProducts = mockProducts.filter((p) => favorites.includes(p.id));
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
const clearAllFavorites = () => {
|
||||
setFavorites([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Heart className="h-8 w-8 text-red-500" />
|
||||
My Favorites
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{favoriteProducts.length} {favoriteProducts.length === 1 ? 'product' : 'products'} saved
|
||||
</p>
|
||||
</div>
|
||||
{favoriteProducts.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearAllFavorites}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{favoriteProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{favoriteProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Heart className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No favorites yet
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
||||
Start adding products to your favorites to easily find them later and track price changes.
|
||||
</p>
|
||||
<Link to="/products">
|
||||
<Button className="gradient-purple">Browse Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Favorites;
|
||||
444
findagram/frontend/src/pages/findagram/Home.jsx
Normal file
444
findagram/frontend/src/pages/findagram/Home.jsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import {
|
||||
getProducts,
|
||||
getDeals,
|
||||
getCategories,
|
||||
getBrands,
|
||||
mapProductForUI,
|
||||
mapCategoryForUI,
|
||||
mapBrandForUI,
|
||||
} from '../../api/client';
|
||||
import {
|
||||
Search,
|
||||
Leaf,
|
||||
TrendingUp,
|
||||
Tag,
|
||||
Star,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
Zap,
|
||||
ShoppingBag,
|
||||
MapPin,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Home = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
|
||||
// API state
|
||||
const [featuredProducts, setFeaturedProducts] = useState([]);
|
||||
const [dealsProducts, setDealsProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [stats, setStats] = useState({
|
||||
products: 0,
|
||||
brands: 0,
|
||||
dispensaries: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [productsRes, dealsRes, categoriesRes, brandsRes] = await Promise.all([
|
||||
getProducts({ limit: 4 }),
|
||||
getDeals({ limit: 4 }),
|
||||
getCategories(),
|
||||
getBrands({ limit: 100 }),
|
||||
]);
|
||||
|
||||
// Set featured products
|
||||
setFeaturedProducts(
|
||||
(productsRes.products || []).map(mapProductForUI)
|
||||
);
|
||||
|
||||
// Set deals products
|
||||
setDealsProducts(
|
||||
(dealsRes.products || []).map(mapProductForUI)
|
||||
);
|
||||
|
||||
// Set categories
|
||||
setCategories(
|
||||
(categoriesRes.categories || []).map(mapCategoryForUI)
|
||||
);
|
||||
|
||||
// Set brands (first 6 as popular)
|
||||
setBrands(
|
||||
(brandsRes.brands || []).slice(0, 6).map(mapBrandForUI)
|
||||
);
|
||||
|
||||
// Set stats
|
||||
setStats({
|
||||
products: productsRes.pagination?.total || 0,
|
||||
brands: brandsRes.pagination?.total || 0,
|
||||
dispensaries: 200, // Hardcoded for now - could add API endpoint
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching home data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/products?search=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites(prev =>
|
||||
prev.includes(productId)
|
||||
? prev.filter(id => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
// Format number for display
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000) {
|
||||
return Math.floor(num / 1000) + ',' + String(num % 1000).padStart(3, '0') + '+';
|
||||
}
|
||||
return num + '+';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<section className="gradient-purple text-white py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2 mb-6">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Find the best cannabis products near you</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-6">
|
||||
Find a <span className="text-purple-200">Gram</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-purple-100 mb-8 max-w-2xl mx-auto">
|
||||
Compare prices, discover new products, and find the best deals at dispensaries near you.
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="max-w-2xl mx-auto">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-4 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products, brands, or strains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 pr-32 h-14 text-lg rounded-full border-0 shadow-lg text-gray-900"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="absolute right-2 h-10 px-6 rounded-full gradient-purple"
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-8">
|
||||
{categories.slice(0, 6).map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/categories/${category.slug}`}
|
||||
className="bg-white/20 hover:bg-white/30 rounded-full px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-8 bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
{loading ? '...' : formatNumber(stats.products)}
|
||||
</p>
|
||||
<p className="text-gray-600">Products</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
{loading ? '...' : formatNumber(stats.brands)}
|
||||
</p>
|
||||
<p className="text-gray-600">Brands</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
{loading ? '...' : formatNumber(stats.dispensaries)}
|
||||
</p>
|
||||
<p className="text-gray-600">Dispensaries</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-primary">Daily</p>
|
||||
<p className="text-gray-600">Price Updates</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Star className="h-6 w-6 text-yellow-500" />
|
||||
Featured Products
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">Top-rated products from trusted brands</p>
|
||||
</div>
|
||||
<Link to="/products">
|
||||
<Button variant="ghost" className="text-primary">
|
||||
View All
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{featuredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deals Section */}
|
||||
<section className="py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Tag className="h-6 w-6 text-pink-500" />
|
||||
Today's Deals
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">Best prices and discounts available now</p>
|
||||
</div>
|
||||
<Link to="/deals">
|
||||
<Button variant="ghost" className="text-primary">
|
||||
View All Deals
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{dealsProducts.length > 0 ? (
|
||||
dealsProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Show featured products if no deals
|
||||
featuredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Browse by Category */}
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Browse by Category</h2>
|
||||
<p className="text-gray-600 mt-1">Find exactly what you're looking for</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.id}
|
||||
to={`/categories/${category.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full gradient-purple flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<Leaf className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{category.productCount} products
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Popular Brands */}
|
||||
<section className="py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
Popular Brands
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">Discover products from top cannabis brands</p>
|
||||
</div>
|
||||
<Link to="/brands">
|
||||
<Button variant="ghost" className="text-primary">
|
||||
All Brands
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{brands.map((brand) => (
|
||||
<Link
|
||||
key={brand.id}
|
||||
to={`/brands/${brand.slug}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{brand.logo ? (
|
||||
<img src={brand.logo} alt={brand.name} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<Leaf className="h-8 w-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 text-sm group-hover:text-primary transition-colors">
|
||||
{brand.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{brand.productCount} products
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 gradient-purple-light">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Why Find a Gram?</h2>
|
||||
<p className="text-gray-600 mt-2">Your one-stop shop for cannabis product discovery</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full gradient-purple flex items-center justify-center">
|
||||
<ShoppingBag className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Compare Prices</h3>
|
||||
<p className="text-gray-600">
|
||||
Find the best prices across multiple dispensaries for any product.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full gradient-purple flex items-center justify-center">
|
||||
<Zap className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Price Alerts</h3>
|
||||
<p className="text-gray-600">
|
||||
Set alerts and get notified when your favorite products go on sale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full gradient-purple flex items-center justify-center">
|
||||
<MapPin className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Find Nearby</h3>
|
||||
<p className="text-gray-600">
|
||||
Discover dispensaries near you that carry the products you want.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 bg-gray-900 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">Ready to find your perfect product?</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-2xl mx-auto">
|
||||
Create a free account to save favorites, set price alerts, and get personalized recommendations.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link to="/signup">
|
||||
<Button size="lg" className="gradient-purple text-white px-8">
|
||||
Get Started Free
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/products">
|
||||
<Button size="lg" variant="outline" className="border-white text-white hover:bg-white hover:text-gray-900 px-8">
|
||||
Browse Products
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
193
findagram/frontend/src/pages/findagram/Login.jsx
Normal file
193
findagram/frontend/src/pages/findagram/Login.jsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../components/ui/card';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Leaf, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const Login = ({ onLogin }) => {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
if (formData.email && formData.password) {
|
||||
onLogin(formData.email, formData.password);
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError('Please fill in all fields');
|
||||
}
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link to="/" className="inline-flex items-center space-x-2">
|
||||
<div className="w-12 h-12 rounded-full gradient-purple flex items-center justify-center">
|
||||
<Leaf className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
Find a <span className="text-primary">Gram</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={formData.rememberMe}
|
||||
onCheckedChange={(checked) => handleChange('rememberMe', checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">Remember me</span>
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full gradient-purple"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0012 2z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" className="text-primary font-medium hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
584
findagram/frontend/src/pages/findagram/ProductDetail.jsx
Normal file
584
findagram/frontend/src/pages/findagram/ProductDetail.jsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import { getProduct, getProducts, getProductAvailability, getSimilarProducts, mapProductForUI } from '../../api/client';
|
||||
import {
|
||||
Heart,
|
||||
Bell,
|
||||
Share2,
|
||||
MapPin,
|
||||
Star,
|
||||
ExternalLink,
|
||||
ChevronRight,
|
||||
Leaf,
|
||||
Droplet,
|
||||
Flame,
|
||||
Wind,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ProductDetail = () => {
|
||||
const { id } = useParams();
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [alertSet, setAlertSet] = useState(false);
|
||||
const [product, setProduct] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// Where to Buy state
|
||||
const [offers, setOffers] = useState([]);
|
||||
const [loadingOffers, setLoadingOffers] = useState(false);
|
||||
const [offersError, setOffersError] = useState(null);
|
||||
const [offersLoaded, setOffersLoaded] = useState(false);
|
||||
const [userLocation, setUserLocation] = useState(null);
|
||||
// Similar Products state
|
||||
const [similarProducts, setSimilarProducts] = useState([]);
|
||||
const [loadingSimilar, setLoadingSimilar] = useState(false);
|
||||
const [similarError, setSimilarError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch the product by ID
|
||||
const data = await getProduct(id);
|
||||
const mappedProduct = mapProductForUI(data);
|
||||
setProduct(mappedProduct);
|
||||
|
||||
// Fetch similar products (same brand + category)
|
||||
setLoadingSimilar(true);
|
||||
setSimilarError(null);
|
||||
try {
|
||||
const similarRes = await getSimilarProducts(id);
|
||||
setSimilarProducts(similarRes.similarProducts || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching similar products:', err);
|
||||
setSimilarError(err.message || 'Failed to load similar products');
|
||||
} finally {
|
||||
setLoadingSimilar(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching product:', err);
|
||||
setError(err.message || 'Failed to load product');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProduct();
|
||||
}, [id]);
|
||||
|
||||
// Function to fetch availability/offers
|
||||
const fetchOffers = async (lat, lng) => {
|
||||
if (offersLoaded && offers.length > 0) return; // Already loaded
|
||||
|
||||
try {
|
||||
setLoadingOffers(true);
|
||||
setOffersError(null);
|
||||
|
||||
const data = await getProductAvailability(id, { lat, lng, maxRadiusMiles: 50 });
|
||||
setOffers(data.offers || []);
|
||||
setOffersLoaded(true);
|
||||
} catch (err) {
|
||||
console.error('Error fetching availability:', err);
|
||||
setOffersError(err.message || 'Failed to load availability');
|
||||
} finally {
|
||||
setLoadingOffers(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tab change - fetch offers when "Where to Buy" is selected
|
||||
const handleTabChange = (value) => {
|
||||
if (value === 'where-to-buy' && !offersLoaded && !loadingOffers) {
|
||||
// Try to get user's location
|
||||
if (userLocation) {
|
||||
fetchOffers(userLocation.lat, userLocation.lng);
|
||||
} else if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
setUserLocation({ lat: latitude, lng: longitude });
|
||||
fetchOffers(latitude, longitude);
|
||||
},
|
||||
(err) => {
|
||||
console.error('Geolocation error:', err);
|
||||
// Default to Phoenix, AZ center if geolocation fails
|
||||
const defaultLat = 33.4484;
|
||||
const defaultLng = -112.0740;
|
||||
setUserLocation({ lat: defaultLat, lng: defaultLng });
|
||||
fetchOffers(defaultLat, defaultLng);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// No geolocation, use Phoenix default
|
||||
const defaultLat = 33.4484;
|
||||
const defaultLng = -112.0740;
|
||||
setUserLocation({ lat: defaultLat, lng: defaultLng });
|
||||
fetchOffers(defaultLat, defaultLng);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading product...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Product Not Found</h2>
|
||||
<p className="text-gray-600 mb-4">{error || "The product you're looking for doesn't exist."}</p>
|
||||
<Link to="/products">
|
||||
<Button>Browse Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const strainColors = {
|
||||
Sativa: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
Indica: 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
Hybrid: 'bg-green-100 text-green-800 border-green-200',
|
||||
};
|
||||
|
||||
const savings = product.onSale && product.salePrice
|
||||
? ((product.price - product.salePrice) / product.price * 100).toFixed(0)
|
||||
: 0;
|
||||
|
||||
// Price range from variants/options if available
|
||||
const lowestPrice = product.priceRange?.min || product.salePrice || product.price || 0;
|
||||
const highestPrice = product.priceRange?.max || product.price || lowestPrice;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<nav className="flex items-center space-x-2 text-sm">
|
||||
<Link to="/" className="text-gray-500 hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<Link to="/products" className="text-gray-500 hover:text-primary">
|
||||
Products
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
{product.category && (
|
||||
<>
|
||||
<Link
|
||||
to={`/categories/${product.category.toLowerCase()}`}
|
||||
className="text-gray-500 hover:text-primary"
|
||||
>
|
||||
{product.category}
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-900">{product.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Product Image */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="relative aspect-square">
|
||||
<img
|
||||
src={product.image || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = '/placeholder-product.jpg';
|
||||
}}
|
||||
/>
|
||||
{product.onSale && savings > 0 && (
|
||||
<Badge variant="deal" className="absolute top-4 left-4 text-sm">
|
||||
{savings}% OFF
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
{/* Brand */}
|
||||
{product.brand && (
|
||||
<Link
|
||||
to={`/brands/${product.brand.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="text-primary hover:underline text-sm font-medium"
|
||||
>
|
||||
{product.brand}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<h1 className="text-3xl font-bold text-gray-900 mt-2 mb-4">{product.name}</h1>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.strainType && (
|
||||
<Badge className={strainColors[product.strainType] || strainColors.Hybrid}>
|
||||
{product.strainType}
|
||||
</Badge>
|
||||
)}
|
||||
{product.category && (
|
||||
<Badge variant="outline">{product.category}</Badge>
|
||||
)}
|
||||
{product.stockStatus && (
|
||||
<Badge variant={product.inStock ? "success" : "secondary"}>
|
||||
{product.inStock ? 'In Stock' : 'Out of Stock'}
|
||||
</Badge>
|
||||
)}
|
||||
{product.storeName && (
|
||||
<Badge variant="secondary">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{product.storeName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rating - if available */}
|
||||
{product.rating && (
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-5 w-5 ${
|
||||
i < Math.floor(product.rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-medium">{product.rating}</span>
|
||||
{product.reviewCount && (
|
||||
<span className="text-gray-500">({product.reviewCount} reviews)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-baseline gap-3">
|
||||
{product.onSale && product.salePrice ? (
|
||||
<>
|
||||
<span className="text-3xl font-bold text-pink-600">
|
||||
${product.salePrice.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 line-through">
|
||||
${product.price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : lowestPrice > 0 ? (
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
${lowestPrice.toFixed(2)}
|
||||
{lowestPrice !== highestPrice && (
|
||||
<span className="text-lg font-normal text-gray-500">
|
||||
{' '}- ${highestPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xl text-gray-500">Price not available</span>
|
||||
)}
|
||||
</div>
|
||||
{product.medPrice && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Medical: ${product.medPrice.toFixed(2)}
|
||||
{product.medSalePrice && ` (Sale: $${product.medSalePrice.toFixed(2)})`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* THC/CBD Info */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-primary mb-1">
|
||||
<Flame className="h-5 w-5" />
|
||||
<span className="font-medium">THC</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
{product.thc ? `${product.thc}%` : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||
<Droplet className="h-5 w-5" />
|
||||
<span className="font-medium">CBD</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
{product.cbd ? `${product.cbd}%` : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<Button
|
||||
variant={isFavorite ? 'default' : 'outline'}
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Heart className={`h-4 w-4 mr-2 ${isFavorite ? 'fill-current' : ''}`} />
|
||||
{isFavorite ? 'Saved' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={alertSet ? 'default' : 'outline'}
|
||||
onClick={() => setAlertSet(!alertSet)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Bell className={`h-4 w-4 mr-2 ${alertSet ? 'fill-current' : ''}`} />
|
||||
{alertSet ? 'Alert Set' : 'Set Price Alert'}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Store Info */}
|
||||
{product.storeName && (
|
||||
<div className="bg-white border rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">Available at</h3>
|
||||
<p className="text-gray-600">{product.storeName}</p>
|
||||
{product.storeCity && (
|
||||
<p className="text-sm text-gray-500">{product.storeCity}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<Tabs defaultValue="details" className="mb-8" onValueChange={handleTabChange}>
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-md">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="options">Options</TabsTrigger>
|
||||
<TabsTrigger value="where-to-buy">Where to Buy</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-4">Product Information</h4>
|
||||
<dl className="space-y-3">
|
||||
{product.category && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Category</dt>
|
||||
<dd className="font-medium">{product.category}</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.subcategory && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Subcategory</dt>
|
||||
<dd className="font-medium">{product.subcategory}</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.strainType && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Strain Type</dt>
|
||||
<dd className="font-medium">{product.strainType}</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.brand && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Brand</dt>
|
||||
<dd className="font-medium">{product.brand}</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.thc && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">THC</dt>
|
||||
<dd className="font-medium">{product.thc}%</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.cbd && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">CBD</dt>
|
||||
<dd className="font-medium">{product.cbd}%</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.stockStatus && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Stock Status</dt>
|
||||
<dd className="font-medium">{product.stockStatus}</dd>
|
||||
</div>
|
||||
)}
|
||||
{product.updatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Last Updated</dt>
|
||||
<dd className="font-medium text-sm">
|
||||
{new Date(product.updatedAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="options" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{product.options && product.options.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900 mb-4">Available Options</h4>
|
||||
{product.options.map((option, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<span className="font-medium">{option.label || option.size || option.weight}</span>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
${(option.price || option.rec_price || 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No variant options available for this product.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="where-to-buy" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{loadingOffers ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mr-2" />
|
||||
<span className="text-gray-600">Finding nearby dispensaries...</span>
|
||||
</div>
|
||||
) : offersError ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-500 mb-2">Error loading availability</p>
|
||||
<p className="text-gray-500 text-sm">{offersError}</p>
|
||||
</div>
|
||||
) : offers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-gray-900 mb-4">
|
||||
Available at {offers.length} dispensar{offers.length === 1 ? 'y' : 'ies'} near you
|
||||
</h4>
|
||||
{offers.map((offer) => (
|
||||
<div
|
||||
key={offer.dispensaryId}
|
||||
className="flex flex-col sm:flex-row justify-between items-start sm:items-center p-4 bg-gray-50 rounded-lg gap-3"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{offer.dispensaryName}</span>
|
||||
{offer.isBestPrice && (
|
||||
<Badge variant="deal" className="text-xs">Best Price</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{offer.city}, {offer.state}</span>
|
||||
<span>•</span>
|
||||
<span>{offer.distanceMiles} mi</span>
|
||||
</div>
|
||||
{offer.stockStatus && offer.stockStatus !== 'in_stock' && (
|
||||
<Badge variant="secondary" className="mt-1 text-xs">
|
||||
{offer.stockStatus === 'out_of_stock' ? 'Out of Stock' : offer.stockStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{offer.price && (
|
||||
<span className="text-lg font-bold text-primary">
|
||||
${offer.price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{offer.menuUrl && (
|
||||
<a
|
||||
href={offer.menuUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
View Menu
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : offersLoaded ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No dispensaries carrying this product found within 50 miles.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Click this tab to find dispensaries near you.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Similar Products */}
|
||||
{similarProducts.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Similar Products</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{similarProducts.map((similarProduct) => (
|
||||
<Link
|
||||
key={similarProduct.productId}
|
||||
to={`/products/${similarProduct.productId}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="overflow-hidden transition-all hover:shadow-lg">
|
||||
<div className="aspect-square bg-gray-100">
|
||||
<img
|
||||
src={similarProduct.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={similarProduct.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = '/placeholder-product.jpg';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-gray-500">{similarProduct.brandName}</p>
|
||||
<h4 className="font-medium group-hover:text-primary line-clamp-2">
|
||||
{similarProduct.name}
|
||||
</h4>
|
||||
<p className="font-bold text-primary mt-2">
|
||||
{similarProduct.price ? `$${similarProduct.price.toFixed(2)}` : 'Price N/A'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
677
findagram/frontend/src/pages/findagram/Products.jsx
Normal file
677
findagram/frontend/src/pages/findagram/Products.jsx
Normal file
@@ -0,0 +1,677 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Slider } from '../../components/ui/slider';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import {
|
||||
getProducts,
|
||||
getCategories,
|
||||
getBrands,
|
||||
mapProductForUI,
|
||||
mapCategoryForUI,
|
||||
mapBrandForUI,
|
||||
} from '../../api/client';
|
||||
import {
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
Grid3X3,
|
||||
List,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Products = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
|
||||
// API data state
|
||||
const [products, setProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [totalProducts, setTotalProducts] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Pagination
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
// Filter states
|
||||
const [selectedCategories, setSelectedCategories] = useState([]);
|
||||
const [selectedBrands, setSelectedBrands] = useState([]);
|
||||
const [selectedStrainTypes, setSelectedStrainTypes] = useState([]);
|
||||
const [priceRange, setPriceRange] = useState([0, 200]);
|
||||
const [thcRange, setThcRange] = useState([0, 100]);
|
||||
const [sortBy, setSortBy] = useState('featured');
|
||||
|
||||
// Debounce timer ref
|
||||
const searchTimeoutRef = useRef(null);
|
||||
|
||||
// Expanded filter sections
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
categories: true,
|
||||
brands: true,
|
||||
strainType: true,
|
||||
price: true,
|
||||
thc: true,
|
||||
});
|
||||
|
||||
const strainTypes = ['Sativa', 'Indica', 'Hybrid'];
|
||||
|
||||
// Fetch categories and brands on mount
|
||||
useEffect(() => {
|
||||
const fetchFilterData = async () => {
|
||||
try {
|
||||
const [categoriesRes, brandsRes] = await Promise.all([
|
||||
getCategories(),
|
||||
getBrands({ limit: 100 }),
|
||||
]);
|
||||
|
||||
// Map categories from API format
|
||||
if (categoriesRes?.categories) {
|
||||
// Group by type (dedupe subcategories for filter)
|
||||
const uniqueTypes = [...new Set(categoriesRes.categories.map(c => c.type))];
|
||||
setCategories(uniqueTypes.map(type => ({
|
||||
id: type,
|
||||
name: formatCategoryName(type),
|
||||
slug: type?.toLowerCase().replace(/\s+/g, '-'),
|
||||
})));
|
||||
}
|
||||
|
||||
// Map brands from API format
|
||||
if (brandsRes?.brands) {
|
||||
setBrands(brandsRes.brands.map(mapBrandForUI));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load filter data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFilterData();
|
||||
}, []);
|
||||
|
||||
// Fetch products when filters change
|
||||
const fetchProducts = useCallback(async (resetOffset = true) => {
|
||||
const currentOffset = resetOffset ? 0 : offset;
|
||||
|
||||
if (resetOffset) {
|
||||
setLoading(true);
|
||||
setOffset(0);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build API params
|
||||
const params = {
|
||||
limit,
|
||||
offset: currentOffset,
|
||||
};
|
||||
|
||||
// Add search if present
|
||||
if (searchQuery.trim()) {
|
||||
params.search = searchQuery.trim();
|
||||
}
|
||||
|
||||
// Add category filter (API uses 'type' field)
|
||||
if (selectedCategories.length === 1) {
|
||||
params.type = selectedCategories[0];
|
||||
}
|
||||
|
||||
// Add brand filter
|
||||
if (selectedBrands.length === 1) {
|
||||
params.brandName = selectedBrands[0];
|
||||
}
|
||||
|
||||
const result = await getProducts(params);
|
||||
|
||||
// Map products to UI format
|
||||
let mappedProducts = (result?.products || []).map(mapProductForUI);
|
||||
|
||||
// Client-side filtering for multiple categories/brands (API only supports single)
|
||||
if (selectedCategories.length > 1) {
|
||||
mappedProducts = mappedProducts.filter(p =>
|
||||
selectedCategories.some(cat =>
|
||||
p.category?.toLowerCase() === cat.toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBrands.length > 1) {
|
||||
mappedProducts = mappedProducts.filter(p =>
|
||||
selectedBrands.some(brand =>
|
||||
p.brand?.toLowerCase() === brand.toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Client-side filtering for strain type
|
||||
if (selectedStrainTypes.length > 0) {
|
||||
mappedProducts = mappedProducts.filter(p =>
|
||||
selectedStrainTypes.some(strain =>
|
||||
p.strainType?.toLowerCase() === strain.toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Client-side filtering for price range
|
||||
mappedProducts = mappedProducts.filter(p => {
|
||||
const price = p.salePrice || p.price;
|
||||
if (price === null || price === undefined) return true;
|
||||
return price >= priceRange[0] && price <= priceRange[1];
|
||||
});
|
||||
|
||||
// Client-side filtering for THC range
|
||||
mappedProducts = mappedProducts.filter(p => {
|
||||
const thc = p.thc;
|
||||
if (thc === null || thc === undefined) return true;
|
||||
return thc >= thcRange[0] && thc <= thcRange[1];
|
||||
});
|
||||
|
||||
if (resetOffset) {
|
||||
setProducts(mappedProducts);
|
||||
} else {
|
||||
setProducts(prev => [...prev, ...mappedProducts]);
|
||||
}
|
||||
setTotalProducts(result?.total || 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch products:', err);
|
||||
setError(err.message || 'Failed to load products');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [searchQuery, selectedCategories, selectedBrands, selectedStrainTypes, priceRange, thcRange, offset]);
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
fetchProducts(true);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, selectedCategories, selectedBrands, selectedStrainTypes, priceRange, thcRange]);
|
||||
|
||||
// Sort products client-side
|
||||
const sortedProducts = [...products].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'price-low':
|
||||
return ((a.salePrice || a.price) || 0) - ((b.salePrice || b.price) || 0);
|
||||
case 'price-high':
|
||||
return ((b.salePrice || b.price) || 0) - ((a.salePrice || a.price) || 0);
|
||||
case 'thc-high':
|
||||
return (b.thc || 0) - (a.thc || 0);
|
||||
case 'rating':
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
case 'name':
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSection = (section) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategories([]);
|
||||
setSelectedBrands([]);
|
||||
setSelectedStrainTypes([]);
|
||||
setPriceRange([0, 200]);
|
||||
setThcRange([0, 100]);
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
const newOffset = offset + limit;
|
||||
setOffset(newOffset);
|
||||
fetchProducts(false);
|
||||
};
|
||||
|
||||
const activeFilterCount =
|
||||
selectedCategories.length +
|
||||
selectedBrands.length +
|
||||
selectedStrainTypes.length +
|
||||
(priceRange[0] > 0 || priceRange[1] < 200 ? 1 : 0) +
|
||||
(thcRange[0] > 0 || thcRange[1] < 100 ? 1 : 0);
|
||||
|
||||
const hasMore = products.length < totalProducts;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="md:hidden"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge className="ml-2">{activeFilterCount}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="featured">Featured</SelectItem>
|
||||
<SelectItem value="price-low">Price: Low to High</SelectItem>
|
||||
<SelectItem value="price-high">Price: High to Low</SelectItem>
|
||||
<SelectItem value="thc-high">THC: High to Low</SelectItem>
|
||||
<SelectItem value="rating">Highest Rated</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="hidden md:flex items-center border rounded-lg">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<span className="text-sm text-gray-500">Active filters:</span>
|
||||
{selectedCategories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="gap-1">
|
||||
{formatCategoryName(cat)}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() =>
|
||||
setSelectedCategories((prev) => prev.filter((c) => c !== cat))
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedBrands.map((brand) => (
|
||||
<Badge key={brand} variant="secondary" className="gap-1">
|
||||
{brand}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() =>
|
||||
setSelectedBrands((prev) => prev.filter((b) => b !== brand))
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedStrainTypes.map((strain) => (
|
||||
<Badge key={strain} variant="secondary" className="gap-1">
|
||||
{strain}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() =>
|
||||
setSelectedStrainTypes((prev) => prev.filter((s) => s !== strain))
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-6">
|
||||
{/* Filters Sidebar */}
|
||||
<div
|
||||
className={`${
|
||||
showFilters ? 'block' : 'hidden'
|
||||
} md:block w-full md:w-64 shrink-0`}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 space-y-6 sticky top-32">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900">Filters</h3>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('categories')}
|
||||
className="flex items-center justify-between w-full py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Categories
|
||||
{expandedSections.categories ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.categories && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedCategories.includes(category.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedCategories((prev) => [...prev, category.id]);
|
||||
} else {
|
||||
setSelectedCategories((prev) =>
|
||||
prev.filter((c) => c !== category.id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Strain Type */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('strainType')}
|
||||
className="flex items-center justify-between w-full py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Strain Type
|
||||
{expandedSections.strainType ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.strainType && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{strainTypes.map((strain) => (
|
||||
<label
|
||||
key={strain}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedStrainTypes.includes(strain)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedStrainTypes((prev) => [...prev, strain]);
|
||||
} else {
|
||||
setSelectedStrainTypes((prev) =>
|
||||
prev.filter((s) => s !== strain)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{strain}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('price')}
|
||||
className="flex items-center justify-between w-full py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Price Range
|
||||
{expandedSections.price ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.price && (
|
||||
<div className="mt-4 px-2">
|
||||
<Slider
|
||||
value={priceRange}
|
||||
onValueChange={setPriceRange}
|
||||
max={200}
|
||||
step={5}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>${priceRange[0]}</span>
|
||||
<span>${priceRange[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* THC Range */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('thc')}
|
||||
className="flex items-center justify-between w-full py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
THC %
|
||||
{expandedSections.thc ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.thc && (
|
||||
<div className="mt-4 px-2">
|
||||
<Slider
|
||||
value={thcRange}
|
||||
onValueChange={setThcRange}
|
||||
max={100}
|
||||
step={1}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>{thcRange[0]}%</span>
|
||||
<span>{thcRange[1]}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brands */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSection('brands')}
|
||||
className="flex items-center justify-between w-full py-2 text-sm font-medium text-gray-900"
|
||||
>
|
||||
Brands
|
||||
{expandedSections.brands ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections.brands && (
|
||||
<div className="space-y-2 mt-2 max-h-48 overflow-y-auto">
|
||||
{brands.map((brand) => (
|
||||
<label
|
||||
key={brand.id}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedBrands.includes(brand.name)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedBrands((prev) => [...prev, brand.name]);
|
||||
} else {
|
||||
setSelectedBrands((prev) =>
|
||||
prev.filter((b) => b !== brand.name)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{brand.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="flex-1">
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-600">
|
||||
{loading ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
`${sortedProducts.length} of ${totalProducts} ${totalProducts === 1 ? 'product' : 'products'} found`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={() => fetchProducts(true)}>Try Again</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products */}
|
||||
{!loading && !error && sortedProducts.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}
|
||||
>
|
||||
{sortedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="text-center mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && sortedProducts.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 mb-4">No products found matching your criteria.</p>
|
||||
<Button onClick={clearFilters}>Clear Filters</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to format category names
|
||||
function formatCategoryName(type) {
|
||||
if (!type) return '';
|
||||
return type
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export default Products;
|
||||
288
findagram/frontend/src/pages/findagram/Profile.jsx
Normal file
288
findagram/frontend/src/pages/findagram/Profile.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../components/ui/card';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../../components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
|
||||
import { Settings, User, Bell, Shield, Camera, Save } from 'lucide-react';
|
||||
|
||||
const Profile = () => {
|
||||
const [profile, setProfile] = useState({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phone: '',
|
||||
location: 'Phoenix, AZ',
|
||||
});
|
||||
|
||||
const [notifications, setNotifications] = useState({
|
||||
priceAlerts: true,
|
||||
newProducts: true,
|
||||
deals: true,
|
||||
newsletter: false,
|
||||
});
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleProfileChange = (field, value) => {
|
||||
setProfile((prev) => ({ ...prev, [field]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleNotificationChange = (field, value) => {
|
||||
setNotifications((prev) => ({ ...prev, [field]: value }));
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// In a real app, this would make an API call
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Settings className="h-8 w-8 text-gray-500" />
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your profile and preferences
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Tabs defaultValue="profile">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-8">
|
||||
<TabsTrigger value="profile" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Security
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Profile Tab */}
|
||||
<TabsContent value="profile">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your personal information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src="" />
|
||||
<AvatarFallback className="text-2xl bg-primary text-white">
|
||||
{profile.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button variant="outline">
|
||||
<Camera className="h-4 w-4 mr-2" />
|
||||
Change Photo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<Input
|
||||
value={profile.name}
|
||||
onChange={(e) => handleProfileChange('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => handleProfileChange('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={(e) => handleProfileChange('phone', e.target.value)}
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location
|
||||
</label>
|
||||
<Input
|
||||
value={profile.location}
|
||||
onChange={(e) => handleProfileChange('location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} className="gradient-purple">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saved ? 'Saved!' : 'Save Changes'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notifications Tab */}
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how you want to be notified
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={notifications.priceAlerts}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationChange('priceAlerts', checked)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Price Alerts</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Get notified when products in your alerts drop to your target price
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={notifications.newProducts}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationChange('newProducts', checked)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">New Products</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Get notified when your favorite brands release new products
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={notifications.deals}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationChange('deals', checked)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Deals & Promotions</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Receive weekly deals and special promotions
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={notifications.newsletter}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNotificationChange('newsletter', checked)
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Newsletter</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Monthly newsletter with industry news and product highlights
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} className="gradient-purple">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saved ? 'Saved!' : 'Save Preferences'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Tab */}
|
||||
<TabsContent value="security">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
<Input type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<Input type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<Input type="password" />
|
||||
</div>
|
||||
<Button className="gradient-purple">Update Password</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible account actions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="text-red-600 border-red-600 hover:bg-red-50">
|
||||
Delete Account
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
123
findagram/frontend/src/pages/findagram/SavedSearches.jsx
Normal file
123
findagram/frontend/src/pages/findagram/SavedSearches.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockSavedSearches } from '../../mockData';
|
||||
import { Bookmark, Search, Trash2, ChevronRight } from 'lucide-react';
|
||||
|
||||
const SavedSearches = () => {
|
||||
const [searches, setSearches] = useState(mockSavedSearches);
|
||||
|
||||
const deleteSearch = (searchId) => {
|
||||
setSearches((prev) => prev.filter((search) => search.id !== searchId));
|
||||
};
|
||||
|
||||
const buildSearchUrl = (filters) => {
|
||||
const params = new URLSearchParams(filters);
|
||||
return `/products?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Bookmark className="h-8 w-8 text-indigo-500" />
|
||||
Saved Searches
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{searches.length} {searches.length === 1 ? 'search' : 'searches'} saved
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/products">
|
||||
<Button className="gradient-purple">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
New Search
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{searches.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{searches.map((search) => (
|
||||
<Card key={search.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<Search className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900">{search.name}</h3>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{search.filters.category && (
|
||||
<Badge variant="secondary">{search.filters.category}</Badge>
|
||||
)}
|
||||
{search.filters.strainType && (
|
||||
<Badge variant="outline">{search.filters.strainType}</Badge>
|
||||
)}
|
||||
{search.filters.priceMax && (
|
||||
<Badge variant="outline">Under ${search.filters.priceMax}</Badge>
|
||||
)}
|
||||
{search.filters.thcMin && (
|
||||
<Badge variant="outline">THC {search.filters.thcMin}%+</Badge>
|
||||
)}
|
||||
{search.filters.search && (
|
||||
<Badge variant="secondary">"{search.filters.search}"</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">{search.resultCount} results</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Saved {new Date(search.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={buildSearchUrl(search.filters)}>
|
||||
<Button variant="outline" size="sm">
|
||||
Run Search
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteSearch(search.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete search"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Bookmark className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No saved searches
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
||||
Save your searches to quickly access your favorite product filters and find what you're looking for faster.
|
||||
</p>
|
||||
<Link to="/products">
|
||||
<Button className="gradient-purple">Start Searching</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SavedSearches;
|
||||
262
findagram/frontend/src/pages/findagram/Signup.jsx
Normal file
262
findagram/frontend/src/pages/findagram/Signup.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../components/ui/card';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import { Leaf, Mail, Lock, User, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const Signup = ({ onLogin }) => {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreeTerms: false,
|
||||
ageVerified: false,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (!formData.agreeTerms) {
|
||||
setError('You must agree to the terms of service');
|
||||
return;
|
||||
}
|
||||
if (!formData.ageVerified) {
|
||||
setError('You must confirm you are 21 years or older');
|
||||
return;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
onLogin(formData.email, formData.password);
|
||||
navigate('/dashboard');
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link to="/" className="inline-flex items-center space-x-2">
|
||||
<div className="w-12 h-12 rounded-full gradient-purple flex items-center justify-center">
|
||||
<Leaf className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
Find a <span className="text-primary">Gram</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>
|
||||
Sign up to save favorites, set alerts, and more
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={formData.ageVerified}
|
||||
onCheckedChange={(checked) => handleChange('ageVerified', checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
I confirm that I am 21 years of age or older
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start space-x-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={formData.agreeTerms}
|
||||
onCheckedChange={(checked) => handleChange('agreeTerms', checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
I agree to the{' '}
|
||||
<Link to="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link to="/privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full gradient-purple"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or sign up with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0012 2z" />
|
||||
</svg>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-primary font-medium hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Signup;
|
||||
104
findagram/frontend/tailwind.config.js
Normal file
104
findagram/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
"./public/index.html"
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "#8B5CF6",
|
||||
50: "#F5F3FF",
|
||||
100: "#EDE9FE",
|
||||
200: "#DDD6FE",
|
||||
300: "#C4B5FD",
|
||||
400: "#A78BFA",
|
||||
500: "#8B5CF6",
|
||||
600: "#7C3AED",
|
||||
700: "#6D28D9",
|
||||
800: "#5B21B6",
|
||||
900: "#4C1D95",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#6366F1",
|
||||
50: "#EEF2FF",
|
||||
100: "#E0E7FF",
|
||||
200: "#C7D2FE",
|
||||
300: "#A5B4FC",
|
||||
400: "#818CF8",
|
||||
500: "#6366F1",
|
||||
600: "#4F46E5",
|
||||
700: "#4338CA",
|
||||
800: "#3730A3",
|
||||
900: "#312E81",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#EC4899",
|
||||
50: "#FDF2F8",
|
||||
100: "#FCE7F3",
|
||||
200: "#FBCFE8",
|
||||
300: "#F9A8D4",
|
||||
400: "#F472B6",
|
||||
500: "#EC4899",
|
||||
600: "#DB2777",
|
||||
700: "#BE185D",
|
||||
800: "#9D174D",
|
||||
900: "#831843",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
Reference in New Issue
Block a user