Merge branch 'dev' into dev

This commit is contained in:
Ryuhane 2025-10-01 14:00:06 -07:00 committed by GitHub
commit 9973d51245
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 5030 additions and 120 deletions

View file

@ -10,7 +10,7 @@
# SurfSense
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as Search Engines (Tavily, LinkUp), Slack, Linear, Jira, ClickUp, Confluence, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar and more to come.
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as Search Engines (Tavily, LinkUp), Slack, Linear, Jira, ClickUp, Confluence, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma and more to come.
<div align="center">
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@ -74,6 +74,7 @@ Open source and easy to deploy locally.
- Discord
- Airtable
- Google Calendar
- Luma
- and more to come.....
## 📄 **Supported File Extensions**

View file

@ -0,0 +1,60 @@
"""Add Luma connector enums
Revision ID: 21
Revises: 20
Create Date: 2025-09-27 20:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "21"
down_revision: str | None = "20"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Safely add 'LUMA_CONNECTOR' to enum types if missing."""
# Add to searchsourceconnectortype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'searchsourceconnectortype' AND e.enumlabel = 'LUMA_CONNECTOR'
) THEN
ALTER TYPE searchsourceconnectortype ADD VALUE 'LUMA_CONNECTOR';
END IF;
END
$$;
"""
)
# Add to documenttype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'documenttype' AND e.enumlabel = 'LUMA_CONNECTOR'
) THEN
ALTER TYPE documenttype ADD VALUE 'LUMA_CONNECTOR';
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""Remove 'LUMA_CONNECTOR' from enum types."""
pass

View file

@ -413,6 +413,42 @@ async def fetch_documents_by_ids(
else:
url = ""
elif doc_type == "LUMA_CONNECTOR":
# Extract Luma-specific metadata
event_id = metadata.get("event_id", "")
event_name = metadata.get("event_name", "Untitled Event")
event_url = metadata.get("event_url", "")
start_time = metadata.get("start_time", "")
location_name = metadata.get("location_name", "")
meeting_url = metadata.get("meeting_url", "")
title = f"Luma: {event_name}"
if start_time:
# Format the start time for display
try:
if "T" in start_time:
from datetime import datetime
start_dt = datetime.fromisoformat(
start_time.replace("Z", "+00:00")
)
formatted_time = start_dt.strftime("%Y-%m-%d %H:%M")
title += f" ({formatted_time})"
except Exception:
pass
description = (
doc.content[:100] + "..."
if len(doc.content) > 100
else doc.content
)
if location_name:
description += f" | Venue: {location_name}"
elif meeting_url:
description += " | Online Event"
url = event_url if event_url else ""
elif doc_type == "EXTENSION":
# Extract Extension-specific metadata
webpage_title = metadata.get("VisitedWebPageTitle", doc.title)
@ -487,6 +523,7 @@ async def fetch_documents_by_ids(
"CONFLUENCE_CONNECTOR": "Confluence (Selected)",
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
"LUMA_CONNECTOR": "Luma Events (Selected)",
}
source_object = {
@ -1197,6 +1234,33 @@ async def fetch_relevant_documents(
}
)
elif connector == "LUMA_CONNECTOR":
(
source_object,
luma_chunks,
) = await connector_service.search_luma(
user_query=reformulated_query,
user_id=user_id,
search_space_id=search_space_id,
top_k=top_k,
search_mode=search_mode,
)
# Add to sources and raw documents
if source_object:
all_sources.append(source_object)
all_raw_documents.extend(luma_chunks)
# Stream found document count
if streaming_service and writer:
writer(
{
"yield_value": streaming_service.format_terminal_info_delta(
f"🎯 Found {len(luma_chunks)} Luma events related to your query"
)
}
)
except Exception as e:
logging.error("Error in search_airtable: %s", traceback.format_exc())
error_message = f"Error searching connector {connector}: {e!s}"

View file

@ -38,6 +38,7 @@ You are SurfSense, an advanced AI research assistant that provides detailed, wel
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
- TAVILY_API: "Tavily search API results" (personalized search results)
- LINKUP_API: "Linkup search API results" (personalized search results)
- LUMA_CONNECTOR: "Luma events"
</knowledge_sources>
<instructions>

View file

@ -38,6 +38,7 @@ You are SurfSense, an advanced AI research assistant that synthesizes informatio
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
- TAVILY_API: "Tavily search API results" (personalized search results)
- LINKUP_API: "Linkup search API results" (personalized search results)
- LUMA_CONNECTOR: "Luma events"
</knowledge_sources>
<instructions>
1. Review the chat history to understand the conversation context and any previous topics discussed.

View file

@ -50,6 +50,7 @@ def get_connector_emoji(connector_name: str) -> str:
"LINKUP_API": "🔗",
"GOOGLE_CALENDAR_CONNECTOR": "📅",
"AIRTABLE_CONNECTOR": "🗃️",
"LUMA_CONNECTOR": "",
}
return connector_emojis.get(connector_name, "🔎")
@ -72,6 +73,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
"TAVILY_API": "Tavily Search",
"LINKUP_API": "Linkup Search",
"AIRTABLE_CONNECTOR": "Airtable",
"LUMA_CONNECTOR": "Luma",
}
return connector_friendly_names.get(connector_name, connector_name)

View file

@ -0,0 +1,435 @@
"""
Luma Connector Module
A module for retrieving events and guest data from Luma Event Platform.
Allows fetching event lists, event details, and guest information with date range filtering.
"""
from datetime import datetime
from typing import Any
import requests
class LumaConnector:
"""Class for retrieving events and guest data from Luma Event Platform."""
def __init__(self, api_key: str | None = None):
"""
Initialize the LumaConnector class.
Args:
api_key: Luma API key (optional, can be set later with set_api_key)
"""
self.api_key = api_key
self.base_url = "https://public-api.luma.com/v1"
def set_api_key(self, api_key: str) -> None:
"""
Set the Luma API key.
Args:
api_key: Luma API key
"""
self.api_key = api_key
def get_headers(self) -> dict[str, str]:
"""
Get headers for Luma API requests.
Returns:
Dictionary of headers
Raises:
ValueError: If no Luma API key has been set
"""
if not self.api_key:
raise ValueError("Luma API key not initialized. Call set_api_key() first.")
return {
"Content-Type": "application/json",
"x-luma-api-key": self.api_key,
}
def make_request(
self, endpoint: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
"""
Make a request to the Luma API.
Args:
endpoint: API endpoint path (without base URL)
params: Query parameters (optional)
Returns:
Response data from the API
Raises:
ValueError: If no Luma API key has been set
Exception: If the API request fails
"""
if not self.api_key:
raise ValueError("Luma API key not initialized. Call set_api_key() first.")
headers = self.get_headers()
url = f"{self.base_url}/{endpoint.lstrip('/')}"
try:
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
raise Exception("Unauthorized: Invalid Luma API key")
elif response.status_code == 403:
raise Exception(
"Forbidden: Access denied or Luma Plus subscription required"
)
elif response.status_code == 429:
raise Exception("Rate limit exceeded: Too many requests")
else:
raise Exception(
f"API request failed with status code {response.status_code}: {response.text}"
)
except requests.exceptions.RequestException as e:
raise Exception(f"Network error: {e}") from e
def get_user_info(self) -> tuple[dict[str, Any] | None, str | None]:
"""
Get information about the authenticated user.
Returns:
Tuple containing (user info dict, error message or None)
"""
try:
user_info = self.make_request("user/get-self")
return user_info, None
except Exception as e:
return None, f"Error fetching user info: {e!s}"
def get_all_events(
self, limit: int = 100
) -> tuple[list[dict[str, Any]], str | None]:
"""
Fetch all events for the authenticated user.
Args:
limit: Maximum number of events to fetch per request (default: 100)
Returns:
Tuple containing (events list, error message or None)
"""
try:
all_events = []
cursor = None
while True:
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
response = self.make_request("calendar/list-events", params)
if "entries" not in response:
break
events = response["entries"]
all_events.extend(events)
# Check for pagination
if response.get("next_cursor"):
cursor = response["next_cursor"]
else:
break
return all_events, None
except Exception as e:
return [], f"Error fetching events: {e!s}"
def get_event_details(
self, event_id: str
) -> tuple[dict[str, Any] | None, str | None]:
"""
Fetch detailed information about a specific event.
Args:
event_id: The ID of the event to fetch details for
Returns:
Tuple containing (event details dict, error message or None)
"""
try:
event_details = self.make_request(f"events/{event_id}")
return event_details, None
except Exception as e:
return None, f"Error fetching event details for {event_id}: {e!s}"
def get_event_guests(
self, event_id: str, limit: int = 100
) -> tuple[list[dict[str, Any]], str | None]:
"""
Fetch guests for a specific event.
Args:
event_id: The ID of the event to fetch guests for
limit: Maximum number of guests to fetch per request (default: 100)
Returns:
Tuple containing (guests list, error message or None)
"""
try:
all_guests = []
cursor = None
while True:
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
response = self.make_request(f"events/{event_id}/guests", params)
if "entries" not in response:
break
guests = response["entries"]
all_guests.extend(guests)
# Check for pagination
if response.get("next_cursor"):
cursor = response["next_cursor"]
else:
break
return all_guests, None
except Exception as e:
return [], f"Error fetching guests for event {event_id}: {e!s}"
def get_events_by_date_range(
self, start_date: str, end_date: str, include_guests: bool = True
) -> tuple[list[dict[str, Any]], str | None]:
"""
Fetch events within a date range.
Args:
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format (inclusive)
include_guests: Whether to include guest information for each event
Returns:
Tuple containing (events list, error message or None)
"""
try:
# Convert date strings to ISO format for comparison
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# Get all events first
all_events, error = self.get_all_events()
if error:
return [], error
# Filter events by date range
filtered_events = []
for event in all_events:
event_start_time = event.get("event", {}).get("start_at")
if event_start_time:
try:
# Parse the event start time (assuming ISO format)
event_dt = datetime.fromisoformat(
event_start_time.replace("Z", "+00:00")
)
event_date = event_dt.date()
# Check if event falls within the date range
if start_dt.date() <= event_date <= end_dt.date():
# Add guest information if requested
if include_guests:
event_id = event.get("api_id")
if event_id:
guests, guest_error = self.get_event_guests(
event_id
)
if not guest_error:
event["guests"] = guests
filtered_events.append(event)
except (ValueError, AttributeError):
# Skip events with invalid dates
continue
if not filtered_events:
return [], "No events found in the specified date range."
return filtered_events, None
except ValueError as e:
return [], f"Invalid date format: {e!s}. Please use YYYY-MM-DD."
except Exception as e:
return [], f"Error fetching events by date range: {e!s}"
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
"""
Convert an event to markdown format.
Args:
event: The event object from Luma API
Returns:
Markdown string representation of the event
"""
# Extract event details
event_data = event.get("event", {})
title = event_data.get("name", "Untitled Event")
description = event_data.get("description", "")
event_id = event.get("api_id", "")
# Extract timing information
start_at = event_data.get("start_at", "")
end_at = event_data.get("end_at", "")
timezone = event_data.get("timezone", "")
# Format dates
start_formatted = self.format_date(start_at) if start_at else "Unknown"
end_formatted = self.format_date(end_at) if end_at else "Unknown"
# Extract location information
geo_info = event_data.get("geo_info", {})
location_name = geo_info.get("name", "")
address = geo_info.get("address", "")
# Extract other details
url = event_data.get("url", "")
visibility = event_data.get("visibility", "")
meeting_url = event_data.get("meeting_url", "")
# Build markdown content
markdown_content = f"# {title}\n\n"
if event_id:
markdown_content += f"**Event ID:** {event_id}\n"
# Add timing information
markdown_content += f"**Start:** {start_formatted}\n"
markdown_content += f"**End:** {end_formatted}\n"
if timezone:
markdown_content += f"**Timezone:** {timezone}\n"
markdown_content += "\n"
# Add location information
if location_name or address:
markdown_content += "## Location\n\n"
if location_name:
markdown_content += f"**Venue:** {location_name}\n"
if address:
markdown_content += f"**Address:** {address}\n"
markdown_content += "\n"
# Add online meeting info
if meeting_url:
markdown_content += f"**Meeting URL:** {meeting_url}\n\n"
# Add description if available
if description:
markdown_content += f"## Description\n\n{description}\n\n"
# Add event details
markdown_content += "## Event Details\n\n"
if url:
markdown_content += f"- **Event URL:** {url}\n"
if visibility:
markdown_content += f"- **Visibility:** {visibility}\n"
# Add guest information if available
if "guests" in event:
guests = event["guests"]
markdown_content += f"\n## Guests ({len(guests)})\n\n"
for guest in guests[:10]: # Show first 10 guests
guest_data = guest.get("guest", {})
name = guest_data.get("name", "Unknown")
email = guest_data.get("email", "")
status = guest.get("registration_status", "unknown")
markdown_content += f"- **{name}**"
if email:
markdown_content += f" ({email})"
markdown_content += f" - Status: {status}\n"
if len(guests) > 10:
markdown_content += f"- ... and {len(guests) - 10} more guests\n"
markdown_content += "\n"
return markdown_content
@staticmethod
def format_date(iso_date: str) -> str:
"""
Format an ISO date string to a more readable format.
Args:
iso_date: ISO format date string
Returns:
Formatted date string
"""
if not iso_date or not isinstance(iso_date, str):
return "Unknown date"
try:
dt = datetime.fromisoformat(iso_date.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
except ValueError:
return iso_date
# Example usage (uncomment to use):
"""
if __name__ == "__main__":
# Set your API key here
api_key = "YOUR_LUMA_API_KEY"
luma = LumaConnector(api_key)
try:
# Test authentication
user_info, error = luma.get_user_info()
if error:
print(f"Authentication error: {error}")
else:
print(f"Authenticated as: {user_info.get('name', 'Unknown')}")
# Get all events
events, error = luma.get_all_events()
if error:
print(f"Error fetching events: {error}")
else:
print(f"Retrieved {len(events)} events")
# Format and print the first event as markdown
if events:
event_md = luma.format_event_to_markdown(events[0])
print("\nSample Event in Markdown:\n")
print(event_md)
# Get events by date range
start_date = "2023-01-01"
end_date = "2023-01-31"
date_events, error = luma.get_events_by_date_range(start_date, end_date)
if error:
print(f"Error: {error}")
else:
print(f"\nRetrieved {len(date_events)} events from {start_date} to {end_date}")
except Exception as e:
print(f"Error: {e}")
"""

View file

@ -49,6 +49,7 @@ class DocumentType(str, Enum):
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
LUMA_CONNECTOR = "LUMA_CONNECTOR"
class SearchSourceConnectorType(str, Enum):
@ -66,6 +67,7 @@ class SearchSourceConnectorType(str, Enum):
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
LUMA_CONNECTOR = "LUMA_CONNECTOR"
class ChatType(str, Enum):

View file

@ -13,6 +13,7 @@ from .google_gmail_add_connector_route import (
)
from .llm_config_routes import router as llm_config_router
from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router
from .podcasts_routes import router as podcasts_router
from .search_source_connectors_routes import router as search_source_connectors_router
from .search_spaces_routes import router as search_spaces_router
@ -27,5 +28,6 @@ router.include_router(search_source_connectors_router)
router.include_router(google_calendar_add_connector_router)
router.include_router(google_gmail_add_connector_router)
router.include_router(airtable_add_connector_router)
router.include_router(luma_add_connector_router)
router.include_router(llm_config_router)
router.include_router(logs_router)

View file

@ -280,3 +280,78 @@ async def airtable_callback(
raise HTTPException(
status_code=500, detail=f"Failed to complete Airtable OAuth: {e!s}"
) from e
async def refresh_airtable_token(
session: AsyncSession, connector: SearchSourceConnector
):
"""
Refresh the Airtable access token for a connector.
Args:
session: Database session
connector: Airtable connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Airtable token for connector {connector.id}")
credentials = AirtableAuthCredentialsBase.from_dict(connector.config)
auth_header = make_basic_auth_header(
config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET
)
# Prepare token refresh data
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": credentials.refresh_token,
"client_id": config.AIRTABLE_CLIENT_ID,
"client_secret": config.AIRTABLE_CLIENT_SECRET,
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
data=refresh_data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": auth_header,
},
timeout=30.0,
)
if token_response.status_code != 200:
raise HTTPException(
status_code=400, detail="Token refresh failed: {token_response.text}"
)
token_json = token_response.json()
# Calculate expiration time (UTC, tz-aware)
expires_at = None
if token_json.get("expires_in"):
now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
# Update credentials object
credentials.access_token = token_json["access_token"]
credentials.expires_in = token_json.get("expires_in")
credentials.expires_at = expires_at
credentials.scope = token_json.get("scope")
# Update connector config
connector.config = credentials.to_dict()
await session.commit()
await session.refresh(connector)
logger.info(
f"Successfully refreshed Airtable token for connector {connector.id}"
)
return connector
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to refresh Airtable token: {e!s}"
) from e

View file

@ -0,0 +1,242 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
User,
get_async_session,
)
from app.users import current_active_user
logger = logging.getLogger(__name__)
router = APIRouter()
class AddLumaConnectorRequest(BaseModel):
"""Request model for adding a Luma connector."""
api_key: str = Field(..., description="Luma API key")
space_id: int = Field(..., description="Search space ID")
@router.post("/connectors/luma/add")
async def add_luma_connector(
request: AddLumaConnectorRequest,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""
Add a new Luma connector for the authenticated user.
Args:
request: The request containing Luma API key and space_id
user: Current authenticated user
session: Database session
Returns:
Success message and connector details
Raises:
HTTPException: If connector already exists or validation fails
"""
try:
# Check if a Luma connector already exists for this user
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
)
)
existing_connector = result.scalars().first()
if existing_connector:
# Update existing connector with new API key
existing_connector.config = {"api_key": request.api_key}
existing_connector.is_indexable = True
await session.commit()
await session.refresh(existing_connector)
logger.info(f"Updated existing Luma connector for user {user.id}")
return {
"message": "Luma connector updated successfully",
"connector_id": existing_connector.id,
"connector_type": "LUMA_CONNECTOR",
}
# Create new Luma connector
db_connector = SearchSourceConnector(
name="Luma Event Connector",
connector_type=SearchSourceConnectorType.LUMA_CONNECTOR,
config={"api_key": request.api_key},
user_id=user.id,
is_indexable=True,
)
session.add(db_connector)
await session.commit()
await session.refresh(db_connector)
logger.info(
f"Successfully created Luma connector for user {user.id} with ID {db_connector.id}"
)
return {
"message": "Luma connector added successfully",
"connector_id": db_connector.id,
"connector_type": "LUMA_CONNECTOR",
}
except IntegrityError as e:
await session.rollback()
logger.error(f"Database integrity error: {e!s}")
raise HTTPException(
status_code=409,
detail="A Luma connector already exists for this user.",
) from e
except Exception as e:
await session.rollback()
logger.error(f"Unexpected error adding Luma connector: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to add Luma connector: {e!s}",
) from e
@router.delete("/connectors/luma")
async def delete_luma_connector(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""
Delete the Luma connector for the authenticated user.
Args:
user: Current authenticated user
session: Database session
Returns:
Success message
Raises:
HTTPException: If connector doesn't exist
"""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Luma connector not found for this user.",
)
await session.delete(connector)
await session.commit()
logger.info(f"Successfully deleted Luma connector for user {user.id}")
return {"message": "Luma connector deleted successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.error(f"Unexpected error deleting Luma connector: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to delete Luma connector: {e!s}",
) from e
@router.get("/connectors/luma/test")
async def test_luma_connector(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""
Test the Luma connector for the authenticated user.
Args:
user: Current authenticated user
session: Database session
Returns:
Test results including user info and event count
Raises:
HTTPException: If connector doesn't exist or test fails
"""
try:
# Get the Luma connector for this user
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.LUMA_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Luma connector not found. Please add a connector first.",
)
# Import LumaConnector
from app.connectors.luma_connector import LumaConnector
# Initialize the connector
api_key = connector.config.get("api_key")
if not api_key:
raise HTTPException(
status_code=400,
detail="Invalid connector configuration: API key missing.",
)
luma = LumaConnector(api_key=api_key)
# Test the connection by fetching user info
user_info, error = luma.get_user_info()
if error:
raise HTTPException(
status_code=400,
detail=f"Failed to connect to Luma: {error}",
)
# Try to fetch events
events, events_error = luma.get_all_events(limit=10)
return {
"message": "Luma connector is working correctly",
"user_info": {
"name": user_info.get("name", "Unknown"),
"email": user_info.get("email", "Unknown"),
},
"event_count": len(events) if not events_error else 0,
"events_error": events_error,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error testing Luma connector: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to test Luma connector: {e!s}",
) from e

View file

@ -7,7 +7,7 @@ PUT /search-source-connectors/{connector_id} - Update a specific connector
DELETE /search-source-connectors/{connector_id} - Delete a specific connector
POST /search-source-connectors/{connector_id}/index - Index content from a connector to a search space
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR).
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR, LUMA_CONNECTOR).
"""
import logging
@ -45,6 +45,7 @@ from app.tasks.connector_indexers import (
index_google_gmail_messages,
index_jira_issues,
index_linear_issues,
index_luma_events,
index_notion_pages,
index_slack_messages,
)
@ -344,6 +345,7 @@ async def index_connector_content(
- LINEAR_CONNECTOR: Indexes issues and comments from Linear
- JIRA_CONNECTOR: Indexes issues and comments from Jira
- DISCORD_CONNECTOR: Indexes messages from all accessible Discord channels
- LUMA_CONNECTOR: Indexes events from Luma
Args:
connector_id: ID of the connector to use
@ -555,6 +557,21 @@ async def index_connector_content(
)
response_message = "Discord indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.LUMA_CONNECTOR:
# Run indexing in background
logger.info(
f"Triggering Luma indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_luma_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "Luma indexing started in the background."
else:
raise HTTPException(
status_code=400,
@ -1262,3 +1279,65 @@ async def run_google_gmail_indexing(
exc_info=True,
)
# Optionally update status in DB to indicate failure
# Add new helper functions for luma indexing
async def run_luma_indexing_with_new_session(
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""
Create a new session and run the Luma indexing task.
This prevents session leaks by creating a dedicated session for the background task.
"""
async with async_session_maker() as session:
await run_luma_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_luma_indexing(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""
Background task to run Luma indexing.
Args:
session: Database session
connector_id: ID of the Luma connector
search_space_id: ID of the search space
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
"""
try:
# Index Luma events without updating last_indexed_at (we'll do it separately)
documents_processed, error_or_warning = await index_luma_events(
session=session,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
update_last_indexed=False, # Don't update timestamp in the indexing function
)
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0:
await update_connector_last_indexed(session, connector_id)
logger.info(
f"Luma indexing completed successfully: {documents_processed} documents processed"
)
else:
logger.error(
f"Luma indexing failed or no documents processed: {error_or_warning}"
)
except Exception as e:
logger.error(f"Error in background Luma indexing task: {e!s}")

View file

@ -196,6 +196,18 @@ class SearchSourceConnectorBase(BaseModel):
if key not in config or config[key] in (None, ""):
raise ValueError(f"{key} is required and cannot be empty")
elif connector_type == SearchSourceConnectorType.LUMA_CONNECTOR:
# For LUMA_CONNECTOR, only allow LUMA_API_KEY
allowed_keys = ["LUMA_API_KEY"]
if set(config.keys()) != set(allowed_keys):
raise ValueError(
f"For LUMA_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the api key is not empty
if not config.get("LUMA_API_KEY"):
raise ValueError("LUMA_API_KEY cannot be empty")
return config

View file

@ -1852,3 +1852,164 @@ class ConnectorService:
}
return result_object, discord_chunks
async def search_luma(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
"""
Search for Luma events and return both the source information and langchain documents
Args:
user_query: The user's query
user_id: The user's ID
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
search_mode: Search mode (CHUNKS or DOCUMENTS)
Returns:
tuple: (sources_info, langchain_documents)
"""
if search_mode == SearchMode.CHUNKS:
luma_chunks = await self.chunk_retriever.hybrid_search(
query_text=user_query,
top_k=top_k,
user_id=user_id,
search_space_id=search_space_id,
document_type="LUMA_CONNECTOR",
)
elif search_mode == SearchMode.DOCUMENTS:
luma_chunks = await self.document_retriever.hybrid_search(
query_text=user_query,
top_k=top_k,
user_id=user_id,
search_space_id=search_space_id,
document_type="LUMA_CONNECTOR",
)
# Transform document retriever results to match expected format
luma_chunks = self._transform_document_results(luma_chunks)
# Early return if no results
if not luma_chunks:
return {
"id": 33,
"name": "Luma Events",
"type": "LUMA_CONNECTOR",
"sources": [],
}, []
# Process each chunk and create sources directly without deduplication
sources_list = []
async with self.counter_lock:
for _i, chunk in enumerate(luma_chunks):
# Extract document metadata
document = chunk.get("document", {})
metadata = document.get("metadata", {})
# Extract Luma-specific metadata
event_id = metadata.get("event_id", "")
event_name = metadata.get("event_name", "Untitled Event")
event_url = metadata.get("event_url", "")
start_time = metadata.get("start_time", "")
end_time = metadata.get("end_time", "")
location_name = metadata.get("location_name", "")
location_address = metadata.get("location_address", "")
meeting_url = metadata.get("meeting_url", "")
timezone = metadata.get("timezone", "")
visibility = metadata.get("visibility", "")
# Create a more descriptive title for Luma events
title = f"Luma: {event_name}"
if start_time:
# Format the start time for display
try:
if "T" in start_time:
from datetime import datetime
start_dt = datetime.fromisoformat(
start_time.replace("Z", "+00:00")
)
formatted_time = start_dt.strftime("%Y-%m-%d %H:%M")
title += f" ({formatted_time})"
else:
title += f" ({start_time})"
except Exception:
title += f" ({start_time})"
# Create a more descriptive description for Luma events
description = chunk.get("content", "")[:150]
if len(description) == 150:
description += "..."
# Add event info to description
info_parts = []
if location_name:
info_parts.append(f"Venue: {location_name}")
elif location_address:
info_parts.append(f"Location: {location_address}")
if meeting_url:
info_parts.append("Online Event")
if end_time:
try:
if "T" in end_time:
from datetime import datetime
end_dt = datetime.fromisoformat(
end_time.replace("Z", "+00:00")
)
formatted_end = end_dt.strftime("%Y-%m-%d %H:%M")
info_parts.append(f"Ends: {formatted_end}")
else:
info_parts.append(f"Ends: {end_time}")
except Exception:
info_parts.append(f"Ends: {end_time}")
if timezone:
info_parts.append(f"TZ: {timezone}")
if visibility:
info_parts.append(f"Visibility: {visibility.title()}")
if info_parts:
if description:
description += f" | {' | '.join(info_parts)}"
else:
description = " | ".join(info_parts)
# Use the Luma event URL if available
url = event_url if event_url else ""
source = {
"id": chunk.get("chunk_id", self.source_id_counter),
"title": title,
"description": description,
"url": url,
"event_id": event_id,
"event_name": event_name,
"start_time": start_time,
"end_time": end_time,
"location_name": location_name,
"location_address": location_address,
"meeting_url": meeting_url,
"timezone": timezone,
"visibility": visibility,
}
self.source_id_counter += 1
sources_list.append(source)
# Create result object
result_object = {
"id": 33, # Assign a unique ID for the Luma connector
"name": "Luma Events",
"type": "LUMA_CONNECTOR",
"sources": sources_list,
}
return result_object, luma_chunks

View file

@ -16,6 +16,7 @@ Available indexers:
- ClickUp: Index tasks from ClickUp workspaces
- Google Gmail: Index messages from Google Gmail
- Google Calendar: Index events from Google Calendar
- Luma: Index events from Luma
"""
# Communication platforms
@ -33,6 +34,7 @@ from .jira_indexer import index_jira_issues
# Issue tracking and project management
from .linear_indexer import index_linear_issues
from .luma_indexer import index_luma_events
# Documentation and knowledge management
from .notion_indexer import index_notion_pages
@ -47,6 +49,7 @@ __all__ = [ # noqa: RUF022
"index_github_repos",
# Calendar and scheduling
"index_google_calendar_events",
"index_luma_events",
"index_jira_issues",
# Issue tracking and project management
"index_linear_issues",

View file

@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.airtable_connector import AirtableConnector
from app.db import Document, DocumentType, SearchSourceConnectorType
from app.routes.airtable_add_connector_route import refresh_airtable_token
from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase
from app.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
@ -102,7 +103,10 @@ async def index_airtable_records(
"Credentials expired",
{"error_type": "ExpiredCredentials"},
)
return 0, "Airtable credentials have expired. Please re-authenticate."
connector = await refresh_airtable_token(session, connector)
# return 0, "Airtable credentials have expired. Please re-authenticate."
# Calculate date range for indexing
start_date_str, end_date_str = calculate_date_range(

View file

@ -0,0 +1,401 @@
"""
Luma connector indexer.
"""
from datetime import datetime, timedelta
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.luma_connector import LumaConnector
from app.db import Document, DocumentType, SearchSourceConnectorType
from app.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
)
from .base import (
get_connector_by_id,
logger,
update_connector_last_indexed,
)
async def index_luma_events(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
update_last_indexed: bool = True,
) -> tuple[int, str | None]:
"""
Index Luma events.
Args:
session: Database session
connector_id: ID of the Luma connector
search_space_id: ID of the search space to store documents in
user_id: User ID
start_date: Start date for indexing (YYYY-MM-DD format)
end_date: End date for indexing (YYYY-MM-DD format)
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
Returns:
Tuple containing (number of documents indexed, error message or None)
"""
task_logger = TaskLoggingService(session, search_space_id)
# Log task start
log_entry = await task_logger.log_task_start(
task_name="luma_events_indexing",
source="connector_indexing_task",
message=f"Starting Luma events indexing for connector {connector_id}",
metadata={
"connector_id": connector_id,
"user_id": str(user_id),
"start_date": start_date,
"end_date": end_date,
},
)
try:
# Get the connector
await task_logger.log_task_progress(
log_entry,
f"Retrieving Luma connector {connector_id} from database",
{"stage": "connector_retrieval"},
)
# Get the connector from the database
connector = await get_connector_by_id(
session, connector_id, SearchSourceConnectorType.LUMA_CONNECTOR
)
if not connector:
await task_logger.log_task_failure(
log_entry,
f"Connector with ID {connector_id} not found or is not a Luma connector",
"Connector not found",
{"error_type": "ConnectorNotFound"},
)
return (
0,
f"Connector with ID {connector_id} not found or is not a Luma connector",
)
# Get the Luma API key from the connector config
api_key = connector.config.get("LUMA_API_KEY")
if not api_key:
await task_logger.log_task_failure(
log_entry,
f"Luma API key not found in connector config for connector {connector_id}",
"Missing Luma API key",
{"error_type": "MissingCredentials"},
)
return 0, "Luma API key not found in connector config"
logger.info(f"Starting Luma indexing for connector {connector_id}")
# Initialize Luma client
await task_logger.log_task_progress(
log_entry,
f"Initializing Luma client for connector {connector_id}",
{"stage": "client_initialization"},
)
luma_client = LumaConnector(api_key=api_key)
# Calculate date range
if start_date is None or end_date is None:
# Fall back to calculating dates based on last_indexed_at
calculated_end_date = datetime.now()
# Use last_indexed_at as start date if available, otherwise use 30 days ago
if connector.last_indexed_at:
# Convert dates to be comparable (both timezone-naive)
last_indexed_naive = (
connector.last_indexed_at.replace(tzinfo=None)
if connector.last_indexed_at.tzinfo
else connector.last_indexed_at
)
# Check if last_indexed_at is in the future or after end_date
if last_indexed_naive > calculated_end_date:
logger.warning(
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
)
calculated_start_date = calculated_end_date - timedelta(days=30)
else:
calculated_start_date = last_indexed_naive
logger.info(
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
)
else:
calculated_start_date = calculated_end_date - timedelta(days=30)
logger.info(
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (30 days ago) as start date"
)
# Use calculated dates if not provided
start_date_str = (
start_date if start_date else calculated_start_date.strftime("%Y-%m-%d")
)
end_date_str = (
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
)
else:
# Use provided dates
start_date_str = start_date
end_date_str = end_date
await task_logger.log_task_progress(
log_entry,
f"Fetching Luma events from {start_date_str} to {end_date_str}",
{
"stage": "fetching_events",
"start_date": start_date_str,
"end_date": end_date_str,
},
)
# Get events within date range from Luma
try:
events, error = luma_client.get_events_by_date_range(
start_date_str, end_date_str, include_guests=False
)
if error:
logger.error(f"Failed to get Luma events: {error}")
# Don't treat "No events found" as an error that should stop indexing
if "No events found" in error or "no events" in error.lower():
logger.info(
"No events found is not a critical error, continuing with update"
)
if update_last_indexed:
await update_connector_last_indexed(
session, connector, update_last_indexed
)
await session.commit()
logger.info(
f"Updated last_indexed_at to {connector.last_indexed_at} despite no events found"
)
await task_logger.log_task_success(
log_entry,
f"No Luma events found in date range {start_date_str} to {end_date_str}",
{"events_found": 0},
)
return 0, None
else:
await task_logger.log_task_failure(
log_entry,
f"Failed to get Luma events: {error}",
"API Error",
{"error_type": "APIError"},
)
return 0, f"Failed to get Luma events: {error}"
logger.info(f"Retrieved {len(events)} events from Luma API")
except Exception as e:
logger.error(f"Error fetching Luma events: {e!s}", exc_info=True)
return 0, f"Error fetching Luma events: {e!s}"
documents_indexed = 0
documents_skipped = 0
skipped_events = []
for event in events:
try:
# Luma event structure fields - events have nested 'event' field
event_data = event.get("event", {})
event_id = event.get("api_id") or event_data.get("id")
event_name = event_data.get("name", "No Title")
event_url = event_data.get("url", "")
if not event_id:
logger.warning(f"Skipping event with missing ID: {event_name}")
skipped_events.append(f"{event_name} (missing ID)")
documents_skipped += 1
continue
# Format event to markdown using Luma connector's method
event_markdown = luma_client.format_event_to_markdown(event)
if not event_markdown.strip():
logger.warning(f"Skipping event with no content: {event_name}")
skipped_events.append(f"{event_name} (no content)")
documents_skipped += 1
continue
# Extract Luma-specific fields from event_data
start_at = event_data.get("start_at", "")
end_at = event_data.get("end_at", "")
timezone = event_data.get("timezone", "")
# Location info from geo_info
geo_info = event_data.get("geo_info", {})
location = geo_info.get("address", "")
city = geo_info.get("city", "")
# Host info
hosts = event_data.get("hosts", [])
host_names = ", ".join(
[host.get("name", "") for host in hosts if host.get("name")]
)
description = event_data.get("description", "")
cover_url = event_data.get("cover_url", "")
content_hash = generate_content_hash(event_markdown, search_space_id)
# Duplicate check via simple query using helper in base
from .base import check_duplicate_document_by_hash
existing_document_by_hash = await check_duplicate_document_by_hash(
session, content_hash
)
if existing_document_by_hash:
logger.info(
f"Document with content hash {content_hash} already exists for event {event_name}. Skipping processing."
)
documents_skipped += 1
continue
# Generate summary with metadata
user_llm = await get_user_long_context_llm(session, user_id)
if user_llm:
document_metadata = {
"event_id": event_id,
"event_name": event_name,
"event_url": event_url,
"start_at": start_at,
"end_at": end_at,
"timezone": timezone,
"location": location or "No location",
"city": city,
"hosts": host_names,
"document_type": "Luma Event",
"connector_type": "Luma",
}
(
summary_content,
summary_embedding,
) = await generate_document_summary(
event_markdown, user_llm, document_metadata
)
else:
# Fallback to simple summary if no LLM configured
summary_content = f"Luma Event: {event_name}\n\n"
if event_url:
summary_content += f"URL: {event_url}\n"
summary_content += f"Start: {start_at}\n"
summary_content += f"End: {end_at}\n"
if timezone:
summary_content += f"Timezone: {timezone}\n"
if location:
summary_content += f"Location: {location}\n"
if city:
summary_content += f"City: {city}\n"
if host_names:
summary_content += f"Hosts: {host_names}\n"
if description:
desc_preview = description[:300]
if len(description) > 300:
desc_preview += "..."
summary_content += f"Description: {desc_preview}\n"
summary_embedding = config.embedding_model_instance.embed(
summary_content
)
chunks = await create_document_chunks(event_markdown)
document = Document(
search_space_id=search_space_id,
title=f"Luma Event - {event_name}",
document_type=DocumentType.LUMA_CONNECTOR,
document_metadata={
"event_id": event_id,
"event_name": event_name,
"event_url": event_url,
"start_at": start_at,
"end_at": end_at,
"timezone": timezone,
"location": location,
"city": city,
"hosts": host_names,
"cover_url": cover_url,
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
content=summary_content,
content_hash=content_hash,
embedding=summary_embedding,
chunks=chunks,
)
session.add(document)
documents_indexed += 1
logger.info(f"Successfully indexed new event {event_name}")
except Exception as e:
logger.error(
f"Error processing event {event.get('name', 'Unknown')}: {e!s}",
exc_info=True,
)
skipped_events.append(
f"{event.get('name', 'Unknown')} (processing error)"
)
documents_skipped += 1
continue
total_processed = documents_indexed
if total_processed > 0:
await update_connector_last_indexed(session, connector, update_last_indexed)
await session.commit()
await task_logger.log_task_success(
log_entry,
f"Successfully completed Luma indexing for connector {connector_id}",
{
"events_processed": total_processed,
"documents_indexed": documents_indexed,
"documents_skipped": documents_skipped,
"skipped_events_count": len(skipped_events),
},
)
logger.info(
f"Luma indexing completed: {documents_indexed} new events, {documents_skipped} skipped"
)
return total_processed, None
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Database error during Luma indexing for connector {connector_id}",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error(f"Database error: {db_error!s}", exc_info=True)
return 0, f"Database error: {db_error!s}"
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Failed to index Luma events for connector {connector_id}",
str(e),
{"error_type": type(e).__name__},
)
logger.error(f"Failed to index Luma events: {e!s}", exc_info=True)
return 0, f"Failed to index Luma events: {e!s}"

View file

@ -136,13 +136,14 @@ async def add_youtube_video_document(
)
try:
captions = YouTubeTranscriptApi.get_transcript(video_id)
ytt_api = YouTubeTranscriptApi()
captions = ytt_api.fetch(video_id)
# Include complete caption information with timestamps
transcript_segments = []
for line in captions:
start_time = line.get("start", 0)
duration = line.get("duration", 0)
text = line.get("text", "")
start_time = line.start
duration = line.duration
text = line.text
timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]"
transcript_segments.append(f"{timestamp} {text}")
transcript_text = "\n".join(transcript_segments)

View file

@ -1,3 +1,5 @@
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
# Contact Form Vars - OPTIONAL
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres

View file

@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db";
import { usersTable } from "@/app/db/schema";
// Define validation schema matching the database schema
const contactSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the request body
const validatedData = contactSchema.parse(body);
// Insert into database
const result = await db
.insert(usersTable)
.values({
name: validatedData.name,
email: validatedData.email,
company: validatedData.company,
message: validatedData.message,
})
.returning();
return NextResponse.json(
{
success: true,
message: "Contact form submitted successfully",
data: result[0],
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
message: "Validation error",
errors: error.errors,
},
{ status: 400 }
);
}
console.error("Error submitting contact form:", error);
return NextResponse.json(
{
success: false,
message: "Failed to submit contact form",
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,12 @@
import React from 'react'
import { ContactFormGridWithDetails } from '@/components/contact/contact-form'
const page = () => {
return (
<div>
<ContactFormGridWithDetails />
</div>
)
}
export default page

View file

@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "motion/react";
import {
Calendar,
CheckCircle,

View file

@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
@ -260,6 +260,17 @@ export default function EditConnectorPage() {
placeholder="Bot token..."
/>
)}
{/* == Luma == */}
{connector.connector_type === "LUMA_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LUMA_API_KEY"
fieldLabel="Luma API Key"
fieldDescription="Update the Luma API Key if needed."
placeholder="API Key..."
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@ -53,6 +53,7 @@ const getConnectorTypeDisplay = (type: string): string => {
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Connector",
GOOGLE_GMAIL_CONNECTOR: "Google Gmail Connector",
AIRTABLE_CONNECTOR: "Airtable Connector",
LUMA_CONNECTOR: "Luma Connector",
// Add other connector types here as needed
};
return typeMap[type] || type;
@ -71,6 +72,7 @@ const getApiKeyFieldName = (connectorType: string): string => {
GITHUB_CONNECTOR: "GITHUB_PAT",
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
LINKUP_API: "LINKUP_API_KEY",
LUMA_CONNECTOR: "LUMA_API_KEY",
};
return fieldMap[connectorType] || "";
};

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -0,0 +1,256 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "motion/react";
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const lumaConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
// Define the type for the form values
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
export default function LumaConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { fetchConnectors, createConnector } = useSearchSourceConnectors();
// Initialize the form
const form = useForm<LumaConnectorFormValues>({
resolver: zodResolver(lumaConnectorFormSchema),
defaultValues: {
name: "Luma Events",
api_key: "",
},
});
useEffect(() => {
fetchConnectors().then((data) => {
const connector = data.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, [fetchConnectors]);
// Handle form submission
const onSubmit = async (values: LumaConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: EnumConnectorName.LUMA_CONNECTOR,
config: {
LUMA_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Luma connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Luma</h1>
<p className="text-muted-foreground">Connect your Luma account to search events.</p>
</div>
</div>
</div>
{/* Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Connect Your Luma Account</CardTitle>
<CardDescription>
Enter your Luma API key to connect your account. We'll use this to access your
events in read-only mode.
</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Luma Events" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Luma API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2 pt-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Read-only access to your Luma events</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Access works even when you're offline</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>You can disconnect anytime</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Key className="mr-2 h-4 w-4" />
Connect Luma
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</Card>
) : (
/* Success Card */
<Card>
<CardHeader>
<CardTitle> Your Luma account is successfully connected!</CardTitle>
</CardHeader>
</Card>
)}
{/* Help Section */}
{!doesConnectorExist && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Get Your API Key</h4>
<p className="text-sm text-muted-foreground">
Log into your Luma account and navigate to your account settings to generate an
API key.
</p>
</div>
<div>
<h4 className="font-medium mb-2">2. Enter Your API Key</h4>
<p className="text-sm text-muted-foreground">
Paste your API key in the field above. We'll use this to securely access your
events with read-only permissions.
</p>
</div>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -6,7 +6,7 @@ import {
IconChevronDown,
IconChevronRight,
} from "@tabler/icons-react";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "motion/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
@ -140,6 +140,13 @@ const connectorCategories: ConnectorCategory[] = [
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "luma-connector",
title: "Luma",
description: "Connect to Luma to search events",
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
status: "available",
},
],
},
{

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import React, { useMemo, useRef } from "react";
import {

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import React from "react";
import { DocumentViewer } from "@/components/document-viewer";

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";

View file

@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";

View file

@ -2,7 +2,7 @@
import { IconBrandYoutube } from "@tabler/icons-react";
import { type Tag, TagInput } from "emblor";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";

View file

@ -15,7 +15,7 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "motion/react";
import {
Activity,
AlertCircle,

View file

@ -1,7 +1,7 @@
"use client";
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";

View file

@ -1,6 +1,6 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { motion, type Variants } from "motion/react";
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";

View file

@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// Configure postgres client for Vercel serverless environment
const client = postgres(process.env.DATABASE_URL!, {
max: 1, // Limit connections for serverless (Vercel)
idle_timeout: 20, // Close idle connections after 20 seconds
max_lifetime: 60 * 30, // Close connections after 30 minutes
connect_timeout: 10, // Connection timeout in seconds
});
export const db = drizzle({ client, schema });

View file

@ -0,0 +1,9 @@
import { integer, pgTable, text, varchar } from "drizzle-orm/pg-core";
export const usersTable = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(),
company: varchar({ length: 255 }).notNull(),
message: text().default(""),
});

View file

@ -1,6 +1,6 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";

View file

@ -1,12 +1,16 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
export function LocalLoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [error, setError] = useState<string | null>(null);
const [errorTitle, setErrorTitle] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [authType, setAuthType] = useState<string | null>(null);
const router = useRouter();
@ -19,7 +23,11 @@ export function LocalLoginForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
setError(null); // Clear any previous errors
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Signing you in...");
try {
// Create form data for the API request
@ -42,13 +50,53 @@ export function LocalLoginForm() {
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Failed to login");
throw new Error(data.detail || `HTTP ${response.status}`);
}
router.push(`/auth/callback?token=${data.access_token}`);
// Success toast
toast.success("Login successful!", {
id: loadingToast,
description: "Redirecting to dashboard...",
duration: 2000,
});
// Small delay to show success message
setTimeout(() => {
router.push(`/auth/callback?token=${data.access_token}`);
}, 500);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
setError(errorMessage);
// Use auth-errors utility to get proper error details
let errorCode = "UNKNOWN_ERROR";
if (err instanceof Error) {
errorCode = err.message;
} else if (isNetworkError(err)) {
errorCode = "NETWORK_ERROR";
}
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);
// Set persistent error display
setErrorTitle(errorDetails.title);
setError(errorDetails.description);
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
onClick: () => handleSubmit(e),
};
}
toast.error(errorDetails.title, toastOptions);
} finally {
setIsLoading(false);
}
@ -57,11 +105,67 @@ export function LocalLoginForm() {
return (
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
{/* Error Display */}
<AnimatePresence>
{error && errorTitle && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
<button
onClick={() => {
setError(null);
setErrorTitle(null);
}}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div>
<label
@ -76,7 +180,12 @@ export function LocalLoginForm() {
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
@ -93,14 +202,19 @@ export function LocalLoginForm() {
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>

View file

@ -1,23 +1,82 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() {
const [authType, setAuthType] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
const searchParams = useSearchParams();
useEffect(() => {
// Check if the user was redirected from registration
if (searchParams.get("registered") === "true") {
setRegistrationSuccess(true);
// Check for various URL parameters that might indicate success or error states
const registered = searchParams.get("registered");
const error = searchParams.get("error");
const message = searchParams.get("message");
const logout = searchParams.get("logout");
// Show registration success message
if (registered === "true") {
toast.success("Registration successful!", {
description: "You can now sign in with your credentials",
duration: 5000,
});
}
// Show logout confirmation
if (logout === "true") {
toast.success("Logged out successfully", {
description: "You have been securely logged out",
duration: 3000,
});
}
// Show error messages from OAuth or other flows using auth-errors utility
if (error) {
// Use the auth-errors utility to get proper error details
const errorDetails = getAuthErrorDetails(error);
// If we have a custom message from URL params, use it as description
const errorDescription = message ? decodeURIComponent(message) : errorDetails.description;
// Set persistent error display
setUrlError({
title: errorDetails.title,
message: errorDescription,
});
// Show toast with conditional retry action
const toastOptions: any = {
description: errorDescription,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(error)) {
toastOptions.action = {
label: "Retry",
onClick: () => window.location.reload(),
};
}
toast.error(errorDetails.title, toastOptions);
}
// Show general messages
if (message && !error && !registered && !logout) {
toast.info("Notice", {
description: decodeURIComponent(message),
duration: 4000,
});
}
// Get the auth type from environment variables
@ -54,11 +113,64 @@ function LoginContent() {
Sign In
</h1>
{registrationSuccess && (
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
Registration successful! You can now sign in with your credentials.
</div>
)}
{/* URL Error Display */}
<AnimatePresence>
{urlError && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="mb-6 w-full max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
</div>
<button
type="button"
onClick={() => setUrlError(null)}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<LocalLoginForm />
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

View file

@ -0,0 +1,12 @@
import React from 'react'
import PricingBasic from '@/components/pricing/pricing-section'
const page = () => {
return (
<div>
<PricingBasic />
</div>
)
}
export default page

View file

@ -1,16 +1,20 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [error, setError] = useState<string | null>(null);
const [errorTitle, setErrorTitle] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@ -28,11 +32,20 @@ export default function RegisterPage() {
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
setErrorTitle("Password Mismatch");
toast.error("Password Mismatch", {
description: "The passwords you entered do not match",
duration: 4000,
});
return;
}
setIsLoading(true);
setError("");
setError(null); // Clear any previous errors
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Creating your account...");
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
@ -52,15 +65,53 @@ export default function RegisterPage() {
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Registration failed");
throw new Error(data.detail || `HTTP ${response.status}`);
}
// Redirect to login page after successful registration
router.push("/login?registered=true");
} catch (err: unknown) {
const errorMessage =
err instanceof Error ? err.message : "An error occurred during registration";
setError(errorMessage);
// Success toast
toast.success("Account created successfully!", {
id: loadingToast,
description: "Redirecting to login page...",
duration: 2000,
});
// Small delay to show success message
setTimeout(() => {
router.push("/login?registered=true");
}, 500);
} catch (err) {
// Use auth-errors utility to get proper error details
let errorCode = "UNKNOWN_ERROR";
if (err instanceof Error) {
errorCode = err.message;
} else if (isNetworkError(err)) {
errorCode = "NETWORK_ERROR";
}
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);
// Set persistent error display
setErrorTitle(errorDetails.title);
setError(errorDetails.description);
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
onClick: () => handleSubmit(e),
};
}
toast.error(errorDetails.title, toastOptions);
} finally {
setIsLoading(false);
}
@ -77,11 +128,67 @@ export default function RegisterPage() {
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
{/* Enhanced Error Display */}
<AnimatePresence>
{error && errorTitle && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
<button
onClick={() => {
setError(null);
setErrorTitle(null);
}}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div>
<label
@ -96,7 +203,12 @@ export default function RegisterPage() {
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
@ -113,7 +225,12 @@ export default function RegisterPage() {
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
@ -130,14 +247,19 @@ export default function RegisterPage() {
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating account..." : "Register"}
</button>

View file

@ -3,43 +3,49 @@ import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://www.surfsense.net/",
url: "https://www.surfsense.com/",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://www.surfsense.net/privacy",
url: "https://www.surfsense.com/contact",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://www.surfsense.com/privacy",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/terms",
url: "https://www.surfsense.com/terms",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs",
url: "https://www.surfsense.com/docs",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/installation",
url: "https://www.surfsense.com/docs/installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/docker-installation",
url: "https://www.surfsense.com/docs/docker-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/manual-installation",
url: "https://www.surfsense.com/docs/manual-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,

View file

@ -1,5 +1,5 @@
"use client";
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc } from "@tabler/icons-react";
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc, IconMail } from "@tabler/icons-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Logo } from "./Logo";
@ -54,6 +54,13 @@ export function ModernHeroWithGradients() {
Notion, YouTube, GitHub, Discord and more.
</p>
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
<Link
href="/contact"
className="w-48 gap-1 rounded-full border border-blue-400 bg-gradient-to-b from-blue-100 to-blue-300 px-5 py-3 text-center text-sm font-medium text-blue-900 shadow-sm dark:border-blue-700 dark:bg-gradient-to-b dark:from-blue-900 dark:to-blue-700 dark:text-blue-100 dark:shadow-inner dark:shadow-blue-500/20 flex items-center justify-center"
>
<IconMail className="h-5 w-5 mr-2 text-blue-700 dark:text-blue-300" />
<span>Contact Us</span>
</Link>
<Link
href="https://discord.gg/ejRNvftDp9"
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10 flex items-center justify-center"

View file

@ -1,6 +1,6 @@
"use client";
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
import { IconMail, IconMenu2, IconUser, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react";
import Link from "next/link";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
@ -167,7 +167,17 @@ const DesktopNav = ({ navItems, visible }: NavbarProps) => {
duration: 0.2,
},
}}
className="flex items-center gap-2"
>
<Link href="/contact">
<Button
variant="outline"
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
>
<IconMail className="h-4 w-4" />
<span>Contact Us</span>
</Button>
</Link>
<Button
onClick={handleGoogleLogin}
variant="outline"
@ -270,6 +280,15 @@ const MobileNav = ({ navItems, visible }: NavbarProps) => {
<motion.span className="block">{navItem.name}</motion.span>
</Link>
))}
<Link href="/contact" className="w-full" onClick={() => setOpen(false)}>
<Button
variant="outline"
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
>
<IconMail className="h-4 w-4" />
<span>Contact Us</span>
</Button>
</Link>
<Button
onClick={handleGoogleLogin}
variant="outline"

View file

@ -1,6 +1,6 @@
"use client";
import { useInView } from "framer-motion";
import { useInView } from "motion/react";
import { Manrope } from "next/font/google";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
@ -85,7 +85,6 @@ const initialState: HighlightState = {
export function AnimatedEmptyState() {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const { state: sidebarState } = useSidebar();
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer,
initialState

View file

@ -0,0 +1,357 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconMailFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import type React from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { cn } from "@/lib/utils";
// Define validation schema matching the database schema
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
export function ContactFormGridWithDetails() {
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
toast.success("Message sent successfully!", {
description: "We will get back to you as soon as possible.",
});
reset();
} else {
toast.error("Failed to send message", {
description: result.message || "Please try again later.",
});
}
} catch (error) {
console.error("Error submitting form:", error);
toast.error("Something went wrong", {
description: "Please try again later.",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
<div className="flex items-start justify-start">
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
<IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer>
</div>
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h2>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
We'd love to Hear From You.
</p>
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
<Link
href="mailto:rohan@surfsense.com"
className="text-sm text-neutral-500 dark:text-neutral-400"
>
rohan@surfsense.com
</Link>
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
<Link
href="https://cal.com/mod-surfsense"
className="text-sm text-neutral-500 dark:text-neutral-400"
>
https://cal.com/mod-surfsense
</Link>
</div>
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
<Pin className="h-30 w-85 top-0 left-0" />
<Image
src="/contact/world.svg"
width={500}
height={500}
alt="world map"
className="[transform:rotateX(45deg)_translateZ(0px)] dark:invert dark:filter"
/>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
>
<Grid size={20} />
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="name"
>
Full name
</label>
<input
id="name"
type="text"
placeholder="John Doe"
{...register("name")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.name ? "border-red-500" : "border-transparent"
)}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="email"
>
Email Address
</label>
<input
id="email"
type="email"
placeholder="john.doe@example.com"
{...register("email")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.email ? "border-red-500" : "border-transparent"
)}
/>
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="company"
>
Company
</label>
<input
id="company"
type="text"
placeholder="Example Inc."
{...register("company")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.company ? "border-red-500" : "border-transparent"
)}
/>
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="message"
>
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
</label>
<textarea
id="message"
rows={5}
placeholder="Type your message here"
{...register("message")}
className={cn(
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.message ? "border-red-500" : "border-transparent"
)}
/>
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}
const Pin = ({ className }: { className?: string }) => {
return (
<motion.div
style={{ transform: "translateZ(1px)" }}
className={cn(
"pointer-events-none absolute z-[60] flex h-40 w-96 items-center justify-center opacity-100 transition duration-500",
className
)}
>
<div className="h-full w-full">
<div className="absolute inset-x-0 top-0 z-20 mx-auto inline-block w-fit rounded-lg bg-neutral-200 px-2 py-1 text-xs font-normal text-neutral-700 dark:bg-neutral-800 dark:text-white">
We are here
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-blue-400/0 via-blue-400/90 to-blue-400/0 transition-opacity duration-500"></span>
</div>
<div
style={{
perspective: "800px",
transform: "rotateX(70deg) translateZ(0px)",
}}
className="absolute top-1/2 left-1/2 mt-4 ml-[0.09375rem] -translate-x-1/2 -translate-y-1/2"
>
<>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 0 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 2 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 4 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
</>
</div>
<>
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500 blur-[2px]" />
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500" />
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[4px] w-[4px] translate-x-[1.5px] translate-y-[14px] rounded-full bg-blue-600 blur-[3px]" />
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[2px] w-[2px] translate-x-[0.5px] translate-y-[14px] rounded-full bg-blue-300" />
</>
</div>
</motion.div>
);
};
export const FeatureIconContainer = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"relative h-14 w-14 rounded-md bg-gradient-to-b from-gray-50 to-neutral-200 p-[4px] dark:from-neutral-800 dark:to-neutral-950",
className
)}
>
<div
className={cn(
"relative z-20 h-full w-full rounded-[5px] bg-gray-50 dark:bg-neutral-800",
className
)}
>
{children}
</div>
<div className="absolute inset-x-0 bottom-0 z-30 mx-auto h-4 w-full rounded-full bg-neutral-600 opacity-50 blur-lg"></div>
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-500 to-transparent"></div>
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-600 to-transparent dark:h-[8px] dark:blur-sm"></div>
</div>
);
};
export const Grid = ({ pattern, size }: { pattern?: number[][]; size?: number }) => {
const p = pattern ?? [
[9, 3],
[8, 5],
[10, 2],
[7, 4],
[9, 6],
];
return (
<div className="pointer-events-none absolute top-0 left-1/2 -mt-2 -ml-20 h-full w-full [mask-image:linear-gradient(white,transparent)]">
<div className="absolute inset-0 bg-gradient-to-r from-zinc-900/30 to-zinc-900/30 opacity-10 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-zinc-900/30 dark:to-zinc-900/30">
<GridPattern
width={size ?? 20}
height={size ?? 20}
x="-12"
y="4"
squares={p}
className="absolute inset-0 h-full w-full fill-black/100 stroke-black/100 mix-blend-overlay dark:fill-white/100 dark:stroke-white/100"
/>
</div>
</div>
);
};
export function GridPattern({ width, height, x, y, squares, ...props }: any) {
const patternId = useId();
return (
<svg aria-hidden="true" {...props}>
<defs>
<pattern
id={patternId}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${patternId})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible">
{squares.map(([x, y]: any, idx: number) => (
<rect
strokeWidth="0"
key={`${x}-${y}-${idx}`}
width={width + 1}
height={height + 1}
x={x * width}
y={y * height}
/>
))}
</svg>
)}
</svg>
);
}

View file

@ -87,6 +87,7 @@ export function DashboardBreadcrumb() {
"tavily-api": "Tavily API",
"serper-api": "Serper API",
"linkup-api": "LinkUp API",
"luma-connector": "Luma",
};
const connectorLabel = connectorLabels[connectorType] || connectorType;

View file

@ -43,5 +43,6 @@ export const editConnectorSchema = z.object({
GOOGLE_CALENDAR_CLIENT_SECRET: z.string().optional(),
GOOGLE_CALENDAR_REFRESH_TOKEN: z.string().optional(),
GOOGLE_CALENDAR_CALENDAR_IDS: z.string().optional(),
LUMA_API_KEY: z.string().optional(),
});
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react";
import { motion } from "motion/react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";

View file

@ -0,0 +1,202 @@
"use client";
import NumberFlow from "@number-flow/react";
import confetti from "canvas-confetti";
import { motion } from "motion/react";
import { Check, Star } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
interface PricingPlan {
name: string;
price: string;
yearlyPrice: string;
period: string;
features: string[];
description: string;
buttonText: string;
href: string;
isPopular: boolean;
}
interface PricingProps {
plans: PricingPlan[];
title?: string;
description?: string;
}
export function Pricing({
plans,
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
}: PricingProps) {
const [isMonthly, setIsMonthly] = useState(true);
const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null);
const handleToggle = (checked: boolean) => {
setIsMonthly(!checked);
if (checked && switchRef.current) {
const rect = switchRef.current.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
confetti({
particleCount: 50,
spread: 60,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
colors: [
"hsl(var(--primary))",
"hsl(var(--accent))",
"hsl(var(--secondary))",
"hsl(var(--muted))",
],
ticks: 200,
gravity: 1.2,
decay: 0.94,
startVelocity: 30,
shapes: ["circle"],
});
}
};
return (
<div className="container py-20">
<div className="text-center space-y-4 mb-12">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
</div>
<div className="flex justify-center mb-10">
<label className="relative inline-flex items-center cursor-pointer">
<Label>
<Switch
ref={switchRef as any}
checked={!isMonthly}
onCheckedChange={handleToggle}
className="relative"
/>
</Label>
</label>
<span className="ml-2 font-semibold">
Annual billing <span className="text-primary">(Save 20%)</span>
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 sm:2 gap-4">
{plans.map((plan, index) => (
<motion.div
key={index}
initial={{ y: 50, opacity: 1 }}
whileInView={
isDesktop
? {
y: plan.isPopular ? -20 : 0,
opacity: 1,
x: index === 2 ? -30 : index === 0 ? 30 : 0,
scale: index === 0 || index === 2 ? 0.94 : 1.0,
}
: {}
}
viewport={{ once: true }}
transition={{
duration: 1.6,
type: "spring",
stiffness: 100,
damping: 30,
delay: 0.4,
opacity: { duration: 0.5 },
}}
className={cn(
`rounded-2xl border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
plan.isPopular ? "border-primary border-2" : "border-border",
"flex flex-col",
!plan.isPopular && "mt-5",
index === 0 || index === 2
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
: "z-10",
index === 0 && "origin-right",
index === 2 && "origin-left"
)}
>
{plan.isPopular && (
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-xl rounded-tr-xl flex items-center">
<Star className="text-primary-foreground h-4 w-4 fill-current" />
<span className="text-primary-foreground ml-1 font-sans font-semibold">
Popular
</span>
</div>
)}
<div className="flex-1 flex flex-col">
<p className="text-base font-semibold text-muted-foreground">{plan.name}</p>
<div className="mt-6 flex items-center justify-center gap-x-2">
<span className="text-5xl font-bold tracking-tight text-foreground">
<NumberFlow
value={isMonthly ? Number(plan.price) : Number(plan.yearlyPrice)}
format={{
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}}
transformTiming={{
duration: 500,
easing: "ease-out",
}}
willChange
className="font-variant-numeric: tabular-nums"
/>
</span>
{plan.period !== "Next 3 months" && (
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
/ {plan.period}
</span>
)}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{isMonthly ? "billed monthly" : "billed annually"}
</p>
<ul className="mt-5 gap-2 flex flex-col">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-4 w-4 text-primary mt-1 flex-shrink-0" />
<span className="text-left">{feature}</span>
</li>
))}
</ul>
<hr className="w-full my-4" />
<Link
href={plan.href}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground"
)}
>
{plan.buttonText}
</Link>
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
</div>
</motion.div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { Pricing } from "@/components/pricing";
const demoPlans = [
{
name: "STARTER",
price: "50",
yearlyPrice: "40",
period: "per month",
features: [
"Up to 10 projects",
"Basic analytics",
"48-hour support response time",
"Limited API access",
"Community support",
],
description: "Perfect for individuals and small projects",
buttonText: "Start Free Trial",
href: "/sign-up",
isPopular: false,
},
{
name: "PROFESSIONAL",
price: "99",
yearlyPrice: "79",
period: "per month",
features: [
"Unlimited projects",
"Advanced analytics",
"24-hour support response time",
"Full API access",
"Priority support",
"Team collaboration",
"Custom integrations",
],
description: "Ideal for growing teams and businesses",
buttonText: "Get Started",
href: "/sign-up",
isPopular: true,
},
{
name: "ENTERPRISE",
price: "299",
yearlyPrice: "239",
period: "per month",
features: [
"Everything in Professional",
"Custom solutions",
"Dedicated account manager",
"1-hour support response time",
"SSO Authentication",
"Advanced security",
"Custom contracts",
"SLA agreement",
],
description: "For large organizations with specific needs",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
},
];
function PricingBasic() {
return (
<Pricing
plans={demoPlans}
title="Simple, Transparent Pricing"
description="Choose the plan that works for you"
/>
);
}
export default PricingBasic;

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion, type Variants } from "framer-motion";
import { MoveLeftIcon, Plus, Search, Trash2 } from "lucide-react";
import { motion, type Variants } from "motion/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";

View file

@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import {
AlertCircle,
Bot,
@ -13,6 +12,7 @@ import {
Settings2,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";

View file

@ -1,6 +1,5 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertCircle,
Bot,
@ -15,6 +14,7 @@ import {
Settings2,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
@ -112,12 +112,12 @@ const LLM_PROVIDERS = [
example: "meta/llama-2-70b-chat",
description: "Run models via API",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "anthropic/claude-opus-4.1, openai/gpt-5",
description: "API gateway and LLM marketplace that provides unified access ",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "anthropic/claude-opus-4.1, openai/gpt-5",
description: "API gateway and LLM marketplace that provides unified access ",
},
{
value: "CUSTOM",
label: "Custom Provider",

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { MoonIcon, SunIcon } from "lucide-react";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";

View file

@ -1,5 +1,5 @@
"use client";
import { motion, type SpringOptions, useSpring, useTransform } from "framer-motion";
import { motion, type SpringOptions, useSpring, useTransform } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

View file

@ -0,0 +1,29 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ 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 };

View file

@ -8,7 +8,7 @@ import {
useMotionValue,
useSpring,
useTransform,
} from "framer-motion";
} from "motion/react";
import type React from "react";
import { useRef } from "react";

View file

@ -13,4 +13,5 @@ export enum EnumConnectorName {
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR",
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR",
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR",
LUMA_CONNECTOR = "LUMA_CONNECTOR",
}

View file

@ -10,6 +10,7 @@ import {
IconLayoutKanban,
IconLinkPlus,
IconMail,
IconSparkles,
IconTable,
IconTicket,
IconWorldWww,
@ -49,6 +50,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <IconBook {...iconProps} />;
case EnumConnectorName.CLICKUP_CONNECTOR:
return <IconChecklist {...iconProps} />;
case EnumConnectorName.LUMA_CONNECTOR:
return <IconSparkles {...iconProps} />;
// Additional cases for non-enum connector types
case "YOUTUBE_VIDEO":
return <IconBrandYoutube {...iconProps} />;

View file

@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './app/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -34,7 +34,8 @@ export type DocumentType =
| "CONFLUENCE_CONNECTOR"
| "CLICKUP_CONNECTOR"
| "GOOGLE_CALENDAR_CONNECTOR"
| "GOOGLE_GMAIL_CONNECTOR";
| "GOOGLE_GMAIL_CONNECTOR"
| "LUMA_CONNECTOR";
export function useDocumentByChunk() {
const [document, setDocument] = useState<DocumentWithChunks | null>(null);

View file

@ -28,7 +28,8 @@ export type DocumentType =
| "CLICKUP_CONNECTOR"
| "GOOGLE_CALENDAR_CONNECTOR"
| "GOOGLE_GMAIL_CONNECTOR"
| "AIRTABLE_CONNECTOR";
| "AIRTABLE_CONNECTOR"
| "LUMA_CONNECTOR";
export interface UseDocumentsOptions {
page?: number;

View file

@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
/**
* Custom hook that tracks if a media query matches
* @param query - The media query string to match (e.g., "(min-width: 768px)")
* @returns boolean - True if the media query matches, false otherwise
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
// Check if we're in the browser (handle SSR)
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia(query);
// Set initial value
setMatches(mediaQuery.matches);
// Create event listener
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Add event listener
mediaQuery.addEventListener("change", handler);
// Cleanup
return () => {
mediaQuery.removeEventListener("change", handler);
};
}, [query]);
return matches;
}

View file

@ -52,6 +52,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
JIRA_BASE_URL: "",
JIRA_EMAIL: "",
JIRA_API_TOKEN: "",
LUMA_API_KEY: "",
},
});
@ -78,6 +79,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
JIRA_BASE_URL: config.JIRA_BASE_URL || "",
JIRA_EMAIL: config.JIRA_EMAIL || "",
JIRA_API_TOKEN: config.JIRA_API_TOKEN || "",
LUMA_API_KEY: config.LUMA_API_KEY || "",
});
if (currentConnector.connector_type === "GITHUB_CONNECTOR") {
const savedRepos = config.repo_full_names || [];
@ -303,6 +305,16 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
};
}
break;
case "LUMA_CONNECTOR":
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
if (!formData.LUMA_API_KEY) {
toast.error("Luma API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LUMA_API_KEY: formData.LUMA_API_KEY };
}
break;
}
if (newConfig !== null) {
@ -365,6 +377,8 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
editForm.setValue("JIRA_BASE_URL", newlySavedConfig.JIRA_BASE_URL || "");
editForm.setValue("JIRA_EMAIL", newlySavedConfig.JIRA_EMAIL || "");
editForm.setValue("JIRA_API_TOKEN", newlySavedConfig.JIRA_API_TOKEN || "");
} else if (connector.connector_type === "LUMA_CONNECTOR") {
editForm.setValue("LUMA_API_KEY", newlySavedConfig.LUMA_API_KEY || "");
}
}
if (connector.connector_type === "GITHUB_CONNECTOR") {

View file

@ -0,0 +1,214 @@
/**
* Authentication error messages and handling utilities
*/
interface AuthErrorMapping {
[key: string]: {
title: string;
description?: string;
};
}
const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
// Common HTTP errors
"401": {
title: "Invalid credentials",
description: "Please check your email and password",
},
"403": {
title: "Access denied",
description: "Your account may be suspended or restricted",
},
"404": {
title: "Account not found",
description: "No account exists with this email address",
},
"409": {
title: "Account conflict",
description: "An account with this email already exists",
},
"429": {
title: "Too many attempts",
description: "Please wait before trying again",
},
"500": {
title: "Server error",
description: "Something went wrong on our end. Please try again",
},
"503": {
title: "Service unavailable",
description: "Login service is temporarily down",
},
// FastAPI specific errors
LOGIN_BAD_CREDENTIALS: {
title: "Invalid credentials",
description: "The email or password you entered is incorrect",
},
LOGIN_USER_NOT_VERIFIED: {
title: "Account not verified",
description: "Please verify your email address before signing in",
},
USER_INACTIVE: {
title: "Account inactive",
description: "Your account has been deactivated. Contact support for assistance",
},
REGISTER_USER_ALREADY_EXISTS: {
title: "Account already exists",
description: "An account with this email address already exists",
},
REGISTER_INVALID_PASSWORD: {
title: "Invalid password",
description: "Password must meet security requirements",
},
// OAuth errors
access_denied: {
title: "Access denied",
description: "You denied access or cancelled the login process",
},
invalid_request: {
title: "Invalid request",
description: "The login request was malformed",
},
unauthorized_client: {
title: "Authentication failed",
description: "The application is not authorized to perform this action",
},
unsupported_response_type: {
title: "Login method not supported",
description: "This login method is not currently available",
},
invalid_scope: {
title: "Invalid permissions",
description: "The requested permissions are not valid",
},
server_error: {
title: "Server error",
description: "An error occurred on the authentication server",
},
temporarily_unavailable: {
title: "Service unavailable",
description: "Login is temporarily unavailable. Please try again later",
},
// Network errors
NETWORK_ERROR: {
title: "Connection failed",
description: "Please check your internet connection and try again",
},
TIMEOUT: {
title: "Request timeout",
description: "The login request took too long. Please try again",
},
// Generic fallbacks
UNKNOWN_ERROR: {
title: "Login failed",
description: "An unexpected error occurred. Please try again",
},
};
/**
* Get a user-friendly error message for authentication errors
* @param errorCode - The error code or message from the API
* @param returnTitle - Whether to return just the title or full description
* @returns Formatted error message
*/
export function getAuthErrorMessage(errorCode: string, returnTitle: boolean = false): string {
if (!errorCode) {
const fallback = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
return returnTitle ? fallback.title : fallback.description || fallback.title;
}
// Clean up the error code
const cleanErrorCode = errorCode.trim().toUpperCase();
// Try exact match first
let errorInfo = AUTH_ERROR_MESSAGES[cleanErrorCode] || AUTH_ERROR_MESSAGES[errorCode];
// Try partial matches for HTTP status codes
if (!errorInfo) {
const statusCodeMatch = errorCode.match(/(\d{3})/);
if (statusCodeMatch) {
errorInfo = AUTH_ERROR_MESSAGES[statusCodeMatch[1]];
}
}
// Try partial matches for common error patterns
if (!errorInfo) {
const patterns = [
{ pattern: /credential|password|email/i, code: "LOGIN_BAD_CREDENTIALS" },
{ pattern: /verify|verification/i, code: "LOGIN_USER_NOT_VERIFIED" },
{ pattern: /inactive|disabled|suspended/i, code: "USER_INACTIVE" },
{ pattern: /exists|duplicate/i, code: "REGISTER_USER_ALREADY_EXISTS" },
{ pattern: /network|connection/i, code: "NETWORK_ERROR" },
{ pattern: /timeout/i, code: "TIMEOUT" },
{ pattern: /rate|limit|many/i, code: "429" },
];
for (const { pattern, code } of patterns) {
if (pattern.test(errorCode)) {
errorInfo = AUTH_ERROR_MESSAGES[code];
break;
}
}
}
// Fallback to unknown error
if (!errorInfo) {
errorInfo = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
}
return returnTitle ? errorInfo.title : errorInfo.description || errorInfo.title;
}
/**
* Get both title and description for an error
* @param errorCode - The error code or message from the API
* @returns Object with title and description
*/
export function getAuthErrorDetails(errorCode: string): { title: string; description: string } {
const title = getAuthErrorMessage(errorCode, true);
const description = getAuthErrorMessage(errorCode, false);
return { title, description };
}
/**
* Check if an error is a network-related error
* @param error - The error object or message
* @returns True if it's a network error
*/
export function isNetworkError(error: unknown): boolean {
if (error instanceof TypeError && error.message.includes("fetch")) {
return true;
}
if (typeof error === "string") {
return /network|connection|fetch|cors/i.test(error);
}
return false;
}
/**
* Check if an error should trigger a retry action
* @param errorCode - The error code or message
* @returns True if retry is recommended
*/
export function shouldRetry(errorCode: string): boolean {
const retryableCodes = [
"500",
"503",
"429",
"NETWORK_ERROR",
"TIMEOUT",
"server_error",
"temporarily_unavailable",
];
return retryableCodes.some(
(code) => errorCode.includes(code) || errorCode.toUpperCase().includes(code)
);
}

View file

@ -15,6 +15,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
AIRTABLE_CONNECTOR: "Airtable",
LUMA_CONNECTOR: "Luma",
};
return typeMap[type] || type;
};

View file

@ -12,12 +12,17 @@
"debug": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
"debug:browser": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
"debug:server": "cross-env NODE_OPTIONS=--inspect=0.0.0.0:9229 next dev --turbopack",
"postinstall": "fumadocs-mdx"
"postinstall": "fumadocs-mdx",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@hookform/resolvers": "^4.1.3",
"@llamaindex/chat-ui": "^0.5.17",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
@ -32,6 +37,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
@ -41,18 +47,23 @@
"@types/mdx": "^2.0.13",
"@types/react-syntax-highlighter": "^15.5.13",
"ai": "^4.3.19",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.5",
"emblor": "^1.4.8",
"framer-motion": "^12.23.9",
"fumadocs-core": "^15.6.6",
"fumadocs-mdx": "^11.7.1",
"fumadocs-ui": "^15.6.6",
"geist": "^1.4.2",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^15.4.4",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"postgres": "^3.4.7",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
@ -76,13 +87,17 @@
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20.19.9",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.5",
"eslint": "^9.32.0",
"eslint-config-next": "15.2.0",
"tailwindcss": "^4.1.11",
"tsx": "^4.20.6",
"typescript": "^5.8.3"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long