feat: added incentive credits system

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-26 23:32:30 -08:00
parent d45b33e776
commit 39d65d6166
27 changed files with 587 additions and 84 deletions

View file

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

View file

@ -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,

View file

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

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

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

View file

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

View file

@ -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");

View file

@ -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");

View file

@ -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,

View file

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

View file

@ -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";

View file

@ -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,

View file

@ -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";

View file

@ -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";

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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";
/**

View file

@ -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";

View file

@ -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";

View file

@ -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,

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

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