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:
Kelly
2025-12-05 16:10:15 -07:00
parent d120a07ed7
commit a0f8d3911c
179 changed files with 140234 additions and 600 deletions

View File

@@ -0,0 +1,9 @@
__pycache__
*.pyc
*.pyo
.env
.env.local
.venv
venv
*.log
.DS_Store

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

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

View File

@@ -0,0 +1 @@
# Find a Gram Backend

View 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()

View 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()

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

View 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")

View 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())

View 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"])

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

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

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

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

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

View 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",
]

View 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

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

View 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

View File

@@ -0,0 +1,7 @@
node_modules
build
.env.local
.env.*.local
npm-debug.log*
.DS_Store
*.log

View File

@@ -0,0 +1,3 @@
# Development environment
# API URL for local development
REACT_APP_API_URL=http://localhost:3010

View 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

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

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

View 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"]
}

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

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

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

View 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">
&copy; {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;

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

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

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

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

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

View 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 };

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

View 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 };

View 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 };

View 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;
}

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

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View 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"
];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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")],
}