mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
feat: added incentive credits system
This commit is contained in:
parent
d45b33e776
commit
39d65d6166
27 changed files with 587 additions and 84 deletions
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Add user incentive tasks table for earning free pages
|
||||||
|
|
||||||
|
Revision ID: 80
|
||||||
|
Revises: 79
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. Create incentive_task_type enum with GITHUB_STAR value
|
||||||
|
2. Create user_incentive_tasks table to track completed tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "80"
|
||||||
|
down_revision: str | None = "79"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create incentive tasks infrastructure."""
|
||||||
|
|
||||||
|
# Check if enum already exists (handles partial migration recovery)
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'")
|
||||||
|
)
|
||||||
|
enum_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
# Create the enum type only if it doesn't exist
|
||||||
|
if not enum_exists:
|
||||||
|
incentive_task_type_enum = postgresql.ENUM(
|
||||||
|
"GITHUB_STAR",
|
||||||
|
name="incentivetasktype",
|
||||||
|
create_type=False,
|
||||||
|
)
|
||||||
|
incentive_task_type_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# Check if table already exists (handles partial migration recovery)
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
# Create the user_incentive_tasks table
|
||||||
|
op.create_table(
|
||||||
|
"user_incentive_tasks",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"task_type",
|
||||||
|
postgresql.ENUM(
|
||||||
|
"GITHUB_STAR", name="incentivetasktype", create_type=False
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("pages_awarded", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"completed_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove incentive tasks infrastructure."""
|
||||||
|
|
||||||
|
# Drop the table
|
||||||
|
op.drop_table("user_incentive_tasks")
|
||||||
|
|
||||||
|
# Drop the enum type
|
||||||
|
postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True)
|
||||||
|
|
@ -144,6 +144,43 @@ class LogStatus(str, Enum):
|
||||||
FAILED = "FAILED"
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTaskType(str, Enum):
|
||||||
|
"""
|
||||||
|
Enum for incentive task types that users can complete to earn free pages.
|
||||||
|
Each task can only be completed once per user.
|
||||||
|
|
||||||
|
When adding new tasks:
|
||||||
|
1. Add a new enum value here
|
||||||
|
2. Add the task configuration to INCENTIVE_TASKS_CONFIG below
|
||||||
|
3. Create an Alembic migration to add the enum value to PostgreSQL
|
||||||
|
"""
|
||||||
|
|
||||||
|
GITHUB_STAR = "GITHUB_STAR"
|
||||||
|
# Future tasks can be added here:
|
||||||
|
# GITHUB_ISSUE = "GITHUB_ISSUE"
|
||||||
|
# SOCIAL_SHARE = "SOCIAL_SHARE"
|
||||||
|
# REFER_FRIEND = "REFER_FRIEND"
|
||||||
|
|
||||||
|
|
||||||
|
# Centralized configuration for incentive tasks
|
||||||
|
# This makes it easy to add new tasks without changing code in multiple places
|
||||||
|
INCENTIVE_TASKS_CONFIG = {
|
||||||
|
IncentiveTaskType.GITHUB_STAR: {
|
||||||
|
"title": "Star our GitHub repository",
|
||||||
|
"description": "Show your support by starring SurfSense on GitHub",
|
||||||
|
"pages_reward": 100,
|
||||||
|
"action_url": "https://github.com/MODSetter/SurfSense",
|
||||||
|
},
|
||||||
|
# Future tasks can be configured here:
|
||||||
|
# IncentiveTaskType.GITHUB_ISSUE: {
|
||||||
|
# "title": "Create an issue",
|
||||||
|
# "description": "Help improve SurfSense by reporting bugs or suggesting features",
|
||||||
|
# "pages_reward": 50,
|
||||||
|
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
|
||||||
|
# },
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Permission(str, Enum):
|
class Permission(str, Enum):
|
||||||
"""
|
"""
|
||||||
Granular permissions for search space resources.
|
Granular permissions for search space resources.
|
||||||
|
|
@ -915,6 +952,39 @@ class Notification(BaseModel, TimestampMixin):
|
||||||
search_space = relationship("SearchSpace", back_populates="notifications")
|
search_space = relationship("SearchSpace", back_populates="notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class UserIncentiveTask(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Tracks completed incentive tasks for users.
|
||||||
|
Each user can only complete each task type once.
|
||||||
|
When a task is completed, the user's pages_limit is increased.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_incentive_tasks"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"task_type",
|
||||||
|
name="uq_user_incentive_task",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True)
|
||||||
|
pages_awarded = Column(Integer, nullable=False)
|
||||||
|
completed_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="incentive_tasks")
|
||||||
|
|
||||||
|
|
||||||
class SearchSpaceRole(BaseModel, TimestampMixin):
|
class SearchSpaceRole(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Custom roles that can be defined per search space.
|
Custom roles that can be defined per search space.
|
||||||
|
|
@ -1093,6 +1163,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Incentive tasks completed by this user
|
||||||
|
incentive_tasks = relationship(
|
||||||
|
"UserIncentiveTask",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -1144,6 +1221,13 @@ else:
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Incentive tasks completed by this user
|
||||||
|
incentive_tasks = relationship(
|
||||||
|
"UserIncentiveTask",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
|
||||||
from .google_gmail_add_connector_route import (
|
from .google_gmail_add_connector_route import (
|
||||||
router as google_gmail_add_connector_router,
|
router as google_gmail_add_connector_router,
|
||||||
)
|
)
|
||||||
|
from .incentive_tasks_routes import router as incentive_tasks_router
|
||||||
from .jira_add_connector_route import router as jira_add_connector_router
|
from .jira_add_connector_route import router as jira_add_connector_router
|
||||||
from .linear_add_connector_route import router as linear_add_connector_router
|
from .linear_add_connector_route import router as linear_add_connector_router
|
||||||
from .logs_routes import router as logs_router
|
from .logs_routes import router as logs_router
|
||||||
|
|
@ -67,3 +68,4 @@ router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||||
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
||||||
router.include_router(composio_router) # Composio OAuth and toolkit management
|
router.include_router(composio_router) # Composio OAuth and toolkit management
|
||||||
|
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
|
||||||
|
|
|
||||||
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""
|
||||||
|
Incentive Tasks API routes.
|
||||||
|
Allows users to complete tasks (like starring GitHub repo) to earn free pages.
|
||||||
|
Each task can only be completed once per user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import (
|
||||||
|
INCENTIVE_TASKS_CONFIG,
|
||||||
|
IncentiveTaskType,
|
||||||
|
User,
|
||||||
|
UserIncentiveTask,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.schemas.incentive_tasks import (
|
||||||
|
CompleteTaskResponse,
|
||||||
|
IncentiveTaskInfo,
|
||||||
|
IncentiveTasksResponse,
|
||||||
|
TaskAlreadyCompletedResponse,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=IncentiveTasksResponse)
|
||||||
|
async def get_incentive_tasks(
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> IncentiveTasksResponse:
|
||||||
|
"""
|
||||||
|
Get all available incentive tasks with the user's completion status.
|
||||||
|
"""
|
||||||
|
# Get all completed tasks for this user
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id)
|
||||||
|
)
|
||||||
|
completed_tasks = {task.task_type: task for task in result.scalars().all()}
|
||||||
|
|
||||||
|
# Build task list with completion status
|
||||||
|
tasks = []
|
||||||
|
total_pages_earned = 0
|
||||||
|
|
||||||
|
for task_type, config in INCENTIVE_TASKS_CONFIG.items():
|
||||||
|
completed_task = completed_tasks.get(task_type)
|
||||||
|
is_completed = completed_task is not None
|
||||||
|
|
||||||
|
if is_completed:
|
||||||
|
total_pages_earned += completed_task.pages_awarded
|
||||||
|
|
||||||
|
tasks.append(
|
||||||
|
IncentiveTaskInfo(
|
||||||
|
task_type=task_type,
|
||||||
|
title=config["title"],
|
||||||
|
description=config["description"],
|
||||||
|
pages_reward=config["pages_reward"],
|
||||||
|
action_url=config["action_url"],
|
||||||
|
completed=is_completed,
|
||||||
|
completed_at=completed_task.completed_at if completed_task else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return IncentiveTasksResponse(
|
||||||
|
tasks=tasks,
|
||||||
|
total_pages_earned=total_pages_earned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{task_type}/complete",
|
||||||
|
response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse,
|
||||||
|
)
|
||||||
|
async def complete_task(
|
||||||
|
task_type: IncentiveTaskType,
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
|
||||||
|
"""
|
||||||
|
Mark an incentive task as completed and award pages to the user.
|
||||||
|
|
||||||
|
Each task can only be completed once. If the task was already completed,
|
||||||
|
returns the existing completion information without awarding additional pages.
|
||||||
|
"""
|
||||||
|
# Validate task type exists in config
|
||||||
|
task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||||
|
if not task_config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unknown task type: {task_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if task was already completed
|
||||||
|
existing_task = await session.execute(
|
||||||
|
select(UserIncentiveTask).where(
|
||||||
|
UserIncentiveTask.user_id == user.id,
|
||||||
|
UserIncentiveTask.task_type == task_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = existing_task.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return TaskAlreadyCompletedResponse(
|
||||||
|
success=False,
|
||||||
|
message="Task already completed",
|
||||||
|
completed_at=existing.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the task completion record
|
||||||
|
pages_reward = task_config["pages_reward"]
|
||||||
|
new_task = UserIncentiveTask(
|
||||||
|
user_id=user.id,
|
||||||
|
task_type=task_type,
|
||||||
|
pages_awarded=pages_reward,
|
||||||
|
)
|
||||||
|
session.add(new_task)
|
||||||
|
|
||||||
|
# Update user's pages_limit
|
||||||
|
user.pages_limit += pages_reward
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
return CompleteTaskResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Task completed! You earned {pages_reward} pages.",
|
||||||
|
pages_awarded=pages_reward,
|
||||||
|
new_pages_limit=user.pages_limit,
|
||||||
|
)
|
||||||
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
Schemas for incentive tasks API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTaskInfo(BaseModel):
|
||||||
|
"""Information about an available incentive task."""
|
||||||
|
|
||||||
|
task_type: IncentiveTaskType
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
pages_reward: int
|
||||||
|
action_url: str
|
||||||
|
completed: bool
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTasksResponse(BaseModel):
|
||||||
|
"""Response containing all available incentive tasks with completion status."""
|
||||||
|
|
||||||
|
tasks: list[IncentiveTaskInfo]
|
||||||
|
total_pages_earned: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteTaskRequest(BaseModel):
|
||||||
|
"""Request to mark a task as completed."""
|
||||||
|
|
||||||
|
task_type: IncentiveTaskType
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteTaskResponse(BaseModel):
|
||||||
|
"""Response after completing a task."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
pages_awarded: int
|
||||||
|
new_pages_limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAlreadyCompletedResponse(BaseModel):
|
||||||
|
"""Response when task was already completed."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
completed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_info(task_type: IncentiveTaskType) -> dict | None:
|
||||||
|
"""Get task configuration by type."""
|
||||||
|
return INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_task_types() -> list[IncentiveTaskType]:
|
||||||
|
"""Get all configured task types."""
|
||||||
|
return list(INCENTIVE_TASKS_CONFIG.keys())
|
||||||
|
|
@ -87,10 +87,14 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
||||||
context_parts.append("<document>")
|
context_parts.append("<document>")
|
||||||
context_parts.append("<document_metadata>")
|
context_parts.append("<document_metadata>")
|
||||||
context_parts.append(f" <document_id>{doc.id}</document_id>")
|
context_parts.append(f" <document_id>{doc.id}</document_id>")
|
||||||
context_parts.append(f" <document_type>{doc.document_type.value}</document_type>")
|
context_parts.append(
|
||||||
|
f" <document_type>{doc.document_type.value}</document_type>"
|
||||||
|
)
|
||||||
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||||
context_parts.append(f" <url><![CDATA[{url}]]></url>")
|
context_parts.append(f" <url><![CDATA[{url}]]></url>")
|
||||||
context_parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
|
context_parts.append(
|
||||||
|
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||||
|
)
|
||||||
context_parts.append("</document_metadata>")
|
context_parts.append("</document_metadata>")
|
||||||
context_parts.append("")
|
context_parts.append("")
|
||||||
context_parts.append("<document_content>")
|
context_parts.append("<document_content>")
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { ValidationError } from "@/lib/error";
|
import { ValidationError } from "@/lib/error";
|
||||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
|
|
||||||
export function LocalLoginForm() {
|
export function LocalLoginForm() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { AppError, ValidationError } from "@/lib/error";
|
import { AppError, ValidationError } from "@/lib/error";
|
||||||
|
|
@ -18,7 +19,6 @@ import {
|
||||||
trackRegistrationSuccess,
|
trackRegistrationSuccess,
|
||||||
} from "@/lib/posthog/events";
|
} from "@/lib/posthog/events";
|
||||||
import { AmbientBackground } from "../login/AmbientBackground";
|
import { AmbientBackground } from "../login/AmbientBackground";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import React from "react";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { DocumentViewer } from "@/components/document-viewer";
|
import { DocumentViewer } from "@/components/document-viewer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,50 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ExternalLink, Gift, Mail, Star, MessageSquarePlus, Share2, Check } from "lucide-react";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, useCallback } from "react";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||||
|
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const GITHUB_REPO_URL = "https://github.com/MODSetter/SurfSense";
|
export default function MorePagesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const INITIAL_TASKS = [
|
// Fetch tasks from API
|
||||||
{
|
const { data, isLoading } = useQuery({
|
||||||
id: "star",
|
queryKey: ["incentive-tasks"],
|
||||||
title: "Star the repository",
|
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||||
reward: 100,
|
});
|
||||||
href: GITHUB_REPO_URL,
|
|
||||||
icon: Star,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "issue",
|
|
||||||
title: "Create an issue",
|
|
||||||
reward: 50,
|
|
||||||
href: `${GITHUB_REPO_URL}/issues/new/choose`,
|
|
||||||
icon: MessageSquarePlus,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "share",
|
|
||||||
title: "Share on social media",
|
|
||||||
reward: 50,
|
|
||||||
href: `https://twitter.com/intent/tweet?text=Check out SurfSense - an AI-powered personal knowledge base!&url=${encodeURIComponent(GITHUB_REPO_URL)}`,
|
|
||||||
icon: Share2,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function FreePagesPage() {
|
// Mutation to complete a task
|
||||||
const [completedIds, setCompletedIds] = useState<Set<string>>(new Set());
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: incentiveTasksApiService.completeTask,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message);
|
||||||
|
// Invalidate queries to refresh data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to complete task. Please try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleTaskClick = useCallback((taskId: string) => {
|
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||||
setCompletedIds((prev) => new Set(prev).add(taskId));
|
if (!task.completed) {
|
||||||
}, []);
|
completeMutation.mutate(task.task_type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allCompleted = completedIds.size === INITIAL_TASKS.length;
|
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||||
|
|
@ -55,67 +57,93 @@ export default function FreePagesPage() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 text-center">
|
<div className="mb-6 text-center">
|
||||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||||
<h2 className="text-xl font-bold tracking-tight">Get Pages</h2>
|
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
|
||||||
Complete tasks to get free additional pages
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
{INITIAL_TASKS.map((task) => {
|
<Card>
|
||||||
const isCompleted = completedIds.has(task.id);
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
const Icon = task.icon;
|
<Skeleton className="h-9 w-9 rounded-full" />
|
||||||
return (
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data?.tasks.map((task) => (
|
||||||
<Card
|
<Card
|
||||||
key={task.id}
|
key={task.task_type}
|
||||||
className={cn("transition-colors", isCompleted && "bg-muted/50")}
|
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-center gap-3 p-3">
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||||
isCompleted ? "bg-primary text-primary-foreground" : "bg-muted"
|
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCompleted ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={cn("text-sm font-medium", isCompleted && "text-muted-foreground line-through")}>
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
task.completed && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">+{task.reward} pages</p>
|
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant={isCompleted ? "ghost" : "outline"}
|
variant={task.completed ? "ghost" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
asChild
|
disabled={task.completed || completeMutation.isPending}
|
||||||
onClick={() => handleTaskClick(task.id)}
|
onClick={() => handleTaskClick(task)}
|
||||||
|
asChild={!task.completed}
|
||||||
>
|
>
|
||||||
<a
|
{task.completed ? (
|
||||||
href={task.href}
|
<span>Done</span>
|
||||||
target="_blank"
|
) : (
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className={cn("gap-1", isCompleted && "pointer-events-none opacity-50")}
|
href={task.action_url}
|
||||||
>
|
target="_blank"
|
||||||
{isCompleted ? "Done" : "Go"}
|
rel="noopener noreferrer"
|
||||||
{!isCompleted && <ExternalLink className="h-3 w-3" />}
|
className="gap-1"
|
||||||
</a>
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Go
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Contact */}
|
{/* Contact */}
|
||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="mb-3 text-sm text-muted-foreground">
|
<p className="mb-3 text-sm text-muted-foreground">
|
||||||
{allCompleted ? "All done! Need more?" : "Need more pages?"}
|
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<Link href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits" className="gap-2">
|
<Link
|
||||||
|
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
Contact Us
|
Contact Us
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -105,7 +106,6 @@ import {
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import type {
|
import type {
|
||||||
CreateInviteRequest,
|
CreateInviteRequest,
|
||||||
DeleteInviteRequest,
|
DeleteInviteRequest,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "re
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -21,6 +20,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useDocumentUploadDialog } from "./document-upload-popup";
|
import { useDocumentUploadDialog } from "./document-upload-popup";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatSessionStatusProps {
|
interface ChatSessionStatusProps {
|
||||||
isAiResponding: boolean;
|
isAiResponding: boolean;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||||
import { getConnectorConfigComponent } from "../index";
|
|
||||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
|
import { getConnectorConfigComponent } from "../index";
|
||||||
|
|
||||||
interface ConnectorEditViewProps {
|
interface ConnectorEditViewProps {
|
||||||
connector: SearchSourceConnector;
|
connector: SearchSourceConnector;
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import { type FC, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function ContactFormGridWithDetails() {
|
||||||
<IconCalendar className="h-5 w-5" />
|
<IconCalendar className="h-5 w-5" />
|
||||||
Schedule a Meeting
|
Schedule a Meeting
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
|
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
|
||||||
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
|
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
|
||||||
<span className="text-sm">or</span>
|
<span className="text-sm">or</span>
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export const Navbar = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
|
<div className="fixed top-1 left-0 right-0 z-60 w-full">
|
||||||
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
|
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
|
||||||
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
deleteThread,
|
deleteThread,
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
|
||||||
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
|
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { Info, RotateCcw, Save } from "lucide-react";
|
import { Info, RotateCcw, Save } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
|
|
@ -21,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
trackDocumentUploadFailure,
|
trackDocumentUploadFailure,
|
||||||
trackDocumentUploadStarted,
|
trackDocumentUploadStarted,
|
||||||
|
|
|
||||||
67
surfsense_web/contracts/types/incentive-tasks.types.ts
Normal file
67
surfsense_web/contracts/types/incentive-tasks.types.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incentive task type enum - matches backend IncentiveTaskType
|
||||||
|
*/
|
||||||
|
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single incentive task info schema
|
||||||
|
*/
|
||||||
|
export const incentiveTaskInfo = z.object({
|
||||||
|
task_type: incentiveTaskTypeEnum,
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
pages_reward: z.number(),
|
||||||
|
action_url: z.string(),
|
||||||
|
completed: z.boolean(),
|
||||||
|
completed_at: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response schema for getting all incentive tasks
|
||||||
|
*/
|
||||||
|
export const getIncentiveTasksResponse = z.object({
|
||||||
|
tasks: z.array(incentiveTaskInfo),
|
||||||
|
total_pages_earned: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response schema for completing a task successfully
|
||||||
|
*/
|
||||||
|
export const completeTaskSuccessResponse = z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
message: z.string(),
|
||||||
|
pages_awarded: z.number(),
|
||||||
|
new_pages_limit: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response schema when task was already completed
|
||||||
|
*/
|
||||||
|
export const completeTaskAlreadyCompletedResponse = z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
message: z.string(),
|
||||||
|
completed_at: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union response for complete task endpoint
|
||||||
|
*/
|
||||||
|
export const completeTaskResponse = z.union([
|
||||||
|
completeTaskSuccessResponse,
|
||||||
|
completeTaskAlreadyCompletedResponse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Inferred types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type IncentiveTaskTypeEnum = z.infer<typeof incentiveTaskTypeEnum>;
|
||||||
|
export type IncentiveTaskInfo = z.infer<typeof incentiveTaskInfo>;
|
||||||
|
export type GetIncentiveTasksResponse = z.infer<typeof getIncentiveTasksResponse>;
|
||||||
|
export type CompleteTaskSuccessResponse = z.infer<typeof completeTaskSuccessResponse>;
|
||||||
|
export type CompleteTaskAlreadyCompletedResponse = z.infer<
|
||||||
|
typeof completeTaskAlreadyCompletedResponse
|
||||||
|
>;
|
||||||
|
export type CompleteTaskResponse = z.infer<typeof completeTaskResponse>;
|
||||||
29
surfsense_web/lib/apis/incentive-tasks-api.service.ts
Normal file
29
surfsense_web/lib/apis/incentive-tasks-api.service.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import {
|
||||||
|
type CompleteTaskResponse,
|
||||||
|
completeTaskResponse,
|
||||||
|
type GetIncentiveTasksResponse,
|
||||||
|
getIncentiveTasksResponse,
|
||||||
|
type IncentiveTaskTypeEnum,
|
||||||
|
} from "@/contracts/types/incentive-tasks.types";
|
||||||
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
||||||
|
class IncentiveTasksApiService {
|
||||||
|
/**
|
||||||
|
* Get all available incentive tasks with completion status
|
||||||
|
*/
|
||||||
|
getTasks = async (): Promise<GetIncentiveTasksResponse> => {
|
||||||
|
return baseApiService.get("/api/v1/incentive-tasks", getIncentiveTasksResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a task as completed and receive page reward
|
||||||
|
*/
|
||||||
|
completeTask = async (taskType: IncentiveTaskTypeEnum): Promise<CompleteTaskResponse> => {
|
||||||
|
return baseApiService.post(
|
||||||
|
`/api/v1/incentive-tasks/${taskType}/complete`,
|
||||||
|
completeTaskResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incentiveTasksApiService = new IncentiveTasksApiService();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue