mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +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"
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Granular permissions for search space resources.
|
||||
|
|
@ -915,6 +952,39 @@ class Notification(BaseModel, TimestampMixin):
|
|||
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):
|
||||
"""
|
||||
Custom roles that can be defined per search space.
|
||||
|
|
@ -1093,6 +1163,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
@ -1144,6 +1221,13 @@ else:
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
|
|||
from .google_gmail_add_connector_route import (
|
||||
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 .linear_add_connector_route import router as linear_add_connector_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(notifications_router) # Notifications with Electric SQL sync
|
||||
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_metadata>")
|
||||
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" <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("")
|
||||
context_parts.append("<document_content>")
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import { useTranslations } from "next-intl";
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
|
|
@ -18,7 +19,6 @@ import {
|
|||
trackRegistrationSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const t = useTranslations("auth");
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import React from "react";
|
|||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
|
|||
|
|
@ -1,48 +1,50 @@
|
|||
"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 Link from "next/link";
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
const GITHUB_REPO_URL = "https://github.com/MODSetter/SurfSense";
|
||||
export default function MorePagesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const INITIAL_TASKS = [
|
||||
{
|
||||
id: "star",
|
||||
title: "Star the repository",
|
||||
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;
|
||||
// Fetch tasks from API
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||
});
|
||||
|
||||
export default function FreePagesPage() {
|
||||
const [completedIds, setCompletedIds] = useState<Set<string>>(new Set());
|
||||
// Mutation to complete a task
|
||||
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) => {
|
||||
setCompletedIds((prev) => new Set(prev).add(taskId));
|
||||
}, []);
|
||||
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="mb-6 text-center">
|
||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<h2 className="text-xl font-bold tracking-tight">Get Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Complete tasks to get free additional pages
|
||||
</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-2">
|
||||
{INITIAL_TASKS.map((task) => {
|
||||
const isCompleted = completedIds.has(task.id);
|
||||
const Icon = task.icon;
|
||||
return (
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<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
|
||||
key={task.id}
|
||||
className={cn("transition-colors", isCompleted && "bg-muted/50")}
|
||||
key={task.task_type}
|
||||
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"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 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}
|
||||
</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>
|
||||
<Button
|
||||
variant={isCompleted ? "ghost" : "outline"}
|
||||
variant={task.completed ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
asChild
|
||||
onClick={() => handleTaskClick(task.id)}
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
>
|
||||
<a
|
||||
href={task.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("gap-1", isCompleted && "pointer-events-none opacity-50")}
|
||||
>
|
||||
{isCompleted ? "Done" : "Go"}
|
||||
{!isCompleted && <ExternalLink className="h-3 w-3" />}
|
||||
</a>
|
||||
{task.completed ? (
|
||||
<span>Done</span>
|
||||
) : (
|
||||
<a
|
||||
href={task.action_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Go
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
<Separator className="my-6" />
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<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" />
|
||||
Contact Us
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import {
|
|||
Bot,
|
||||
Brain,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -105,7 +106,6 @@ import {
|
|||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
CreateInviteRequest,
|
||||
DeleteInviteRequest,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -21,6 +20,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentUploadDialog } from "./document-upload-popup";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSessionStatusProps {
|
||||
isAiResponding: boolean;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
connector: SearchSourceConnector;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { type FC, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function ContactFormGridWithDetails() {
|
|||
<IconCalendar className="h-5 w-5" />
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
|
||||
|
||||
<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="text-sm">or</span>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export const Navbar = () => {
|
|||
}, []);
|
||||
|
||||
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} />
|
||||
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import {
|
||||
deleteThread,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
|
||||
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Info, RotateCcw, Save } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
|||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -21,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
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