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:
18
findadispo/backend/.env.example
Normal file
18
findadispo/backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/findadispo
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY=your-super-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# External API
|
||||
DISPENSARY_API_URL=http://localhost:3010
|
||||
DISPENSARY_API_KEY=your-api-key
|
||||
|
||||
# CORS
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
139
findadispo/backend/auth.py
Normal file
139
findadispo/backend/auth.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from config import get_settings
|
||||
from database import get_db
|
||||
from models import User
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||
|
||||
|
||||
# Pydantic models
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: str
|
||||
phone: Optional[str] = None
|
||||
default_location: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
default_location: Optional[str] = None
|
||||
notify_price_alerts: Optional[bool] = None
|
||||
notify_new_dispensaries: Optional[bool] = None
|
||||
notify_weekly_digest: Optional[bool] = None
|
||||
notify_promotions: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
phone: Optional[str]
|
||||
default_location: Optional[str]
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
notify_price_alerts: bool
|
||||
notify_new_dispensaries: bool
|
||||
notify_weekly_digest: bool
|
||||
notify_promotions: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
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, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
user = get_user_by_email(db, email)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def create_user(db: Session, user_data: UserCreate) -> User:
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
db_user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.full_name,
|
||||
phone=user_data.phone,
|
||||
default_location=user_data.default_location,
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(email=email)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = get_user_by_email(db, email=token_data.email)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
31
findadispo/backend/config.py
Normal file
31
findadispo/backend/config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
database_url: str = "postgresql://user:password@localhost:5432/findadispo"
|
||||
|
||||
# JWT Settings
|
||||
secret_key: str = "your-super-secret-key-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# External API
|
||||
dispensary_api_url: str = "http://localhost:3010"
|
||||
dispensary_api_key: str = ""
|
||||
|
||||
# CORS
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
25
findadispo/backend/database.py
Normal file
25
findadispo/backend/database.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency to get database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database tables"""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
77
findadispo/backend/models.py
Normal file
77
findadispo/backend/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from 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)
|
||||
full_name = Column(String(255))
|
||||
phone = Column(String(50))
|
||||
default_location = Column(String(255))
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_verified = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Notification preferences
|
||||
notify_price_alerts = Column(Boolean, default=True)
|
||||
notify_new_dispensaries = Column(Boolean, default=False)
|
||||
notify_weekly_digest = Column(Boolean, default=True)
|
||||
notify_promotions = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
saved_searches = relationship("SavedSearch", back_populates="user", cascade="all, delete-orphan")
|
||||
alerts = relationship("PriceAlert", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SavedSearch(Base):
|
||||
__tablename__ = "saved_searches"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
query = Column(String(255))
|
||||
filters = Column(JSON) # Store filters as JSON
|
||||
results_count = Column(Integer, default=0)
|
||||
last_used = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", back_populates="saved_searches")
|
||||
|
||||
|
||||
class PriceAlert(Base):
|
||||
__tablename__ = "price_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
product_name = Column(String(255), nullable=False)
|
||||
dispensary_id = Column(Integer)
|
||||
dispensary_name = Column(String(255))
|
||||
target_price = Column(Float, nullable=False)
|
||||
current_price = Column(Float)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_triggered = Column(Boolean, default=False)
|
||||
triggered_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", back_populates="alerts")
|
||||
|
||||
|
||||
class ContactMessage(Base):
|
||||
__tablename__ = "contact_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
email = Column(String(255), nullable=False)
|
||||
subject = Column(String(255))
|
||||
message = Column(Text, nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
11
findadispo/backend/requirements.txt
Normal file
11
findadispo/backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.6
|
||||
pydantic[email]==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.26.0
|
||||
alembic==1.13.1
|
||||
1
findadispo/backend/routes/__init__.py
Normal file
1
findadispo/backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
234
findadispo/backend/routes/alerts_routes.py
Normal file
234
findadispo/backend/routes/alerts_routes.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_active_user
|
||||
from models import User, PriceAlert
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["Price Alerts"])
|
||||
|
||||
|
||||
class AlertCreate(BaseModel):
|
||||
product_name: str
|
||||
dispensary_id: Optional[int] = None
|
||||
dispensary_name: Optional[str] = None
|
||||
target_price: float
|
||||
current_price: Optional[float] = None
|
||||
|
||||
|
||||
class AlertUpdate(BaseModel):
|
||||
target_price: Optional[float] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class AlertResponse(BaseModel):
|
||||
id: int
|
||||
product_name: str
|
||||
dispensary_id: Optional[int]
|
||||
dispensary_name: Optional[str]
|
||||
target_price: float
|
||||
current_price: Optional[float]
|
||||
is_active: bool
|
||||
is_triggered: bool
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AlertResponse])
|
||||
def get_alerts(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all price alerts for the current user"""
|
||||
alerts = db.query(PriceAlert).filter(
|
||||
PriceAlert.user_id == current_user.id
|
||||
).order_by(PriceAlert.created_at.desc()).all()
|
||||
|
||||
return [
|
||||
AlertResponse(
|
||||
id=a.id,
|
||||
product_name=a.product_name,
|
||||
dispensary_id=a.dispensary_id,
|
||||
dispensary_name=a.dispensary_name,
|
||||
target_price=a.target_price,
|
||||
current_price=a.current_price,
|
||||
is_active=a.is_active,
|
||||
is_triggered=a.is_triggered,
|
||||
created_at=a.created_at.isoformat() if a.created_at else ""
|
||||
)
|
||||
for a in alerts
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=AlertResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_alert(
|
||||
alert_data: AlertCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new price alert"""
|
||||
alert = PriceAlert(
|
||||
user_id=current_user.id,
|
||||
product_name=alert_data.product_name,
|
||||
dispensary_id=alert_data.dispensary_id,
|
||||
dispensary_name=alert_data.dispensary_name,
|
||||
target_price=alert_data.target_price,
|
||||
current_price=alert_data.current_price,
|
||||
is_triggered=alert_data.current_price and alert_data.current_price <= alert_data.target_price
|
||||
)
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return AlertResponse(
|
||||
id=alert.id,
|
||||
product_name=alert.product_name,
|
||||
dispensary_id=alert.dispensary_id,
|
||||
dispensary_name=alert.dispensary_name,
|
||||
target_price=alert.target_price,
|
||||
current_price=alert.current_price,
|
||||
is_active=alert.is_active,
|
||||
is_triggered=alert.is_triggered,
|
||||
created_at=alert.created_at.isoformat() if alert.created_at else ""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{alert_id}", response_model=AlertResponse)
|
||||
def get_alert(
|
||||
alert_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific price alert"""
|
||||
alert = db.query(PriceAlert).filter(
|
||||
PriceAlert.id == alert_id,
|
||||
PriceAlert.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Alert not found"
|
||||
)
|
||||
|
||||
return AlertResponse(
|
||||
id=alert.id,
|
||||
product_name=alert.product_name,
|
||||
dispensary_id=alert.dispensary_id,
|
||||
dispensary_name=alert.dispensary_name,
|
||||
target_price=alert.target_price,
|
||||
current_price=alert.current_price,
|
||||
is_active=alert.is_active,
|
||||
is_triggered=alert.is_triggered,
|
||||
created_at=alert.created_at.isoformat() if alert.created_at else ""
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{alert_id}", response_model=AlertResponse)
|
||||
def update_alert(
|
||||
alert_id: int,
|
||||
alert_update: AlertUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a price alert"""
|
||||
alert = db.query(PriceAlert).filter(
|
||||
PriceAlert.id == alert_id,
|
||||
PriceAlert.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Alert not found"
|
||||
)
|
||||
|
||||
update_data = alert_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(alert, field, value)
|
||||
|
||||
# Check if alert should be triggered
|
||||
if alert.current_price and alert.current_price <= alert.target_price:
|
||||
alert.is_triggered = True
|
||||
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return AlertResponse(
|
||||
id=alert.id,
|
||||
product_name=alert.product_name,
|
||||
dispensary_id=alert.dispensary_id,
|
||||
dispensary_name=alert.dispensary_name,
|
||||
target_price=alert.target_price,
|
||||
current_price=alert.current_price,
|
||||
is_active=alert.is_active,
|
||||
is_triggered=alert.is_triggered,
|
||||
created_at=alert.created_at.isoformat() if alert.created_at else ""
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{alert_id}/toggle")
|
||||
def toggle_alert(
|
||||
alert_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Toggle alert active status"""
|
||||
alert = db.query(PriceAlert).filter(
|
||||
PriceAlert.id == alert_id,
|
||||
PriceAlert.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Alert not found"
|
||||
)
|
||||
|
||||
alert.is_active = not alert.is_active
|
||||
db.commit()
|
||||
return {"message": f"Alert {'activated' if alert.is_active else 'deactivated'}"}
|
||||
|
||||
|
||||
@router.delete("/{alert_id}")
|
||||
def delete_alert(
|
||||
alert_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a price alert"""
|
||||
alert = db.query(PriceAlert).filter(
|
||||
PriceAlert.id == alert_id,
|
||||
PriceAlert.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Alert not found"
|
||||
)
|
||||
|
||||
db.delete(alert)
|
||||
db.commit()
|
||||
return {"message": "Alert deleted"}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
def get_alert_stats(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get alert statistics for dashboard"""
|
||||
alerts = db.query(PriceAlert).filter(
|
||||
PriceAlert.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
return {
|
||||
"total": len(alerts),
|
||||
"active": len([a for a in alerts if a.is_active]),
|
||||
"triggered": len([a for a in alerts if a.is_triggered]),
|
||||
}
|
||||
108
findadispo/backend/routes/auth_routes.py
Normal file
108
findadispo/backend/routes/auth_routes.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from config import get_settings
|
||||
from auth import (
|
||||
UserCreate, UserUpdate, UserResponse, Token,
|
||||
authenticate_user, create_user, create_access_token,
|
||||
get_user_by_email, get_current_active_user, get_password_hash
|
||||
)
|
||||
from models import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def signup(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""Register a new user"""
|
||||
# Check if user exists
|
||||
existing_user = get_user_by_email(db, user_data.email)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = create_user(db, user_data)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Login and get access token"""
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.email}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user_info(current_user: User = Depends(get_current_active_user)):
|
||||
"""Get current user information"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
def update_current_user(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update current user information"""
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Change user password"""
|
||||
from auth import verify_password
|
||||
|
||||
if not verify_password(current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Incorrect current password"
|
||||
)
|
||||
|
||||
current_user.hashed_password = get_password_hash(new_password)
|
||||
db.commit()
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/me")
|
||||
def delete_account(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete user account"""
|
||||
db.delete(current_user)
|
||||
db.commit()
|
||||
return {"message": "Account deleted successfully"}
|
||||
49
findadispo/backend/routes/contact_routes.py
Normal file
49
findadispo/backend/routes/contact_routes.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from database import get_db
|
||||
from models import ContactMessage
|
||||
|
||||
router = APIRouter(prefix="/contact", tags=["Contact"])
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
subject: str
|
||||
message: str
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
subject: str
|
||||
message: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
def submit_contact(
|
||||
contact_data: ContactCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Submit a contact form message"""
|
||||
message = ContactMessage(
|
||||
name=contact_data.name,
|
||||
email=contact_data.email,
|
||||
subject=contact_data.subject,
|
||||
message=contact_data.message
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return {
|
||||
"message": "Thank you for your message! We'll get back to you soon.",
|
||||
"id": message.id
|
||||
}
|
||||
164
findadispo/backend/routes/dispensary_routes.py
Normal file
164
findadispo/backend/routes/dispensary_routes.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/dispensaries", tags=["Dispensaries"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DispensaryQuery(BaseModel):
|
||||
lat: Optional[float] = None
|
||||
lng: Optional[float] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
radius: Optional[int] = 25
|
||||
limit: Optional[int] = 20
|
||||
offset: Optional[int] = 0
|
||||
|
||||
|
||||
async def fetch_from_api(endpoint: str, params: dict = None):
|
||||
"""Fetch data from the external dispensary API"""
|
||||
headers = {}
|
||||
if settings.dispensary_api_key:
|
||||
headers["X-API-Key"] = settings.dispensary_api_key
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{settings.dispensary_api_url}{endpoint}",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=30.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"API error: {e.response.text}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Failed to connect to dispensary API: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def search_dispensaries(
|
||||
lat: Optional[float] = None,
|
||||
lng: Optional[float] = None,
|
||||
city: Optional[str] = None,
|
||||
state: Optional[str] = "AZ",
|
||||
radius: int = 25,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
open_now: bool = False,
|
||||
min_rating: Optional[float] = None
|
||||
):
|
||||
"""Search for dispensaries by location"""
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"state": state
|
||||
}
|
||||
|
||||
if lat and lng:
|
||||
params["lat"] = lat
|
||||
params["lng"] = lng
|
||||
params["radius"] = radius
|
||||
|
||||
if city:
|
||||
params["city"] = city
|
||||
|
||||
# Fetch from external API
|
||||
data = await fetch_from_api("/api/az/stores", params)
|
||||
|
||||
# Apply client-side filters if needed
|
||||
stores = data.get("stores", [])
|
||||
|
||||
if open_now:
|
||||
# Filter stores that are currently open
|
||||
# This would need actual business hours logic
|
||||
pass
|
||||
|
||||
if min_rating:
|
||||
stores = [s for s in stores if (s.get("rating") or 0) >= min_rating]
|
||||
|
||||
return {
|
||||
"dispensaries": stores,
|
||||
"total": len(stores),
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{dispensary_id}")
|
||||
async def get_dispensary(dispensary_id: int):
|
||||
"""Get details for a specific dispensary"""
|
||||
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}")
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{dispensary_id}/products")
|
||||
async def get_dispensary_products(
|
||||
dispensary_id: int,
|
||||
category: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get products for a specific dispensary"""
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if category:
|
||||
params["category"] = category
|
||||
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/products", params)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{dispensary_id}/categories")
|
||||
async def get_dispensary_categories(dispensary_id: int):
|
||||
"""Get product categories for a dispensary"""
|
||||
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/categories")
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/nearby")
|
||||
async def get_nearby_dispensaries(
|
||||
lat: float,
|
||||
lng: float,
|
||||
radius: int = 10,
|
||||
limit: int = 10
|
||||
):
|
||||
"""Get nearby dispensaries by coordinates"""
|
||||
params = {
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
"limit": limit
|
||||
}
|
||||
data = await fetch_from_api("/api/az/stores", params)
|
||||
return data.get("stores", [])
|
||||
|
||||
|
||||
@router.get("/featured")
|
||||
async def get_featured_dispensaries(limit: int = 6):
|
||||
"""Get featured dispensaries for the homepage"""
|
||||
# For now, return top-rated dispensaries
|
||||
params = {
|
||||
"limit": limit,
|
||||
"sort": "rating"
|
||||
}
|
||||
data = await fetch_from_api("/api/az/stores", params)
|
||||
return data.get("stores", [])
|
||||
201
findadispo/backend/routes/search_routes.py
Normal file
201
findadispo/backend/routes/search_routes.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_active_user
|
||||
from models import User, SavedSearch
|
||||
|
||||
router = APIRouter(prefix="/searches", tags=["Saved Searches"])
|
||||
|
||||
|
||||
class SavedSearchCreate(BaseModel):
|
||||
name: str
|
||||
query: str
|
||||
filters: Optional[dict] = None
|
||||
results_count: Optional[int] = 0
|
||||
|
||||
|
||||
class SavedSearchUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
filters: Optional[dict] = None
|
||||
results_count: Optional[int] = None
|
||||
|
||||
|
||||
class SavedSearchResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
query: str
|
||||
filters: Optional[dict]
|
||||
results_count: int
|
||||
last_used: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[SavedSearchResponse])
|
||||
def get_saved_searches(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all saved searches for the current user"""
|
||||
searches = db.query(SavedSearch).filter(
|
||||
SavedSearch.user_id == current_user.id
|
||||
).order_by(SavedSearch.last_used.desc()).all()
|
||||
|
||||
return [
|
||||
SavedSearchResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
query=s.query,
|
||||
filters=s.filters,
|
||||
results_count=s.results_count,
|
||||
last_used=s.last_used.isoformat() if s.last_used else ""
|
||||
)
|
||||
for s in searches
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=SavedSearchResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_saved_search(
|
||||
search_data: SavedSearchCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new saved search"""
|
||||
search = SavedSearch(
|
||||
user_id=current_user.id,
|
||||
name=search_data.name,
|
||||
query=search_data.query,
|
||||
filters=search_data.filters,
|
||||
results_count=search_data.results_count
|
||||
)
|
||||
db.add(search)
|
||||
db.commit()
|
||||
db.refresh(search)
|
||||
|
||||
return SavedSearchResponse(
|
||||
id=search.id,
|
||||
name=search.name,
|
||||
query=search.query,
|
||||
filters=search.filters,
|
||||
results_count=search.results_count,
|
||||
last_used=search.last_used.isoformat() if search.last_used else ""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{search_id}", response_model=SavedSearchResponse)
|
||||
def get_saved_search(
|
||||
search_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific saved search"""
|
||||
search = db.query(SavedSearch).filter(
|
||||
SavedSearch.id == search_id,
|
||||
SavedSearch.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not search:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Saved search not found"
|
||||
)
|
||||
|
||||
return SavedSearchResponse(
|
||||
id=search.id,
|
||||
name=search.name,
|
||||
query=search.query,
|
||||
filters=search.filters,
|
||||
results_count=search.results_count,
|
||||
last_used=search.last_used.isoformat() if search.last_used else ""
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{search_id}", response_model=SavedSearchResponse)
|
||||
def update_saved_search(
|
||||
search_id: int,
|
||||
search_update: SavedSearchUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a saved search"""
|
||||
search = db.query(SavedSearch).filter(
|
||||
SavedSearch.id == search_id,
|
||||
SavedSearch.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not search:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Saved search not found"
|
||||
)
|
||||
|
||||
update_data = search_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(search, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(search)
|
||||
|
||||
return SavedSearchResponse(
|
||||
id=search.id,
|
||||
name=search.name,
|
||||
query=search.query,
|
||||
filters=search.filters,
|
||||
results_count=search.results_count,
|
||||
last_used=search.last_used.isoformat() if search.last_used else ""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{search_id}/use")
|
||||
def mark_search_used(
|
||||
search_id: int,
|
||||
results_count: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark a saved search as used (updates last_used timestamp)"""
|
||||
search = db.query(SavedSearch).filter(
|
||||
SavedSearch.id == search_id,
|
||||
SavedSearch.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not search:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Saved search not found"
|
||||
)
|
||||
|
||||
search.last_used = func.now()
|
||||
if results_count is not None:
|
||||
search.results_count = results_count
|
||||
|
||||
db.commit()
|
||||
return {"message": "Search marked as used"}
|
||||
|
||||
|
||||
@router.delete("/{search_id}")
|
||||
def delete_saved_search(
|
||||
search_id: int,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a saved search"""
|
||||
search = db.query(SavedSearch).filter(
|
||||
SavedSearch.id == search_id,
|
||||
SavedSearch.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not search:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Saved search not found"
|
||||
)
|
||||
|
||||
db.delete(search)
|
||||
db.commit()
|
||||
return {"message": "Saved search deleted"}
|
||||
81
findadispo/backend/server.py
Normal file
81
findadispo/backend/server.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
from config import get_settings
|
||||
from database import init_db
|
||||
from routes.auth_routes import router as auth_router
|
||||
from routes.search_routes import router as search_router
|
||||
from routes.alerts_routes import router as alerts_router
|
||||
from routes.dispensary_routes import router as dispensary_router
|
||||
from routes.contact_routes import router as contact_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Find a Dispensary API",
|
||||
description="Backend API for Find a Dispensary - Cannabis dispensary locator",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
settings.frontend_url,
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"https://findadispo.com",
|
||||
"https://www.findadispo.com"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(search_router, prefix="/api")
|
||||
app.include_router(alerts_router, prefix="/api")
|
||||
app.include_router(dispensary_router, prefix="/api")
|
||||
app.include_router(contact_router, prefix="/api")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize database on startup"""
|
||||
init_db()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""Root endpoint"""
|
||||
return {
|
||||
"name": "Find a Dispensary API",
|
||||
"version": "1.0.0",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
def api_version():
|
||||
"""API version endpoint"""
|
||||
return {"version": "1.0.0"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"server:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=True
|
||||
)
|
||||
14
findadispo/frontend/.env.example
Normal file
14
findadispo/frontend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Findadispo Frontend Environment Variables
|
||||
# Copy this file to .env.development or .env.production
|
||||
|
||||
# API URL for dispensary data endpoints (public API)
|
||||
# Local development: http://localhost:3010
|
||||
# Production: https://dispos.crawlsy.com (or your production API URL)
|
||||
REACT_APP_DATA_API_URL=http://localhost:3010
|
||||
|
||||
# API Key for accessing the /api/v1/* endpoints
|
||||
# Get this from the backend admin panel or database
|
||||
REACT_APP_DATA_API_KEY=your_api_key_here
|
||||
|
||||
# Backend URL (for other backend services if needed)
|
||||
REACT_APP_BACKEND_URL=http://localhost:8001
|
||||
52
findadispo/frontend/Dockerfile
Normal file
52
findadispo/frontend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (using npm install since package-lock.json may not exist)
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Set build-time environment variable for API URL (CRA uses REACT_APP_ prefix)
|
||||
ENV REACT_APP_API_URL=https://api.findadispo.com
|
||||
|
||||
# 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;"]
|
||||
62
findadispo/frontend/package.json
Normal file
62
findadispo/frontend/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "findadispo-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@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-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@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",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
findadispo/frontend/postcss.config.js
Normal file
6
findadispo/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
17
findadispo/frontend/public/index.html
Normal file
17
findadispo/frontend/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!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="#10B981" />
|
||||
<meta name="description" content="Find licensed cannabis dispensaries near you. Search by location, compare ratings, and discover the best dispensaries in your area." />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Find a Dispensary - Cannabis Dispensary Locator</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
29
findadispo/frontend/public/manifest.json
Normal file
29
findadispo/frontend/public/manifest.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"short_name": "Find a Dispo",
|
||||
"name": "Find a Dispensary - Cannabis Locator",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#10B981",
|
||||
"background_color": "#ffffff",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["lifestyle", "shopping"]
|
||||
}
|
||||
113
findadispo/frontend/public/service-worker.js
Normal file
113
findadispo/frontend/public/service-worker.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Find a Dispensary PWA Service Worker
|
||||
const CACHE_NAME = 'findadispo-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
console.log('Service Worker: Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => {
|
||||
console.log('Service Worker: Clearing old cache', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - network first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
// Skip API requests
|
||||
if (event.request.url.includes('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Clone response for caching
|
||||
const responseClone = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Return cached version if offline
|
||||
return caches.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
// Return offline page for navigation requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for saved searches
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-searches') {
|
||||
console.log('Service Worker: Syncing saved searches');
|
||||
}
|
||||
});
|
||||
|
||||
// Push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: event.data?.text() || 'New update from Find a Dispensary',
|
||||
icon: '/logo192.png',
|
||||
badge: '/logo192.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
},
|
||||
actions: [
|
||||
{ action: 'explore', title: 'View Details' },
|
||||
{ action: 'close', title: 'Close' }
|
||||
]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('Find a Dispensary', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle notification click
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'explore') {
|
||||
event.waitUntil(
|
||||
clients.openWindow('/dashboard/alerts')
|
||||
);
|
||||
}
|
||||
});
|
||||
130
findadispo/frontend/src/App.js
Normal file
130
findadispo/frontend/src/App.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
|
||||
// Layout Components
|
||||
import { Header } from './components/findadispo/Header';
|
||||
import { Footer } from './components/findadispo/Footer';
|
||||
|
||||
// Page Components
|
||||
import { Home } from './pages/findadispo/Home';
|
||||
import { StoreLocator } from './pages/findadispo/StoreLocator';
|
||||
import { DispensaryDetail } from './pages/findadispo/DispensaryDetail';
|
||||
import { About } from './pages/findadispo/About';
|
||||
import { Contact } from './pages/findadispo/Contact';
|
||||
import { Login } from './pages/findadispo/Login';
|
||||
import { Signup } from './pages/findadispo/Signup';
|
||||
|
||||
// Dashboard Components
|
||||
import { Dashboard } from './pages/findadispo/Dashboard';
|
||||
import { DashboardHome } from './pages/findadispo/DashboardHome';
|
||||
import { SavedSearches } from './pages/findadispo/SavedSearches';
|
||||
import { Alerts } from './pages/findadispo/Alerts';
|
||||
import { Profile } from './pages/findadispo/Profile';
|
||||
|
||||
// Protected Route Component
|
||||
function ProtectedRoute({ children }) {
|
||||
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
// Main Layout with Header and Footer
|
||||
function MainLayout({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dashboard Layout (no footer, custom header handled in Dashboard component)
|
||||
function DashboardLayout({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public Routes with Main Layout */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<MainLayout>
|
||||
<Home />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/store-locator"
|
||||
element={
|
||||
<MainLayout>
|
||||
<StoreLocator />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dispensary/:slug"
|
||||
element={
|
||||
<MainLayout>
|
||||
<DispensaryDetail />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/about"
|
||||
element={
|
||||
<MainLayout>
|
||||
<About />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contact"
|
||||
element={
|
||||
<MainLayout>
|
||||
<Contact />
|
||||
</MainLayout>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Auth Routes (no header/footer) */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
|
||||
{/* Dashboard Routes (protected) */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout>
|
||||
<Dashboard />
|
||||
</DashboardLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardHome />} />
|
||||
<Route path="saved" element={<SavedSearches />} />
|
||||
<Route path="alerts" element={<Alerts />} />
|
||||
<Route path="profile" element={<Profile />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
290
findadispo/frontend/src/api/client.js
Normal file
290
findadispo/frontend/src/api/client.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// Findadispo API Client
|
||||
// Connects to /api/v1/* endpoints with X-API-Key authentication
|
||||
|
||||
import { API_CONFIG } from '../lib/utils';
|
||||
|
||||
const API_BASE_URL = API_CONFIG.DATA_API_URL;
|
||||
const API_KEY = API_CONFIG.DATA_API_KEY;
|
||||
|
||||
// Helper function to make authenticated API requests
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(API_KEY && { 'X-API-Key': API_KEY }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `API request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dispensaries with optional filters
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {string} params.search - Search query (name, city, zip)
|
||||
* @param {string} params.state - State filter
|
||||
* @param {string} params.city - City filter
|
||||
* @param {number} params.limit - Results per page
|
||||
* @param {number} params.offset - Pagination offset
|
||||
* @returns {Promise<{dispensaries: Array, total: number, limit: number, offset: number}>}
|
||||
*/
|
||||
export async function getDispensaries(params = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.search) queryParams.append('search', params.search);
|
||||
if (params.state) queryParams.append('state', params.state);
|
||||
if (params.city) queryParams.append('city', params.city);
|
||||
if (params.limit) queryParams.append('limit', params.limit);
|
||||
if (params.offset) queryParams.append('offset', params.offset);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const endpoint = `/api/v1/dispensaries${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
return apiRequest(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single dispensary by slug or ID
|
||||
* @param {string} slugOrId - Dispensary slug or ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getDispensaryBySlug(slugOrId) {
|
||||
return apiRequest(`/api/v1/dispensaries/${slugOrId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dispensary by ID
|
||||
* @param {number} id - Dispensary ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getDispensaryById(id) {
|
||||
return apiRequest(`/api/v1/dispensaries/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API dispensary response to UI format
|
||||
* Converts snake_case API fields to camelCase UI fields
|
||||
* and adds any default values for missing data
|
||||
* @param {Object} apiDispensary - Dispensary from API
|
||||
* @returns {Object} - Dispensary formatted for UI
|
||||
*/
|
||||
export function mapDispensaryForUI(apiDispensary) {
|
||||
// Build full address from components
|
||||
const addressParts = [
|
||||
apiDispensary.address,
|
||||
apiDispensary.city,
|
||||
apiDispensary.state,
|
||||
apiDispensary.zip
|
||||
].filter(Boolean);
|
||||
const fullAddress = addressParts.join(', ');
|
||||
|
||||
// Format hours for display
|
||||
let hoursDisplay = 'Hours not available';
|
||||
if (apiDispensary.hours) {
|
||||
if (typeof apiDispensary.hours === 'string') {
|
||||
hoursDisplay = apiDispensary.hours;
|
||||
} else if (apiDispensary.hours.formatted) {
|
||||
hoursDisplay = apiDispensary.hours.formatted;
|
||||
} else {
|
||||
// Try to format from day-by-day structure
|
||||
hoursDisplay = formatHoursFromObject(apiDispensary.hours);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if currently open based on hours
|
||||
const isOpen = checkIfOpen(apiDispensary.hours);
|
||||
|
||||
// Handle location from nested object or flat fields
|
||||
const lat = apiDispensary.location?.latitude || apiDispensary.latitude;
|
||||
const lng = apiDispensary.location?.longitude || apiDispensary.longitude;
|
||||
|
||||
return {
|
||||
id: apiDispensary.id,
|
||||
name: apiDispensary.dba_name || apiDispensary.name,
|
||||
slug: apiDispensary.slug || generateSlug(apiDispensary.name),
|
||||
address: fullAddress,
|
||||
city: apiDispensary.city,
|
||||
state: apiDispensary.state,
|
||||
zip: apiDispensary.zip,
|
||||
phone: apiDispensary.phone || null,
|
||||
hours: hoursDisplay,
|
||||
hoursData: apiDispensary.hours || null, // Keep raw hours data for open/close logic
|
||||
rating: apiDispensary.rating || 0,
|
||||
reviews: apiDispensary.review_count || 0,
|
||||
distance: apiDispensary.distance || null,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
image: apiDispensary.image_url || 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop',
|
||||
isOpen: isOpen,
|
||||
amenities: apiDispensary.amenities || [],
|
||||
description: apiDispensary.description || 'Cannabis dispensary',
|
||||
website: apiDispensary.website,
|
||||
menuUrl: apiDispensary.menu_url,
|
||||
menuType: apiDispensary.menu_type || apiDispensary.platform,
|
||||
productCount: apiDispensary.product_count || 0,
|
||||
inStockCount: apiDispensary.in_stock_count || 0,
|
||||
lastUpdated: apiDispensary.last_updated,
|
||||
dataAvailable: apiDispensary.data_available ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-friendly slug from dispensary name
|
||||
*/
|
||||
function generateSlug(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hours from day-by-day object to readable string
|
||||
*/
|
||||
function formatHoursFromObject(hours) {
|
||||
if (!hours || typeof hours !== 'object') return 'Hours not available';
|
||||
|
||||
// Try to create a simple string like "Mon-Sat 9am-9pm"
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
const shortDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
let result = [];
|
||||
for (let i = 0; i < days.length; i++) {
|
||||
const dayData = hours[days[i]];
|
||||
if (dayData && dayData.open && dayData.close) {
|
||||
result.push(`${shortDays[i]}: ${formatTime(dayData.open)}-${formatTime(dayData.close)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result.join(', ') : 'Hours not available';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format 24hr time to 12hr format
|
||||
*/
|
||||
function formatTime(time) {
|
||||
if (!time) return '';
|
||||
const [hours, minutes] = time.split(':');
|
||||
const hour = parseInt(hours, 10);
|
||||
const suffix = hour >= 12 ? 'pm' : 'am';
|
||||
const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
|
||||
return minutes === '00' ? `${displayHour}${suffix}` : `${displayHour}:${minutes}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dispensary is currently open based on hours data
|
||||
*/
|
||||
function checkIfOpen(hours) {
|
||||
if (!hours || typeof hours !== 'object') return true; // Default to open if no data
|
||||
|
||||
const now = new Date();
|
||||
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
const today = dayNames[now.getDay()];
|
||||
const todayHours = hours[today];
|
||||
|
||||
if (!todayHours || !todayHours.open || !todayHours.close) return true;
|
||||
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
const [openHour, openMin] = todayHours.open.split(':').map(Number);
|
||||
const [closeHour, closeMin] = todayHours.close.split(':').map(Number);
|
||||
|
||||
const openTime = openHour * 60 + (openMin || 0);
|
||||
const closeTime = closeHour * 60 + (closeMin || 0);
|
||||
|
||||
return currentTime >= openTime && currentTime <= closeTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search dispensaries by query and filters
|
||||
* This function provides a mockData-compatible interface
|
||||
* @param {string} query - Search query
|
||||
* @param {Object} filters - Filters (openNow, minRating, maxDistance, amenities)
|
||||
* @returns {Promise<Array>} - Array of dispensaries
|
||||
*/
|
||||
export async function searchDispensaries(query, filters = {}) {
|
||||
try {
|
||||
const params = {
|
||||
search: query || undefined,
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
const response = await getDispensaries(params);
|
||||
let dispensaries = (response.dispensaries || []).map(mapDispensaryForUI);
|
||||
|
||||
// Apply client-side filters that aren't supported by API
|
||||
if (filters.openNow) {
|
||||
dispensaries = dispensaries.filter(d => d.isOpen);
|
||||
}
|
||||
|
||||
if (filters.minRating) {
|
||||
dispensaries = dispensaries.filter(d => d.rating >= filters.minRating);
|
||||
}
|
||||
|
||||
if (filters.maxDistance && filters.maxDistance < 100) {
|
||||
dispensaries = dispensaries.filter(d => !d.distance || d.distance <= filters.maxDistance);
|
||||
}
|
||||
|
||||
if (filters.amenities && filters.amenities.length > 0) {
|
||||
dispensaries = dispensaries.filter(d =>
|
||||
filters.amenities.every(amenity => d.amenities.includes(amenity))
|
||||
);
|
||||
}
|
||||
|
||||
return dispensaries;
|
||||
} catch (error) {
|
||||
console.error('Error searching dispensaries:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of unique cities for filter dropdown
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function getCities() {
|
||||
try {
|
||||
const response = await getDispensaries({ limit: 500 });
|
||||
const cities = [...new Set(
|
||||
(response.dispensaries || [])
|
||||
.map(d => d.city)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
)];
|
||||
return cities;
|
||||
} catch (error) {
|
||||
console.error('Error fetching cities:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of unique states for filter dropdown
|
||||
* @returns {Promise<Array<string>>}
|
||||
*/
|
||||
export async function getStates() {
|
||||
try {
|
||||
const response = await getDispensaries({ limit: 500 });
|
||||
const states = [...new Set(
|
||||
(response.dispensaries || [])
|
||||
.map(d => d.state)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
)];
|
||||
return states;
|
||||
} catch (error) {
|
||||
console.error('Error fetching states:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
119
findadispo/frontend/src/components/findadispo/Footer.jsx
Normal file
119
findadispo/frontend/src/components/findadispo/Footer.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MapPin, Mail, Phone } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-900 text-gray-300">
|
||||
<div className="container px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Brand */}
|
||||
<div className="space-y-4">
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<MapPin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-white">Find a Dispensary</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-400">
|
||||
Helping you discover licensed cannabis dispensaries near you.
|
||||
Find trusted locations, compare options, and access quality products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/" className="text-sm hover:text-primary transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/store-locator" className="text-sm hover:text-primary transition-colors">
|
||||
Store Locator
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-sm hover:text-primary transition-colors">
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/contact" className="text-sm hover:text-primary transition-colors">
|
||||
Contact
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Account</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/login" className="text-sm hover:text-primary transition-colors">
|
||||
Log In
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/signup" className="text-sm hover:text-primary transition-colors">
|
||||
Sign Up
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/dashboard" className="text-sm hover:text-primary transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/dashboard/alerts" className="text-sm hover:text-primary transition-colors">
|
||||
Price Alerts
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Contact Us</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center space-x-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
<a href="mailto:support@findadispo.com" className="hover:text-primary transition-colors">
|
||||
support@findadispo.com
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center space-x-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-primary" />
|
||||
<span>(555) 123-4567</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-gray-800 mt-8 pt-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-sm text-gray-500">
|
||||
{currentYear} Find a Dispensary. All rights reserved.
|
||||
</p>
|
||||
<div className="flex space-x-6">
|
||||
<Link to="/privacy" className="text-sm text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link to="/terms" className="text-sm text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
128
findadispo/frontend/src/components/findadispo/Header.jsx
Normal file
128
findadispo/frontend/src/components/findadispo/Header.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { MapPin, Menu, X, User, LogIn } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Mock auth state - replace with real auth context
|
||||
const isAuthenticated = false;
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/store-locator', label: 'Store Locator' },
|
||||
{ href: '/about', label: 'About' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
];
|
||||
|
||||
const isActive = (path) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||
<div className="container flex h-16 items-center justify-between px-4">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<MapPin className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold text-gray-900">Find a Dispensary</span>
|
||||
<span className="text-xs text-gray-500 -mt-1">Cannabis Locator</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
to={link.href}
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive(link.href) ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Auth Buttons */}
|
||||
<div className="hidden md:flex items-center space-x-3">
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard">
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login">
|
||||
<Button variant="ghost" size="sm">Log in</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button size="sm" className="flex items-center gap-2">
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t bg-white">
|
||||
<nav className="container px-4 py-4 space-y-3">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
to={link.href}
|
||||
className={`block py-2 text-sm font-medium transition-colors ${
|
||||
isActive(link.href) ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-3 border-t space-y-2">
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="outline" className="w-full">Dashboard</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="outline" className="w-full">Log in</Button>
|
||||
</Link>
|
||||
<Link to="/signup" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button className="w-full">Sign up</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
35
findadispo/frontend/src/components/ui/badge.jsx
Normal file
35
findadispo/frontend/src/components/ui/badge.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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-100 text-green-800",
|
||||
warning:
|
||||
"border-transparent bg-yellow-100 text-yellow-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
46
findadispo/frontend/src/components/ui/button.jsx
Normal file
46
findadispo/frontend/src/components/ui/button.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
60
findadispo/frontend/src/components/ui/card.jsx
Normal file
60
findadispo/frontend/src/components/ui/card.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
22
findadispo/frontend/src/components/ui/checkbox.jsx
Normal file
22
findadispo/frontend/src/components/ui/checkbox.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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 };
|
||||
19
findadispo/frontend/src/components/ui/input.jsx
Normal file
19
findadispo/frontend/src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
19
findadispo/frontend/src/components/ui/label.jsx
Normal file
19
findadispo/frontend/src/components/ui/label.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
22
findadispo/frontend/src/components/ui/separator.jsx
Normal file
22
findadispo/frontend/src/components/ui/separator.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Separator = React.forwardRef(
|
||||
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
22
findadispo/frontend/src/components/ui/slider.jsx
Normal file
22
findadispo/frontend/src/components/ui/slider.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/20">
|
||||
<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 };
|
||||
23
findadispo/frontend/src/components/ui/switch.jsx
Normal file
23
findadispo/frontend/src/components/ui/switch.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
18
findadispo/frontend/src/components/ui/textarea.jsx
Normal file
18
findadispo/frontend/src/components/ui/textarea.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] 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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
56
findadispo/frontend/src/index.css
Normal file
56
findadispo/frontend/src/index.css
Normal file
@@ -0,0 +1,56 @@
|
||||
@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: 160 84% 39%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217 91% 60%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 38 92% 50%;
|
||||
--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: 160 84% 39%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
24
findadispo/frontend/src/index.js
Normal file
24
findadispo/frontend/src/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('SW registered:', registration);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('SW registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
89
findadispo/frontend/src/lib/utils.js
Normal file
89
findadispo/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Format distance for display
|
||||
export function formatDistance(miles) {
|
||||
if (miles < 0.1) {
|
||||
return "< 0.1 mi";
|
||||
}
|
||||
return `${miles.toFixed(1)} mi`;
|
||||
}
|
||||
|
||||
// Format phone number
|
||||
export function formatPhone(phone) {
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
// Check if dispensary is currently open based on hours string
|
||||
export function isCurrentlyOpen(hoursString) {
|
||||
// This is a simplified check - in production you'd parse actual hours
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generate star rating display
|
||||
export function getStarRating(rating) {
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
return { fullStars, hasHalfStar, emptyStars: 5 - fullStars - (hasHalfStar ? 1 : 0) };
|
||||
}
|
||||
|
||||
// Debounce function for search
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
export function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
export function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// Validate phone format
|
||||
export function isValidPhone(phone) {
|
||||
const phoneRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
// Get initials from name
|
||||
export function getInitials(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
// API configuration
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: process.env.REACT_APP_BACKEND_URL || 'http://localhost:8001',
|
||||
DATA_API_URL: process.env.REACT_APP_DATA_API_URL || 'http://localhost:3010',
|
||||
DATA_API_KEY: process.env.REACT_APP_DATA_API_KEY || ''
|
||||
};
|
||||
234
findadispo/frontend/src/mockData.js
Normal file
234
findadispo/frontend/src/mockData.js
Normal file
@@ -0,0 +1,234 @@
|
||||
// Mock dispensary data for development
|
||||
export const mockDispensaries = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Green Haven Dispensary",
|
||||
slug: "green-haven",
|
||||
address: "123 Main St, Phoenix, AZ 85001",
|
||||
phone: "(602) 555-0123",
|
||||
hours: "9:00 AM - 9:00 PM",
|
||||
rating: 4.8,
|
||||
reviews: 342,
|
||||
distance: 1.2,
|
||||
lat: 33.4484,
|
||||
lng: -112.0740,
|
||||
image: "https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Wheelchair Access", "ATM"],
|
||||
description: "Premium cannabis dispensary offering a wide selection of flower, edibles, and concentrates."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Desert Bloom Cannabis",
|
||||
slug: "desert-bloom",
|
||||
address: "456 Oak Ave, Scottsdale, AZ 85251",
|
||||
phone: "(480) 555-0456",
|
||||
hours: "10:00 AM - 8:00 PM",
|
||||
rating: 4.6,
|
||||
reviews: 218,
|
||||
distance: 2.5,
|
||||
lat: 33.4942,
|
||||
lng: -111.9261,
|
||||
image: "https://images.unsplash.com/photo-1603909223429-69bb7c5a7e97?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Online Ordering"],
|
||||
description: "Your neighborhood dispensary with knowledgeable staff and quality products."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Cactus Wellness",
|
||||
slug: "cactus-wellness",
|
||||
address: "789 Cactus Rd, Tempe, AZ 85281",
|
||||
phone: "(480) 555-0789",
|
||||
hours: "8:00 AM - 10:00 PM",
|
||||
rating: 4.9,
|
||||
reviews: 567,
|
||||
distance: 3.1,
|
||||
lat: 33.4255,
|
||||
lng: -111.9400,
|
||||
image: "https://images.unsplash.com/photo-1585063560070-e4e3f0b5e0e1?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Wheelchair Access", "ATM", "Online Ordering"],
|
||||
description: "Award-winning dispensary focused on wellness and patient education."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Mountain High Dispensary",
|
||||
slug: "mountain-high",
|
||||
address: "321 Summit Blvd, Mesa, AZ 85201",
|
||||
phone: "(480) 555-0321",
|
||||
hours: "9:00 AM - 9:00 PM",
|
||||
rating: 4.4,
|
||||
reviews: 156,
|
||||
distance: 4.2,
|
||||
lat: 33.4152,
|
||||
lng: -111.8315,
|
||||
image: "https://images.unsplash.com/photo-1616690710400-a16d146927c5?w=400&h=300&fit=crop",
|
||||
isOpen: false,
|
||||
amenities: ["Parking", "ATM"],
|
||||
description: "Locally owned dispensary with competitive prices and daily deals."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Valley Verde",
|
||||
slug: "valley-verde",
|
||||
address: "555 Valley View Dr, Glendale, AZ 85301",
|
||||
phone: "(623) 555-0555",
|
||||
hours: "10:00 AM - 9:00 PM",
|
||||
rating: 4.7,
|
||||
reviews: 289,
|
||||
distance: 5.8,
|
||||
lat: 33.5387,
|
||||
lng: -112.1860,
|
||||
image: "https://images.unsplash.com/photo-1558642452-9d2a7deb7f62?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Wheelchair Access", "Online Ordering"],
|
||||
description: "Family-friendly atmosphere with a focus on medical cannabis."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Sunrise Cannabis Co",
|
||||
slug: "sunrise-cannabis",
|
||||
address: "888 Sunrise Blvd, Chandler, AZ 85225",
|
||||
phone: "(480) 555-0888",
|
||||
hours: "7:00 AM - 11:00 PM",
|
||||
rating: 4.5,
|
||||
reviews: 423,
|
||||
distance: 6.3,
|
||||
lat: 33.3062,
|
||||
lng: -111.8413,
|
||||
image: "https://images.unsplash.com/photo-1571166585747-8b6e1a93d2a7?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Drive-Through", "ATM", "Online Ordering"],
|
||||
description: "Open early for your convenience with drive-through service available."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Oasis Dispensary",
|
||||
slug: "oasis-dispensary",
|
||||
address: "222 Palm Lane, Gilbert, AZ 85234",
|
||||
phone: "(480) 555-0222",
|
||||
hours: "9:00 AM - 8:00 PM",
|
||||
rating: 4.3,
|
||||
reviews: 178,
|
||||
distance: 7.1,
|
||||
lat: 33.3528,
|
||||
lng: -111.7890,
|
||||
image: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=300&fit=crop",
|
||||
isOpen: true,
|
||||
amenities: ["Parking", "Wheelchair Access"],
|
||||
description: "Relaxing environment with a curated selection of premium products."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Copper State Cannabis",
|
||||
slug: "copper-state",
|
||||
address: "444 Copper Ave, Tucson, AZ 85701",
|
||||
phone: "(520) 555-0444",
|
||||
hours: "10:00 AM - 7:00 PM",
|
||||
rating: 4.6,
|
||||
reviews: 312,
|
||||
distance: 8.5,
|
||||
lat: 32.2226,
|
||||
lng: -110.9747,
|
||||
image: "https://images.unsplash.com/photo-1601055903647-ddf1ee9701b7?w=400&h=300&fit=crop",
|
||||
isOpen: false,
|
||||
amenities: ["Parking", "ATM", "Online Ordering"],
|
||||
description: "Tucson's premier cannabis destination with Arizona-grown products."
|
||||
}
|
||||
];
|
||||
|
||||
// Mock saved searches for dashboard
|
||||
export const mockSavedSearches = [
|
||||
{
|
||||
id: 1,
|
||||
query: "Phoenix dispensaries",
|
||||
filters: { distance: 5, rating: 4 },
|
||||
createdAt: "2024-01-15T10:30:00Z"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
query: "Open now Scottsdale",
|
||||
filters: { openNow: true },
|
||||
createdAt: "2024-01-10T14:20:00Z"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
query: "Dispensaries with parking",
|
||||
filters: { amenities: ["Parking"] },
|
||||
createdAt: "2024-01-05T09:15:00Z"
|
||||
}
|
||||
];
|
||||
|
||||
// Mock alerts for dashboard
|
||||
export const mockAlerts = [
|
||||
{
|
||||
id: 1,
|
||||
dispensaryName: "Green Haven Dispensary",
|
||||
alertType: "price_drop",
|
||||
notifyVia: ["email"],
|
||||
active: true,
|
||||
createdAt: "2024-01-12T11:00:00Z"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
dispensaryName: "Desert Bloom Cannabis",
|
||||
alertType: "new_location",
|
||||
notifyVia: ["email", "sms"],
|
||||
active: true,
|
||||
createdAt: "2024-01-08T16:45:00Z"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
dispensaryName: "Cactus Wellness",
|
||||
alertType: "price_drop",
|
||||
notifyVia: ["sms"],
|
||||
active: false,
|
||||
createdAt: "2024-01-01T08:30:00Z"
|
||||
}
|
||||
];
|
||||
|
||||
// Mock user data
|
||||
export const mockUser = {
|
||||
id: 1,
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
phone: "(555) 123-4567",
|
||||
notifications: {
|
||||
email: true,
|
||||
sms: false,
|
||||
marketing: false
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get dispensary by slug
|
||||
export const getDispensaryBySlug = (slug) => {
|
||||
return mockDispensaries.find(d => d.slug === slug);
|
||||
};
|
||||
|
||||
// Helper function to search dispensaries
|
||||
export const searchDispensaries = (query, filters = {}) => {
|
||||
let results = [...mockDispensaries];
|
||||
|
||||
if (query) {
|
||||
const searchTerm = query.toLowerCase();
|
||||
results = results.filter(d =>
|
||||
d.name.toLowerCase().includes(searchTerm) ||
|
||||
d.address.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.openNow) {
|
||||
results = results.filter(d => d.isOpen);
|
||||
}
|
||||
|
||||
if (filters.minRating) {
|
||||
results = results.filter(d => d.rating >= filters.minRating);
|
||||
}
|
||||
|
||||
if (filters.maxDistance) {
|
||||
results = results.filter(d => d.distance <= filters.maxDistance);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
133
findadispo/frontend/src/pages/findadispo/About.jsx
Normal file
133
findadispo/frontend/src/pages/findadispo/About.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { MapPin, Shield, Users, TrendingUp } from 'lucide-react';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
|
||||
export function About() {
|
||||
const stats = [
|
||||
{ label: 'Dispensaries Tracked', value: '10,000+' },
|
||||
{ label: 'States Covered', value: '35' },
|
||||
{ label: 'Monthly Users', value: '500K+' },
|
||||
{ label: 'Data Accuracy', value: '99.9%' },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Accurate Location Data',
|
||||
description: 'Real-time, verified dispensary information including hours, contact details, and precise locations.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Licensed Only',
|
||||
description: 'We only list state-licensed dispensaries, ensuring you access safe, legal cannabis products.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Community Driven',
|
||||
description: 'User reviews and ratings help you find the best dispensaries in your area.',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Price Tracking',
|
||||
description: 'Save money with our price alerts and discover deals at dispensaries near you.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-b from-primary/5 to-white py-20">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
About Find a Dispensary
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
We're on a mission to make finding quality cannabis products simple, safe, and accessible.
|
||||
Our platform connects consumers with licensed dispensaries across the country.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<p className="text-4xl font-bold text-primary">{stat.value}</p>
|
||||
<p className="text-gray-600 mt-2">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-6">Our Mission</h2>
|
||||
<p className="text-lg text-gray-600 leading-relaxed">
|
||||
As the cannabis industry continues to grow and evolve, we believe everyone deserves
|
||||
access to accurate, up-to-date information about licensed dispensaries in their area.
|
||||
Our platform was built to bridge the gap between consumers and quality cannabis products,
|
||||
making it easier than ever to find trusted dispensaries that meet your needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">
|
||||
Why Choose Us
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{features.map((feature, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<feature.icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{feature.title}</h3>
|
||||
<p className="text-gray-600">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">
|
||||
How It Works
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{ step: '1', title: 'Search', description: 'Enter your location or use "Near Me" to find dispensaries.' },
|
||||
{ step: '2', title: 'Compare', description: 'View ratings, hours, and amenities to find the right fit.' },
|
||||
{ step: '3', title: 'Visit', description: 'Get directions and contact info to plan your visit.' },
|
||||
].map((item, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white text-2xl font-bold mx-auto mb-4">
|
||||
{item.step}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{item.title}</h3>
|
||||
<p className="text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default About;
|
||||
197
findadispo/frontend/src/pages/findadispo/Alerts.jsx
Normal file
197
findadispo/frontend/src/pages/findadispo/Alerts.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, Trash2, Plus, TrendingDown, MapPin } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { mockAlerts } from '../../mockData';
|
||||
|
||||
export function Alerts() {
|
||||
const [alerts, setAlerts] = useState(mockAlerts);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
const toggleAlert = (id) => {
|
||||
setAlerts(
|
||||
alerts.map((alert) =>
|
||||
alert.id === id ? { ...alert, isActive: !alert.isActive } : alert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
setAlerts(alerts.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const activeCount = alerts.filter((a) => a.isActive).length;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Price Alerts</h1>
|
||||
<p className="text-gray-600">
|
||||
Get notified when prices drop on your favorite products
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Alert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{alerts.length}</p>
|
||||
<p className="text-sm text-gray-500">Total Alerts</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary">{activeCount}</p>
|
||||
<p className="text-sm text-gray-500">Active</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-500">
|
||||
{alerts.filter((a) => a.currentPrice <= a.targetPrice).length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Triggered</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-500">$142</p>
|
||||
<p className="text-sm text-gray-500">Total Saved</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert) => (
|
||||
<Card
|
||||
key={alert.id}
|
||||
className={`transition-opacity ${!alert.isActive ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`h-12 w-12 rounded-lg flex items-center justify-center ${
|
||||
alert.currentPrice <= alert.targetPrice
|
||||
? 'bg-green-100'
|
||||
: 'bg-primary/10'
|
||||
}`}
|
||||
>
|
||||
{alert.currentPrice <= alert.targetPrice ? (
|
||||
<TrendingDown className="h-6 w-6 text-green-500" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{alert.productName}</h3>
|
||||
{alert.currentPrice <= alert.targetPrice && (
|
||||
<Badge variant="success">Price Alert!</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{alert.dispensary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">Target Price</p>
|
||||
<p className="font-semibold text-primary">${alert.targetPrice}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">Current Price</p>
|
||||
<p
|
||||
className={`font-semibold ${
|
||||
alert.currentPrice <= alert.targetPrice
|
||||
? 'text-green-500'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
${alert.currentPrice}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-4 border-l">
|
||||
<Switch
|
||||
checked={alert.isActive}
|
||||
onCheckedChange={() => toggleAlert(alert.id)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDelete(alert.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No price alerts</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Create alerts to get notified when prices drop on products you want
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Alert
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Simple Create Modal - In production would be a proper modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Create Price Alert</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>Alert creation would connect to the API</p>
|
||||
<p className="text-sm mt-2">Search for a product and set your target price</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setShowCreateModal(false)}>
|
||||
Create Alert
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Alerts;
|
||||
213
findadispo/frontend/src/pages/findadispo/Contact.jsx
Normal file
213
findadispo/frontend/src/pages/findadispo/Contact.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Phone, MapPin, Send, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
|
||||
export function Contact() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [expandedFaq, setExpandedFaq] = useState(null);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
// In real app, would submit to backend
|
||||
console.log('Form submitted:', formData);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({ ...formData, [e.target.name]: e.target.value });
|
||||
};
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'How do I find dispensaries near me?',
|
||||
answer: 'Use the "Near Me" button on our homepage to automatically detect your location, or enter a city, zip code, or address in the search bar.',
|
||||
},
|
||||
{
|
||||
question: 'Are all listed dispensaries licensed?',
|
||||
answer: 'Yes, we only list state-licensed dispensaries. Our team verifies each listing to ensure they hold valid licenses.',
|
||||
},
|
||||
{
|
||||
question: 'How do I set up price alerts?',
|
||||
answer: 'Create a free account, then navigate to your Dashboard and click on "Alerts". You can set up alerts for specific dispensaries or product types.',
|
||||
},
|
||||
{
|
||||
question: 'Is my personal information secure?',
|
||||
answer: 'Absolutely. We use industry-standard encryption and never share your personal information with third parties.',
|
||||
},
|
||||
{
|
||||
question: 'How can I suggest a dispensary to add?',
|
||||
answer: 'Use the contact form on this page to submit a new dispensary suggestion. Please include the name, address, and website if available.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Contact Us</h1>
|
||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
Have questions or feedback? We'd love to hear from you. Send us a message
|
||||
and we'll respond as soon as possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send us a message</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitted ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="h-16 w-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Send className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">Message Sent!</h3>
|
||||
<p className="text-gray-600">Thank you for reaching out. We'll get back to you within 24-48 hours.</p>
|
||||
<Button className="mt-4" onClick={() => setSubmitted(false)}>
|
||||
Send Another Message
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Your name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
placeholder="What is this regarding?"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Your message..."
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Message
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contact Info Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Email</p>
|
||||
<a href="mailto:support@findadispo.com" className="text-gray-600 hover:text-primary">
|
||||
support@findadispo.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Phone className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Phone</p>
|
||||
<p className="text-gray-600">(555) 123-4567</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Business Hours</p>
|
||||
<p className="text-gray-600">Mon-Fri: 9AM - 6PM PST</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-3xl mx-auto mt-16">
|
||||
<h2 className="text-2xl font-bold text-gray-900 text-center mb-8">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-0">
|
||||
<button
|
||||
className="w-full p-4 text-left flex items-center justify-between"
|
||||
onClick={() => setExpandedFaq(expandedFaq === index ? null : index)}
|
||||
>
|
||||
<span className="font-medium text-gray-900">{faq.question}</span>
|
||||
{expandedFaq === index ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{expandedFaq === index && (
|
||||
<div className="px-4 pb-4 text-gray-600">
|
||||
{faq.answer}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Contact;
|
||||
104
findadispo/frontend/src/pages/findadispo/Dashboard.jsx
Normal file
104
findadispo/frontend/src/pages/findadispo/Dashboard.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Home, Search, Bell, User, LogOut, MapPin } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('isAuthenticated');
|
||||
localStorage.removeItem('user');
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: Home, label: 'Overview', end: true },
|
||||
{ to: '/dashboard/saved', icon: Search, label: 'Saved Searches' },
|
||||
{ to: '/dashboard/alerts', icon: Bell, label: 'Price Alerts' },
|
||||
{ to: '/dashboard/profile', icon: User, label: 'Profile' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden md:flex flex-col w-64 bg-white border-r min-h-[calc(100vh-64px)] sticky top-16">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Welcome back!</p>
|
||||
<p className="text-xs text-gray-500">Manage your account</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-5 w-5 mr-3" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t z-50">
|
||||
<nav className="flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center py-3 px-4 ${
|
||||
isActive ? 'text-primary' : 'text-gray-500'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
177
findadispo/frontend/src/pages/findadispo/DashboardHome.jsx
Normal file
177
findadispo/frontend/src/pages/findadispo/DashboardHome.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MapPin, Star, Clock, Search, Bell, TrendingDown, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockSavedSearches, mockAlerts } from '../../mockData';
|
||||
|
||||
export function DashboardHome() {
|
||||
const recentSearches = mockSavedSearches.slice(0, 3);
|
||||
const activeAlerts = mockAlerts.filter((a) => a.isActive).slice(0, 3);
|
||||
|
||||
const stats = [
|
||||
{ label: 'Saved Searches', value: mockSavedSearches.length, icon: Search },
|
||||
{ label: 'Active Alerts', value: mockAlerts.filter((a) => a.isActive).length, icon: Bell },
|
||||
{ label: 'Dispensaries Viewed', value: 24, icon: MapPin },
|
||||
{ label: 'Deals Found', value: 8, icon: TrendingDown },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Welcome back! Here's your activity summary.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<stat.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Saved Searches */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Recent Saved Searches</CardTitle>
|
||||
<Link to="/dashboard/saved">
|
||||
<Button variant="ghost" size="sm">
|
||||
View All <ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentSearches.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentSearches.map((search) => (
|
||||
<div
|
||||
key={search.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{search.name}</p>
|
||||
<p className="text-xs text-gray-500">{search.query}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">{search.results} results</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">No saved searches yet</p>
|
||||
<Link to="/">
|
||||
<Button variant="link" size="sm">
|
||||
Start searching
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Alerts */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Active Price Alerts</CardTitle>
|
||||
<Link to="/dashboard/alerts">
|
||||
<Button variant="ghost" size="sm">
|
||||
View All <ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{activeAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{alert.productName}</p>
|
||||
<p className="text-xs text-gray-500">{alert.dispensary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
Under ${alert.targetPrice}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Current: ${alert.currentPrice}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Bell className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">No active alerts</p>
|
||||
<Link to="/dashboard/alerts">
|
||||
<Button variant="link" size="sm">
|
||||
Create an alert
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link to="/">
|
||||
<Button variant="outline" className="w-full h-auto py-4 flex-col">
|
||||
<Search className="h-6 w-6 mb-2" />
|
||||
<span>Find Dispensaries</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/saved">
|
||||
<Button variant="outline" className="w-full h-auto py-4 flex-col">
|
||||
<MapPin className="h-6 w-6 mb-2" />
|
||||
<span>Saved Searches</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/alerts">
|
||||
<Button variant="outline" className="w-full h-auto py-4 flex-col">
|
||||
<Bell className="h-6 w-6 mb-2" />
|
||||
<span>Price Alerts</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/profile">
|
||||
<Button variant="outline" className="w-full h-auto py-4 flex-col">
|
||||
<Star className="h-6 w-6 mb-2" />
|
||||
<span>Edit Profile</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardHome;
|
||||
228
findadispo/frontend/src/pages/findadispo/DispensaryDetail.jsx
Normal file
228
findadispo/frontend/src/pages/findadispo/DispensaryDetail.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { MapPin, Phone, Clock, Star, Navigation, ArrowLeft, Share2, Heart, Loader2 } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { getDispensaryBySlug, mapDispensaryForUI } from '../../api/client';
|
||||
import { formatDistance } from '../../lib/utils';
|
||||
|
||||
export function DispensaryDetail() {
|
||||
const { slug } = useParams();
|
||||
const [dispensary, setDispensary] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDispensary = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await getDispensaryBySlug(slug);
|
||||
setDispensary(mapDispensaryForUI(result));
|
||||
} catch (err) {
|
||||
console.error('Error fetching dispensary:', err);
|
||||
setError('Failed to load dispensary details.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDispensary();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<Loader2 className="h-16 w-16 mx-auto mb-4 animate-spin text-primary" />
|
||||
<p className="text-gray-600">Loading dispensary details...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-red-300" />
|
||||
<h1 className="text-2xl font-bold mb-4">Error Loading Dispensary</h1>
|
||||
<p className="text-red-600 mb-6">{error}</p>
|
||||
<Link to="/">
|
||||
<Button>Back to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dispensary) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<h1 className="text-2xl font-bold mb-4">Dispensary Not Found</h1>
|
||||
<p className="text-gray-600 mb-6">The dispensary you're looking for doesn't exist.</p>
|
||||
<Link to="/">
|
||||
<Button>Back to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-64 md:h-80 bg-gray-800">
|
||||
<img
|
||||
src={dispensary.image}
|
||||
alt={dispensary.name}
|
||||
className="w-full h-full object-cover opacity-60"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
|
||||
{/* Back Button */}
|
||||
<div className="absolute top-4 left-4">
|
||||
<Link to="/">
|
||||
<Button variant="ghost" className="text-white hover:bg-white/20">
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<Button variant="ghost" className="text-white hover:bg-white/20" size="icon">
|
||||
<Share2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-white hover:bg-white/20" size="icon">
|
||||
<Heart className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="absolute bottom-6 left-0 right-0 container mx-auto px-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Badge variant={dispensary.isOpen ? 'success' : 'secondary'}>
|
||||
{dispensary.isOpen ? 'Open Now' : 'Closed'}
|
||||
</Badge>
|
||||
<span className="text-white/80 text-sm">{formatDistance(dispensary.distance)} away</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">{dispensary.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Quick Info */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
<Star className="h-6 w-6 fill-current" />
|
||||
<span className="text-2xl font-bold">{dispensary.rating}</span>
|
||||
</div>
|
||||
<span className="text-gray-500">({dispensary.reviews} reviews)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span>{dispensary.hours}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">{dispensary.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Amenities */}
|
||||
{dispensary.amenities && dispensary.amenities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Amenities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dispensary.amenities.map((amenity, index) => (
|
||||
<Badge key={index} variant="outline">{amenity}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Products Section Placeholder */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>Product menu coming soon</p>
|
||||
<p className="text-sm mt-2">Connect to API to view available products</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Contact Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-gray-900">{dispensary.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
<a href={`tel:${dispensary.phone}`} className="text-primary hover:underline">
|
||||
{dispensary.phone}
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" size="lg">
|
||||
<Phone className="h-5 w-5 mr-2" />
|
||||
Call Now
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" size="lg">
|
||||
<Navigation className="h-5 w-5 mr-2" />
|
||||
Get Directions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Map Placeholder */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-48 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<MapPin className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm">Map View</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DispensaryDetail;
|
||||
301
findadispo/frontend/src/pages/findadispo/Home.jsx
Normal file
301
findadispo/frontend/src/pages/findadispo/Home.jsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, MapPin, Navigation, Star, Clock, Phone, ExternalLink, Filter, ChevronDown, Loader2 } from 'lucide-react';
|
||||
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 { searchDispensaries } from '../../api/client';
|
||||
import { formatDistance } from '../../lib/utils';
|
||||
|
||||
export function Home() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dispensaries, setDispensaries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedDispensary, setSelectedDispensary] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
openNow: false,
|
||||
minRating: 0,
|
||||
maxDistance: 100,
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Fetch dispensaries from API
|
||||
useEffect(() => {
|
||||
const fetchDispensaries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const results = await searchDispensaries(searchQuery, filters);
|
||||
setDispensaries(results);
|
||||
} catch (err) {
|
||||
console.error('Error fetching dispensaries:', err);
|
||||
setError('Failed to load dispensaries. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDispensaries();
|
||||
}, [searchQuery, filters]);
|
||||
|
||||
const handleNearMe = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
// In real app, would filter/sort by distance from user
|
||||
console.log('User location:', position.coords);
|
||||
setSearchQuery('Near me');
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
alert('Unable to get your location. Please enable location services.');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
alert('Geolocation is not supported by your browser');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
// Search is already handled by useEffect
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-64px)]">
|
||||
{/* Search Header */}
|
||||
<div className="bg-white border-b px-4 py-4">
|
||||
<div className="container mx-auto">
|
||||
<form onSubmit={handleSearch} className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by city, zip code, or dispensary name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleNearMe}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Navigation className="h-4 w-4" />
|
||||
Near Me
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
<Button type="submit">Search</Button>
|
||||
</form>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.openNow}
|
||||
onChange={(e) => setFilters({ ...filters, openNow: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm">Open Now</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Min Rating:</span>
|
||||
<select
|
||||
value={filters.minRating}
|
||||
onChange={(e) => setFilters({ ...filters, minRating: Number(e.target.value) })}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="0">Any</option>
|
||||
<option value="3">3+ Stars</option>
|
||||
<option value="4">4+ Stars</option>
|
||||
<option value="4.5">4.5+ Stars</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Max Distance:</span>
|
||||
<select
|
||||
value={filters.maxDistance}
|
||||
onChange={(e) => setFilters({ ...filters, maxDistance: Number(e.target.value) })}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="5">5 miles</option>
|
||||
<option value="10">10 miles</option>
|
||||
<option value="25">25 miles</option>
|
||||
<option value="50">50 miles</option>
|
||||
<option value="100">Any distance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Split View */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Dispensary List (Left Side) */}
|
||||
<div className="w-full md:w-2/5 overflow-y-auto border-r bg-gray-50">
|
||||
<div className="p-4 border-b bg-white">
|
||||
<p className="text-sm text-gray-600">
|
||||
{dispensaries.length} dispensaries found
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="h-8 w-8 mx-auto mb-4 animate-spin text-primary" />
|
||||
<p className="text-gray-500">Loading dispensaries...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setFilters({...filters})}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : dispensaries.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<MapPin className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No dispensaries found</p>
|
||||
<p className="text-sm">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
) : (
|
||||
dispensaries.map((dispensary) => (
|
||||
<DispensaryCard
|
||||
key={dispensary.id}
|
||||
dispensary={dispensary}
|
||||
isSelected={selectedDispensary?.id === dispensary.id}
|
||||
onClick={() => setSelectedDispensary(dispensary)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map (Right Side) */}
|
||||
<div className="hidden md:flex flex-1 relative bg-gray-200">
|
||||
{/* Placeholder for Google Maps */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-primary/30" />
|
||||
<p className="text-lg font-medium">Map View</p>
|
||||
<p className="text-sm">Google Maps will be integrated here</p>
|
||||
<p className="text-xs mt-2 text-gray-400">Configure API key in admin panel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Dispensary Card Overlay */}
|
||||
{selectedDispensary && (
|
||||
<div className="absolute bottom-4 left-4 right-4 max-w-md">
|
||||
<Card className="shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<img
|
||||
src={selectedDispensary.image}
|
||||
alt={selectedDispensary.name}
|
||||
className="w-20 h-20 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{selectedDispensary.name}</h3>
|
||||
<div className="flex items-center gap-1 text-sm text-amber-500">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span>{selectedDispensary.rating}</span>
|
||||
<span className="text-gray-400">({selectedDispensary.reviews})</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">{selectedDispensary.address}</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Link to={`/dispensary/${selectedDispensary.slug}`}>
|
||||
<Button size="sm">View Menu</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline">
|
||||
<Navigation className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dispensary Card Component
|
||||
function DispensaryCard({ dispensary, isSelected, onClick }) {
|
||||
return (
|
||||
<div
|
||||
className={`p-4 bg-white cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
isSelected ? 'bg-primary/5 border-l-4 border-l-primary' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<img
|
||||
src={dispensary.image}
|
||||
alt={dispensary.name}
|
||||
className="w-24 h-24 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{dispensary.name}</h3>
|
||||
<Badge variant={dispensary.isOpen ? 'success' : 'secondary'} className="flex-shrink-0">
|
||||
{dispensary.isOpen ? 'Open' : 'Closed'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span className="text-sm font-medium">{dispensary.rating}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">({dispensary.reviews} reviews)</span>
|
||||
<span className="text-sm text-gray-400">|</span>
|
||||
<span className="text-sm text-gray-500">{formatDistance(dispensary.distance)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1 text-sm text-gray-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{dispensary.hours}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 truncate mt-1">
|
||||
<MapPin className="h-3.5 w-3.5 inline mr-1" />
|
||||
{dispensary.address}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Link to={`/dispensary/${dispensary.slug}`}>
|
||||
<Button size="sm" className="h-8">View Menu</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
<Phone className="h-3.5 w-3.5 mr-1" />
|
||||
Call
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-1" />
|
||||
Directions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
194
findadispo/frontend/src/pages/findadispo/Login.jsx
Normal file
194
findadispo/frontend/src/pages/findadispo/Login.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MapPin, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// For demo purposes, accept any email/password
|
||||
if (formData.email && formData.password) {
|
||||
// In real app, would call auth API and store JWT
|
||||
localStorage.setItem('isAuthenticated', 'true');
|
||||
localStorage.setItem('user', JSON.stringify({ email: formData.email }));
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError('Please fill in all fields');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link to="/" className="inline-flex items-center space-x-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<MapPin className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">Find a Dispensary</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome Back</CardTitle>
|
||||
<p className="text-gray-600 mt-2">Sign in to your account</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="you@example.com"
|
||||
className="pl-10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your password"
|
||||
className="pl-10 pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -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 gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
name="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleChange}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="rememberMe" className="text-sm font-normal cursor-pointer">
|
||||
Remember me for 30 days
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" 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-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-4 text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 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.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</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 for free
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
246
findadispo/frontend/src/pages/findadispo/Profile.jsx
Normal file
246
findadispo/frontend/src/pages/findadispo/Profile.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { User, Mail, MapPin, Bell, Shield, Save } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { Separator } from '../../components/ui/separator';
|
||||
|
||||
export function Profile() {
|
||||
const [profile, setProfile] = useState({
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '(555) 123-4567',
|
||||
location: 'Phoenix, AZ',
|
||||
});
|
||||
|
||||
const [notifications, setNotifications] = useState({
|
||||
priceAlerts: true,
|
||||
newDispensaries: false,
|
||||
weeklyDigest: true,
|
||||
promotions: false,
|
||||
});
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleProfileChange = (e) => {
|
||||
setProfile({ ...profile, [e.target.name]: e.target.value });
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleNotificationChange = (key, value) => {
|
||||
setNotifications({ ...notifications, [key]: value });
|
||||
setSaved(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// In real app, would save to API
|
||||
console.log('Saving profile:', profile);
|
||||
console.log('Saving notifications:', notifications);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Profile Settings</h1>
|
||||
<p className="text-gray-600">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Personal Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" size="sm">
|
||||
Change Photo
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 mt-1">JPG, PNG. Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={profile.name}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={handleProfileChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Default Location</Label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="location"
|
||||
name="location"
|
||||
value={profile.location}
|
||||
onChange={handleProfileChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Preferences */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Notification Preferences
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Price Alerts</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Get notified when prices drop on watched products
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifications.priceAlerts}
|
||||
onCheckedChange={(value) => handleNotificationChange('priceAlerts', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">New Dispensaries</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Get notified when new dispensaries open near you
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifications.newDispensaries}
|
||||
onCheckedChange={(value) => handleNotificationChange('newDispensaries', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Weekly Digest</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Receive a weekly summary of deals in your area
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifications.weeklyDigest}
|
||||
onCheckedChange={(value) => handleNotificationChange('weeklyDigest', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Promotions & Tips</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Receive tips and promotional offers
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notifications.promotions}
|
||||
onCheckedChange={(value) => handleNotificationChange('promotions', value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Password</p>
|
||||
<p className="text-sm text-gray-500">Last changed 3 months ago</p>
|
||||
</div>
|
||||
<Button variant="outline">Change Password</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-sm text-gray-500">Add an extra layer of security</p>
|
||||
</div>
|
||||
<Button variant="outline">Enable</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-red-600">Delete Account</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Permanently delete your account and all data
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="text-red-600 hover:bg-red-50">
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-green-600">Changes saved successfully!</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profile;
|
||||
105
findadispo/frontend/src/pages/findadispo/SavedSearches.jsx
Normal file
105
findadispo/frontend/src/pages/findadispo/SavedSearches.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Trash2, ExternalLink, Clock, MapPin } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockSavedSearches } from '../../mockData';
|
||||
|
||||
export function SavedSearches() {
|
||||
const [searches, setSearches] = useState(mockSavedSearches);
|
||||
|
||||
const handleDelete = (id) => {
|
||||
setSearches(searches.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Saved Searches</h1>
|
||||
<p className="text-gray-600">Quickly access your frequently used searches</p>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<Button>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
New Search
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{searches.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{searches.map((search) => (
|
||||
<Card key={search.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{search.name}</h3>
|
||||
<p className="text-xs text-gray-500">{search.query}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
onClick={() => handleDelete(search.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{search.lastUsed}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{search.results} results</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{search.filters && search.filters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{search.filters.map((filter, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{filter}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to={`/?q=${encodeURIComponent(search.query)}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Run Search
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Search className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No saved searches</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Save your frequent searches for quick access
|
||||
</p>
|
||||
<Link to="/">
|
||||
<Button>Start Searching</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedSearches;
|
||||
306
findadispo/frontend/src/pages/findadispo/Signup.jsx
Normal file
306
findadispo/frontend/src/pages/findadispo/Signup.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MapPin, Mail, Lock, Eye, EyeOff, User, Check } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
|
||||
export function Signup() {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreeToTerms: false,
|
||||
subscribeNewsletter: true,
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: 'At least 8 characters', test: (p) => p.length >= 8 },
|
||||
{ label: 'Contains a number', test: (p) => /\d/.test(p) },
|
||||
{ label: 'Contains uppercase letter', test: (p) => /[A-Z]/.test(p) },
|
||||
];
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
});
|
||||
// Clear errors on change
|
||||
if (errors[name]) {
|
||||
setErrors({ ...errors, [name]: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Please enter a valid email';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
if (!formData.agreeToTerms) {
|
||||
newErrors.agreeToTerms = 'You must agree to the terms';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// In real app, would call registration API
|
||||
localStorage.setItem('isAuthenticated', 'true');
|
||||
localStorage.setItem('user', JSON.stringify({ email: formData.email, name: formData.name }));
|
||||
navigate('/dashboard');
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link to="/" className="inline-flex items-center space-x-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<MapPin className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">Find a Dispensary</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Create Your Account</CardTitle>
|
||||
<p className="text-gray-600 mt-2">Start finding dispensaries near you</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="John Doe"
|
||||
className={`pl-10 ${errors.name ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="you@example.com"
|
||||
className={`pl-10 ${errors.email ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-red-500 text-xs">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Create a password"
|
||||
className={`pl-10 pr-10 ${errors.password ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -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>
|
||||
{errors.password && <p className="text-red-500 text-xs">{errors.password}</p>}
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="space-y-1 mt-2">
|
||||
{passwordRequirements.map((req, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<Check
|
||||
className={`h-3 w-3 ${
|
||||
req.test(formData.password) ? 'text-green-500' : 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
req.test(formData.password) ? 'text-green-600' : 'text-gray-500'
|
||||
}
|
||||
>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
placeholder="Confirm your password"
|
||||
className={`pl-10 ${errors.confirmPassword ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-red-500 text-xs">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agreeToTerms"
|
||||
name="agreeToTerms"
|
||||
checked={formData.agreeToTerms}
|
||||
onChange={handleChange}
|
||||
className="rounded border-gray-300 mt-1"
|
||||
/>
|
||||
<Label htmlFor="agreeToTerms" className="text-sm font-normal cursor-pointer">
|
||||
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>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.agreeToTerms && (
|
||||
<p className="text-red-500 text-xs">{errors.agreeToTerms}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subscribeNewsletter"
|
||||
name="subscribeNewsletter"
|
||||
checked={formData.subscribeNewsletter}
|
||||
onChange={handleChange}
|
||||
className="rounded border-gray-300 mt-1"
|
||||
/>
|
||||
<Label htmlFor="subscribeNewsletter" className="text-sm font-normal cursor-pointer">
|
||||
Send me updates about deals and new dispensaries in my area
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" 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-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-4 text-gray-500">Or sign up with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 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.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z" />
|
||||
</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;
|
||||
321
findadispo/frontend/src/pages/findadispo/StoreLocator.jsx
Normal file
321
findadispo/frontend/src/pages/findadispo/StoreLocator.jsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, MapPin, Navigation, Star, Clock, Phone, Filter, ChevronDown, List, Map, Loader2 } from 'lucide-react';
|
||||
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 { searchDispensaries } from '../../api/client';
|
||||
import { formatDistance } from '../../lib/utils';
|
||||
|
||||
export function StoreLocator() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dispensaries, setDispensaries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('list'); // 'list' or 'map'
|
||||
const [filters, setFilters] = useState({
|
||||
openNow: false,
|
||||
minRating: 0,
|
||||
maxDistance: 100,
|
||||
amenities: [],
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const amenityOptions = ['Wheelchair Accessible', 'ATM', 'Online Ordering', 'Curbside Pickup', 'Delivery'];
|
||||
|
||||
// Fetch dispensaries from API
|
||||
useEffect(() => {
|
||||
const fetchDispensaries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const results = await searchDispensaries(searchQuery, filters);
|
||||
setDispensaries(results);
|
||||
} catch (err) {
|
||||
console.error('Error fetching dispensaries:', err);
|
||||
setError('Failed to load dispensaries. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDispensaries();
|
||||
}, [searchQuery, filters]);
|
||||
|
||||
const handleNearMe = () => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
console.log('User location:', position.coords);
|
||||
setSearchQuery('Near me');
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
alert('Unable to get your location. Please enable location services.');
|
||||
}
|
||||
);
|
||||
} else {
|
||||
alert('Geolocation is not supported by your browser');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAmenity = (amenity) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
amenities: prev.amenities.includes(amenity)
|
||||
? prev.amenities.filter((a) => a !== amenity)
|
||||
: [...prev.amenities, amenity],
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
openNow: false,
|
||||
minRating: 0,
|
||||
maxDistance: 100,
|
||||
amenities: [],
|
||||
});
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.openNow,
|
||||
filters.minRating > 0,
|
||||
filters.maxDistance < 100,
|
||||
filters.amenities.length > 0,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Search Header */}
|
||||
<div className="bg-white shadow-sm sticky top-16 z-40">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by city, zip code, or dispensary name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleNearMe} className="flex items-center gap-2">
|
||||
<Navigation className="h-4 w-4" />
|
||||
Near Me
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="primary" className="ml-1">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">{dispensaries.length} dispensaries found</p>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('map')}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Map className="h-4 w-4" />
|
||||
Map
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg border">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.openNow}
|
||||
onChange={(e) => setFilters({ ...filters, openNow: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">Open Now</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Min Rating:</span>
|
||||
<select
|
||||
value={filters.minRating}
|
||||
onChange={(e) => setFilters({ ...filters, minRating: Number(e.target.value) })}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="0">Any</option>
|
||||
<option value="3">3+ Stars</option>
|
||||
<option value="4">4+ Stars</option>
|
||||
<option value="4.5">4.5+ Stars</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Distance:</span>
|
||||
<select
|
||||
value={filters.maxDistance}
|
||||
onChange={(e) => setFilters({ ...filters, maxDistance: Number(e.target.value) })}
|
||||
className="text-sm border rounded px-2 py-1"
|
||||
>
|
||||
<option value="5">5 miles</option>
|
||||
<option value="10">10 miles</option>
|
||||
<option value="25">25 miles</option>
|
||||
<option value="50">50 miles</option>
|
||||
<option value="100">Any distance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amenities */}
|
||||
<div className="mt-4">
|
||||
<span className="text-sm font-medium block mb-2">Amenities:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{amenityOptions.map((amenity) => (
|
||||
<Badge
|
||||
key={amenity}
|
||||
variant={filters.amenities.includes(amenity) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleAmenity(amenity)}
|
||||
>
|
||||
{amenity}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="mt-4">
|
||||
Clear all filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{viewMode === 'list' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full py-16 text-center">
|
||||
<Loader2 className="h-16 w-16 mx-auto mb-4 animate-spin text-primary" />
|
||||
<p className="text-gray-600">Loading dispensaries...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="col-span-full py-16 text-center">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-red-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error loading dispensaries</h3>
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<Button variant="outline" onClick={() => setFilters({...filters})}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : dispensaries.length === 0 ? (
|
||||
<div className="col-span-full py-16 text-center">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No dispensaries found</h3>
|
||||
<p className="text-gray-600 mb-4">Try adjusting your search or filters</p>
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
dispensaries.map((dispensary) => (
|
||||
<DispensaryGridCard key={dispensary.id} dispensary={dispensary} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[calc(100vh-280px)] bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<MapPin className="h-16 w-16 mx-auto mb-4 text-primary/30" />
|
||||
<p className="text-lg font-medium">Map View</p>
|
||||
<p className="text-sm">Google Maps will be integrated here</p>
|
||||
<p className="text-xs mt-2 text-gray-400">Configure API key in admin panel</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DispensaryGridCard({ dispensary }) {
|
||||
return (
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="relative h-40">
|
||||
<img src={dispensary.image} alt={dispensary.name} className="w-full h-full object-cover" />
|
||||
<Badge
|
||||
variant={dispensary.isOpen ? 'success' : 'secondary'}
|
||||
className="absolute top-3 right-3"
|
||||
>
|
||||
{dispensary.isOpen ? 'Open' : 'Closed'}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{dispensary.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex items-center gap-1 text-amber-500">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span className="text-sm font-medium">{dispensary.rating}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">({dispensary.reviews})</span>
|
||||
<span className="text-sm text-gray-400">|</span>
|
||||
<span className="text-sm text-gray-500">{formatDistance(dispensary.distance)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-gray-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{dispensary.hours}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 truncate mt-1">
|
||||
<MapPin className="h-3.5 w-3.5 inline mr-1" />
|
||||
{dispensary.address}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Link to={`/dispensary/${dispensary.slug}`} className="flex-1">
|
||||
<Button size="sm" className="w-full">
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline">
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default StoreLocator;
|
||||
94
findadispo/frontend/tailwind.config.js
Normal file
94
findadispo/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/** @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: "#10B981",
|
||||
foreground: "#ffffff",
|
||||
50: "#ECFDF5",
|
||||
100: "#D1FAE5",
|
||||
200: "#A7F3D0",
|
||||
300: "#6EE7B7",
|
||||
400: "#34D399",
|
||||
500: "#10B981",
|
||||
600: "#059669",
|
||||
700: "#047857",
|
||||
800: "#065F46",
|
||||
900: "#064E3B",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#3B82F6",
|
||||
foreground: "#ffffff",
|
||||
50: "#EFF6FF",
|
||||
100: "#DBEAFE",
|
||||
200: "#BFDBFE",
|
||||
300: "#93C5FD",
|
||||
400: "#60A5FA",
|
||||
500: "#3B82F6",
|
||||
600: "#2563EB",
|
||||
700: "#1D4ED8",
|
||||
800: "#1E40AF",
|
||||
900: "#1E3A8A",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#F59E0B",
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
Reference in New Issue
Block a user