From 17642493eb6be9a31455bd2afbad4cf980061b84 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 31 Mar 2026 14:45:46 -0700 Subject: [PATCH 1/2] chore: linting --- .../versions/113_add_prompt_library_schema.py | 4 +- .../new_chat/tools/dropbox/create_file.py | 15 ++-- .../new_chat/tools/dropbox/trash_file.py | 4 +- .../app/connectors/dropbox/client.py | 8 +- .../connectors/dropbox/content_extractor.py | 1 - surfsense_backend/app/routes/__init__.py | 2 +- .../app/routes/dropbox_add_connector_route.py | 4 +- .../connector_indexers/dropbox_indexer.py | 4 +- .../app/(home)/login/LocalLoginForm.tsx | 23 ++---- surfsense_web/app/(home)/page.tsx | 2 +- .../components/CommunityPromptsContent.tsx | 28 +++---- .../components/PromptsContent.tsx | 39 +++++----- .../assistant-ui/assistant-message.tsx | 12 ++- .../assistant-ui/connector-popup.tsx | 10 +-- .../components/mcp-connect-form.tsx | 2 +- .../components/composio-drive-config.tsx | 2 +- .../components/dropbox-config.tsx | 76 +++++++++++-------- .../components/mcp-config.tsx | 2 +- .../components/onedrive-config.tsx | 2 +- .../components/webcrawler-config.tsx | 2 +- .../views/indexing-configuration-view.tsx | 30 ++++---- .../hooks/use-connector-dialog.ts | 26 +++---- .../assistant-ui/thinking-steps.tsx | 2 +- .../components/assistant-ui/tool-fallback.tsx | 2 +- .../comment-thread/comment-thread.tsx | 2 +- .../components/editor-panel/editor-panel.tsx | 2 +- .../hitl-edit-panel/hitl-edit-panel.tsx | 2 +- surfsense_web/components/homepage/navbar.tsx | 2 +- .../layout/hooks/useSidebarState.ts | 2 +- .../layout/providers/LayoutDataProvider.tsx | 4 +- .../layout/ui/right-panel/RightPanel.tsx | 17 ++++- .../components/layout/ui/tabs/TabBar.tsx | 7 +- .../new-chat/source-detail-panel.tsx | 8 +- surfsense_web/components/onboarding-tour.tsx | 4 +- .../components/report-panel/report-panel.tsx | 4 +- .../settings/general-settings-manager.tsx | 4 +- .../components/settings/llm-role-manager.tsx | 6 +- .../settings/prompt-config-manager.tsx | 3 +- surfsense_web/components/tool-ui/audio.tsx | 8 +- .../tool-ui/dropbox/create-file.tsx | 8 +- .../components/tool-ui/dropbox/trash-file.tsx | 6 +- surfsense_web/components/tool-ui/index.ts | 2 +- .../hooks/use-search-source-connectors.ts | 27 ++++--- 43 files changed, 224 insertions(+), 196 deletions(-) diff --git a/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py b/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py index 191ffcbda..539bf3f69 100644 --- a/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py +++ b/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py @@ -7,6 +7,7 @@ Revises: 112 from collections.abc import Sequence import sqlalchemy as sa + from alembic import op revision: str = "113" @@ -25,8 +26,7 @@ def upgrade() -> None: " ON prompts (is_public) WHERE is_public = true" ) op.execute( - "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" - " default_prompt_slug VARCHAR(100)" + "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS default_prompt_slug VARCHAR(100)" ) op.execute( "CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug" diff --git a/surfsense_backend/app/agents/new_chat/tools/dropbox/create_file.py b/surfsense_backend/app/agents/new_chat/tools/dropbox/create_file.py index d85ab804e..ed8034861 100644 --- a/surfsense_backend/app/agents/new_chat/tools/dropbox/create_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/dropbox/create_file.py @@ -145,8 +145,7 @@ def create_create_dropbox_file_tool( "name": item["name"], } for item in items - if item.get(".tag") == "folder" - and item.get("name") + if item.get(".tag") == "folder" and item.get("name") ] except Exception: logger.warning( @@ -239,12 +238,12 @@ def create_create_dropbox_file_tool( client = DropboxClient(session=db_session, connector_id=connector.id) parent_path = final_parent_folder_path or "" - file_path = f"{parent_path}/{final_name}" if parent_path else f"/{final_name}" + file_path = ( + f"{parent_path}/{final_name}" if parent_path else f"/{final_name}" + ) if final_file_type == "paper": - created = await client.create_paper_doc( - file_path, final_content or "" - ) + created = await client.create_paper_doc(file_path, final_content or "") file_id = created.get("file_id", "") web_url = created.get("url", "") else: @@ -255,9 +254,7 @@ def create_create_dropbox_file_tool( file_id = created.get("id", "") web_url = "" - logger.info( - f"Dropbox file created: id={file_id}, name={final_name}" - ) + logger.info(f"Dropbox file created: id={file_id}, name={final_name}") kb_message_suffix = "" try: diff --git a/surfsense_backend/app/agents/new_chat/tools/dropbox/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/dropbox/trash_file.py index e10fa3972..e15dc3092 100644 --- a/surfsense_backend/app/agents/new_chat/tools/dropbox/trash_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/dropbox/trash_file.py @@ -248,9 +248,7 @@ def create_delete_dropbox_file_tool( f"Deleting Dropbox file: path='{final_file_path}', connector={actual_connector_id}" ) - client = DropboxClient( - session=db_session, connector_id=actual_connector_id - ) + client = DropboxClient(session=db_session, connector_id=actual_connector_id) await client.delete_file(final_file_path) logger.info(f"Dropbox file deleted: path={final_file_path}") diff --git a/surfsense_backend/app/connectors/dropbox/client.py b/surfsense_backend/app/connectors/dropbox/client.py index 530059143..dfae38f66 100644 --- a/surfsense_backend/app/connectors/dropbox/client.py +++ b/surfsense_backend/app/connectors/dropbox/client.py @@ -225,18 +225,14 @@ class DropboxClient: return all_items, None - async def get_metadata( - self, path: str - ) -> tuple[dict[str, Any] | None, str | None]: + async def get_metadata(self, path: str) -> tuple[dict[str, Any] | None, str | None]: resp = await self._request("/2/files/get_metadata", {"path": path}) if resp.status_code != 200: return None, f"Failed to get metadata: {resp.status_code} - {resp.text}" return resp.json(), None async def download_file(self, path: str) -> tuple[bytes | None, str | None]: - resp = await self._content_request( - "/2/files/download", {"path": path} - ) + resp = await self._content_request("/2/files/download", {"path": path}) if resp.status_code != 200: return None, f"Download failed: {resp.status_code}" return resp.content, None diff --git a/surfsense_backend/app/connectors/dropbox/content_extractor.py b/surfsense_backend/app/connectors/dropbox/content_extractor.py index 226a643c7..e89893b14 100644 --- a/surfsense_backend/app/connectors/dropbox/content_extractor.py +++ b/surfsense_backend/app/connectors/dropbox/content_extractor.py @@ -8,7 +8,6 @@ import contextlib import logging import os import tempfile -from pathlib import Path from typing import Any from .client import DropboxClient diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index d2cf9ff37..983af7597 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -9,8 +9,8 @@ from .clickup_add_connector_route import router as clickup_add_connector_router from .composio_routes import router as composio_router from .confluence_add_connector_route import router as confluence_add_connector_router from .discord_add_connector_route import router as discord_add_connector_router -from .dropbox_add_connector_route import router as dropbox_add_connector_router from .documents_routes import router as documents_router +from .dropbox_add_connector_route import router as dropbox_add_connector_router from .editor_routes import router as editor_router from .folders_routes import router as folders_router from .google_calendar_add_connector_route import ( diff --git a/surfsense_backend/app/routes/dropbox_add_connector_route.py b/surfsense_backend/app/routes/dropbox_add_connector_route.py index 8dcaf8c1c..941e5c00f 100644 --- a/surfsense_backend/app/routes/dropbox_add_connector_route.py +++ b/surfsense_backend/app/routes/dropbox_add_connector_route.py @@ -72,9 +72,7 @@ async def connect_dropbox(space_id: int, user: User = Depends(current_active_use if not space_id: raise HTTPException(status_code=400, detail="space_id is required") if not config.DROPBOX_APP_KEY: - raise HTTPException( - status_code=500, detail="Dropbox OAuth not configured." - ) + raise HTTPException(status_code=500, detail="Dropbox OAuth not configured.") if not config.SECRET_KEY: raise HTTPException( status_code=500, detail="SECRET_KEY not configured for OAuth security." diff --git a/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py b/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py index a16111c53..1b039add7 100644 --- a/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/dropbox_indexer.py @@ -466,7 +466,9 @@ async def index_dropbox_files( folders = items_dict.get("folders", []) for folder in folders: - folder_path = folder.get("path", folder.get("path_lower", folder.get("id", ""))) + folder_path = folder.get( + "path", folder.get("path_lower", folder.get("id", "")) + ) folder_name = folder.get("name", "Root") logger.info(f"Using full scan for folder {folder_name}") diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 3d675e56d..ee3b47683 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -150,10 +150,7 @@ export function LocalLoginForm() {
-
-
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index e4ba1426d..522d71e59 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -230,7 +230,9 @@ export function PromptsContent() { Cancel @@ -286,21 +288,21 @@ export function PromptsContent() { )}
- +
-
-
- -

- Only sync changes since last index (faster). Disable for a full re-index. -

+
+
+ +

+ Only sync changes since last index (faster). Disable for a full re-index. +

+
+ handleIndexingOptionChange("incremental_sync", checked)} + />
- handleIndexingOptionChange("incremental_sync", checked)} - /> -
-
-
- -

- Recursively index files in subfolders of selected folders -

+
+
+ +

+ Recursively index files in subfolders of selected folders +

+
+ handleIndexingOptionChange("include_subfolders", checked)} + />
- handleIndexingOptionChange("include_subfolders", checked)} - /> -
); diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx index 0db1981d0..ca997a9ba 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx @@ -248,7 +248,7 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setShowDetails(prev => !prev); + setShowDetails((prev) => !prev); }} > {showDetails ? ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx index 3a0190e99..b835dbcec 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx @@ -220,7 +220,7 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh
+
); diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index a2762a029..6543bbd72 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -780,12 +780,12 @@ export const useConnectorDialog = () => { }); } - // Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio) - if ( - (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" || - indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || - indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" || - indexingConfig.connectorType === "DROPBOX_CONNECTOR") && + // Handle Google Drive / OneDrive / Dropbox folder selection (regular and Composio) + if ( + (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" || + indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + indexingConfig.connectorType === "ONEDRIVE_CONNECTOR" || + indexingConfig.connectorType === "DROPBOX_CONNECTOR") && indexingConnectorConfig ) { const selectedFolders = indexingConnectorConfig.selected_folders as @@ -1049,13 +1049,13 @@ export const useConnectorDialog = () => { if (!editingConnector.is_indexable) { // Non-indexable connectors (like Tavily API) don't need re-indexing indexingDescription = "Settings saved."; - } else if ( - editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || - editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || - editingConnector.connector_type === "ONEDRIVE_CONNECTOR" || - editingConnector.connector_type === "DROPBOX_CONNECTOR" - ) { - // Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges + } else if ( + editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "ONEDRIVE_CONNECTOR" || + editingConnector.connector_type === "DROPBOX_CONNECTOR" + ) { + // Google Drive (both regular and Composio) / OneDrive / Dropbox uses folder selection from config, not date ranges const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as | Array<{ id: string; name: string }> | undefined; diff --git a/surfsense_web/components/assistant-ui/thinking-steps.tsx b/surfsense_web/components/assistant-ui/thinking-steps.tsx index b3462da5f..df1cef12c 100644 --- a/surfsense_web/components/assistant-ui/thinking-steps.tsx +++ b/surfsense_web/components/assistant-ui/thinking-steps.tsx @@ -69,7 +69,7 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
- + + + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx new file mode 100644 index 000000000..6f46602c6 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function PurchaseSuccessPage() { + const params = useParams(); + const queryClient = useQueryClient(); + const searchSpaceId = String(params.search_space_id ?? ""); + + useEffect(() => { + void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); + }, [queryClient]); + + return ( +
+ + + + Purchase complete + + Your additional pages are being applied to your account now. + + + +

+ Your sidebar usage meter should refresh automatically in a moment. +

+
+ + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx new file mode 100644 index 000000000..833f06201 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Receipt } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; +import { cn } from "@/lib/utils"; + +const STATUS_STYLES: Record = { + completed: { label: "Completed", className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600" }, + pending: { label: "Pending", className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600" }, + failed: { label: "Failed", className: "bg-destructive text-white border-transparent hover:bg-destructive" }, +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatAmount(purchase: PagePurchase): string { + if (purchase.amount_total == null) return "—"; + const dollars = purchase.amount_total / 100; + const currency = (purchase.currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${currency}`; +} + +export function PurchaseHistoryContent() { + const { data, isLoading } = useQuery({ + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const purchases = data?.purchases ?? []; + + if (purchases.length === 0) { + return ( +
+ +

No purchases yet

+

+ Your page-pack purchases will appear here after checkout. +

+
+ ); + } + + return ( +
+
+ + + + Date + Pages + Amount + Status + + + + {purchases.map((p) => { + const style = STATUS_STYLES[p.status]; + return ( + + + {formatDate(p.created_at)} + + + {p.pages_granted.toLocaleString()} + + + {formatAmount(p)} + + + + {style.label} + + + + ); + })} + +
+
+

+ Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. +

+
+ ); +} diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 4a32c2147..f727a2018 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useState } from "react"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { queryClient } from "@/lib/query-client/client"; interface DashboardLayoutProps { children: React.ReactNode; @@ -22,6 +24,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { redirectToLogin(); return; } + queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] }); setIsCheckingAuth(false); }, []); diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts index b7289568b..8e196c9c7 100644 --- a/surfsense_web/atoms/user/user-query.atoms.ts +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -1,16 +1,15 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { userApiService } from "@/lib/apis/user-api.service"; -import { getBearerToken, isPublicRoute } from "@/lib/auth-utils"; +import { getBearerToken } from "@/lib/auth-utils"; export const USER_QUERY_KEY = ["user", "me"] as const; const userQueryFn = () => userApiService.getMe(); export const currentUserAtom = atomWithQuery(() => { - const pathname = typeof window !== "undefined" ? window.location.pathname : null; return { queryKey: USER_QUERY_KEY, staleTime: 5 * 60 * 1000, - enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname), + enabled: !!getBearerToken(), queryFn: userQueryFn, }; }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 76c0389b9..4eff8d546 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -15,7 +15,6 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { - morePagesDialogAtom, searchSpaceSettingsDialogAtom, teamDialogAtom, userSettingsDialogAtom, @@ -27,7 +26,6 @@ import { type Tab, } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { TeamDialog } from "@/components/settings/team-dialog"; import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; @@ -203,8 +201,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const seenPageLimitNotifications = useRef>(new Set()); const isInitialLoad = useRef(true); - const setMorePagesOpen = useSetAtom(morePagesDialogAtom); - // Effect to show toast for new page_limit_exceeded notifications useEffect(() => { if (statusInbox.loading) return; @@ -233,12 +229,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid duration: 8000, icon: , action: { - label: "View Plans", - onClick: () => setMorePagesOpen(true), + label: "Get More Pages", + onClick: () => router.push(`/dashboard/${searchSpaceId}/more-pages`), }, }); } - }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]); + }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]); // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); @@ -923,7 +919,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid - ); } diff --git a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx index 14e75e176..32532254d 100644 --- a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx +++ b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx @@ -1,10 +1,12 @@ "use client"; -import { useSetAtom } from "jotai"; -import { Zap } from "lucide-react"; -import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; +import { useQuery } from "@tanstack/react-query"; +import { CreditCard, Zap } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; interface PageUsageDisplayProps { pagesUsed: number; @@ -12,12 +14,18 @@ interface PageUsageDisplayProps { } export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { - const setMorePagesOpen = useSetAtom(morePagesDialogAtom); + const params = useParams(); + const searchSpaceId = params?.search_space_id ?? ""; const usagePercentage = (pagesUsed / pagesLimit) * 100; + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true; return (
-
+
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages @@ -25,19 +33,32 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp {usagePercentage.toFixed(0)}%
- + + {pageBuyingEnabled && ( + + + + Buy Pages + + + $1/1k + + + )}
); diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index ce7b06da6..af1c9bd59 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -8,11 +8,12 @@ const demoPlans = [ price: "0", yearlyPrice: "0", period: "", - billingText: "", + billingText: "1,000 pages included", features: [ "Self Hostable", - "Upload and chat with 300+ pages of content", - "Includes access to ChatGPT text and audio models", + "1,000 pages included to start", + "Earn up to 6,000+ bonus pages for free", + "Includes access to OpenAI text, audio and image models", "Realtime Collaborative Group Chats with teammates", "Community support on Discord", ], @@ -22,21 +23,20 @@ const demoPlans = [ isPopular: false, }, { - name: "PRO", - price: "0", - yearlyPrice: "0", - period: "", - billingText: "Free during beta", + name: "PAY AS YOU GO", + price: "1", + yearlyPrice: "1", + period: "1,000 pages", + billingText: "No subscription, buy only when you need more", features: [ "Everything in Free", - "Includes 6000+ pages of content", - "Access to more models and providers", + "Buy 1,000-page packs at $1 each", "Priority support on Discord", ], description: "", buttonText: "Get Started", href: "/login", - isPopular: true, + isPopular: false, }, { name: "ENTERPRISE", @@ -45,7 +45,7 @@ const demoPlans = [ period: "", billingText: "", features: [ - "Everything in Pro", + "Everything in Pay As You Go", "On-prem or VPC deployment", "Audit logs and compliance", "SSO, OIDC & SAML", @@ -63,7 +63,11 @@ const demoPlans = [ function PricingBasic() { return ( - + ); } diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index f4df921f3..c97eac072 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -14,7 +14,7 @@ import { schema } from "@/zero/schema"; const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848"; -function ZeroAuthGuard({ children }: { children: React.ReactNode }) { +function ZeroAuthSync() { const zero = useZero(); const connectionState = useConnectionState(); const isRefreshingRef = useRef(false); @@ -37,7 +37,7 @@ function ZeroAuthGuard({ children }: { children: React.ReactNode }) { }); }, [connectionState, zero]); - return <>{children}; + return null; } export function ZeroProvider({ children }: { children: React.ReactNode }) { @@ -59,7 +59,8 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) { return ( - {hasUser ? {children} : children} + {hasUser && } + {children} ); } diff --git a/surfsense_web/components/settings/buy-pages-content.tsx b/surfsense_web/components/settings/buy-pages-content.tsx new file mode 100644 index 000000000..6aeb44ea2 --- /dev/null +++ b/surfsense_web/components/settings/buy-pages-content.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Minus, Plus } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; +import { AppError } from "@/lib/error"; +import { cn } from "@/lib/utils"; + +const PAGE_PACK_SIZE = 1000; +const PRICE_PER_PACK_USD = 1; +const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const; + +export function BuyPagesContent() { + const params = useParams(); + const [quantity, setQuantity] = useState(1); + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + + const purchaseMutation = useMutation({ + mutationFn: stripeApiService.createCheckoutSession, + onSuccess: (response) => { + window.location.assign(response.checkout_url); + }, + onError: (error) => { + if (error instanceof AppError && error.message) { + toast.error(error.message); + return; + } + toast.error("Failed to start checkout. Please try again."); + }, + }); + + const searchSpaceId = Number(params.search_space_id); + const hasValidSearchSpace = Number.isFinite(searchSpaceId) && searchSpaceId > 0; + const totalPages = quantity * PAGE_PACK_SIZE; + const totalPrice = quantity * PRICE_PER_PACK_USD; + + if (stripeStatus && !stripeStatus.page_buying_enabled) { + return ( +
+

Buy Pages

+

+ Page purchases are temporarily unavailable. +

+
+ ); + } + + const handleBuyNow = () => { + if (!hasValidSearchSpace) { + toast.error("Unable to determine the current workspace for checkout."); + return; + } + purchaseMutation.mutate({ + quantity, + search_space_id: searchSpaceId, + }); + }; + + return ( +
+
+

Buy Pages

+

+ $1 per 1,000 pages, pay as you go +

+
+ +
+ {/* Stepper */} +
+ + + {totalPages.toLocaleString()} + + +
+ + {/* Quick-pick presets */} +
+ {PRESET_MULTIPLIERS.map((m) => ( + + ))} +
+ +
+ {totalPages.toLocaleString()} pages + ${totalPrice} +
+ + +

+ Secure checkout via Stripe +

+
+
+ ); +} diff --git a/surfsense_web/components/settings/more-pages-content.tsx b/surfsense_web/components/settings/more-pages-content.tsx index bf0c9924f..b076b4fdc 100644 --- a/surfsense_web/components/settings/more-pages-content.tsx +++ b/surfsense_web/components/settings/more-pages-content.tsx @@ -1,20 +1,16 @@ "use client"; -import { IconCalendar, IconMailFilled } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react"; +import { Check, ExternalLink, Mail } from "lucide-react"; import Link from "next/link"; -import { useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { Button } from "@/components/ui/button"; import { Card, CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, } from "@/components/ui/card"; import { Dialog, @@ -22,15 +18,14 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { - trackIncentiveContactOpened, trackIncentivePageViewed, trackIncentiveTaskClicked, trackIncentiveTaskCompleted, @@ -38,7 +33,10 @@ import { import { cn } from "@/lib/utils"; export function MorePagesContent() { + const params = useParams(); const queryClient = useQueryClient(); + const searchSpaceId = params?.search_space_id ?? ""; + const [claimOpen, setClaimOpen] = useState(false); useEffect(() => { trackIncentivePageViewed(); @@ -48,6 +46,11 @@ export function MorePagesContent() { queryKey: ["incentive-tasks"], queryFn: () => incentiveTasksApiService.getTasks(), }); + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true; const completeMutation = useMutation({ mutationFn: incentiveTasksApiService.completeTask, @@ -59,7 +62,7 @@ export function MorePagesContent() { trackIncentiveTaskCompleted(taskType, task.pages_reward); } queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); - queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); } }, onError: () => { @@ -75,132 +78,138 @@ export function MorePagesContent() { }; return ( -
+
- -

Get More Pages

+

Get Free Pages

- Complete tasks to earn additional pages + Claim your free page offer and earn bonus pages

- {isLoading ? ( - - - -
- - -
- -
-
- ) : ( -
- {data?.tasks.map((task) => ( - - -
- {task.completed ? : } -
-
+ {/* 6k free offer */} + + +
+ 6k +
+
+

Claim 6,000 Free Pages

+

+ Limited offer. Schedule a meeting or email us to claim. +

+
+ +
+
+ + + + {/* Free tasks */} +
+

Earn Bonus Pages

+ {isLoading ? ( + + + +
+ +
+ +
+
+ ) : ( +
+ {data?.tasks.map((task) => ( + + +
+ {task.completed ? : +{task.pages_reward}} +

{task.title}

-

+{task.pages_reward} pages

-
- - - - ))} -
- )} + + + + ))} +
+ )} +
- - -
- - Upgrade to PRO - - FREE - -
- - For a limited time, get{" "} - 6,000 additional pages at no - cost. Contact us and we'll upgrade your account instantly. - -
- - open && trackIncentiveContactOpened()}> - - - - - - Get in Touch - Pick the option that works best for you. - -
- - -
-
-
-
-
+ {/* Link to buy pages */} +
+

Need more?

+ {pageBuyingEnabled ? ( + + ) : ( +

+ Page purchases are temporarily unavailable. +

+ )} +
+ + {/* Claim 6k dialog */} + + + + Claim 6,000 Free Pages + + Send us an email to claim your free 6,000 pages. Include your account email and primary usecase for free pages. + + + + +
); } diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx index 3a66c54de..389ebc5fd 100644 --- a/surfsense_web/components/settings/user-settings-dialog.tsx +++ b/surfsense_web/components/settings/user-settings-dialog.tsx @@ -1,12 +1,13 @@ "use client"; import { useAtom } from "jotai"; -import { Globe, KeyRound, Sparkles, User } from "lucide-react"; +import { Globe, KeyRound, Receipt, Sparkles, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent"; import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"; import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent"; import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent"; +import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { SettingsDialog } from "@/components/settings/settings-dialog"; @@ -31,6 +32,11 @@ export function UserSettingsDialog() { label: "Community Prompts", icon: , }, + { + value: "purchases", + label: "Purchase History", + icon: , + }, ]; return ( @@ -47,6 +53,7 @@ export function UserSettingsDialog() { {state.initialTab === "api-key" && } {state.initialTab === "prompts" && } {state.initialTab === "community-prompts" && } + {state.initialTab === "purchases" && }
); diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts new file mode 100644 index 000000000..c7a6bf387 --- /dev/null +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const pagePurchaseStatusEnum = z.enum(["pending", "completed", "failed"]); + +export const createCheckoutSessionRequest = z.object({ + quantity: z.number().int().min(1).max(100), + search_space_id: z.number().int().min(1), +}); + +export const createCheckoutSessionResponse = z.object({ + checkout_url: z.string(), +}); + +export const stripeStatusResponse = z.object({ + page_buying_enabled: z.boolean(), +}); + +export const pagePurchase = z.object({ + id: z.uuid(), + stripe_checkout_session_id: z.string(), + stripe_payment_intent_id: z.string().nullable(), + quantity: z.number(), + pages_granted: z.number(), + amount_total: z.number().nullable(), + currency: z.string().nullable(), + status: pagePurchaseStatusEnum, + completed_at: z.string().nullable(), + created_at: z.string(), +}); + +export const getPagePurchasesResponse = z.object({ + purchases: z.array(pagePurchase), +}); + +export type PagePurchaseStatus = z.infer; +export type CreateCheckoutSessionRequest = z.infer; +export type CreateCheckoutSessionResponse = z.infer; +export type StripeStatusResponse = z.infer; +export type PagePurchase = z.infer; +export type GetPagePurchasesResponse = z.infer; diff --git a/surfsense_web/lib/apis/stripe-api.service.ts b/surfsense_web/lib/apis/stripe-api.service.ts new file mode 100644 index 000000000..f0d927ebf --- /dev/null +++ b/surfsense_web/lib/apis/stripe-api.service.ts @@ -0,0 +1,30 @@ +import { + type CreateCheckoutSessionRequest, + type CreateCheckoutSessionResponse, + createCheckoutSessionResponse, + type GetPagePurchasesResponse, + getPagePurchasesResponse, + type StripeStatusResponse, + stripeStatusResponse, +} from "@/contracts/types/stripe.types"; +import { baseApiService } from "./base-api.service"; + +class StripeApiService { + createCheckoutSession = async ( + request: CreateCheckoutSessionRequest + ): Promise => { + return baseApiService.post("/api/v1/stripe/create-checkout-session", createCheckoutSessionResponse, { + body: request, + }); + }; + + getPurchases = async (): Promise => { + return baseApiService.get("/api/v1/stripe/purchases", getPagePurchasesResponse); + }; + + getStatus = async (): Promise => { + return baseApiService.get("/api/v1/stripe/status", stripeStatusResponse); + }; +} + +export const stripeApiService = new StripeApiService(); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index e36aff10a..baa371e05 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -45,3 +45,4 @@ export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted"; // Helper to check if running in cloud mode export const isCloud = () => DEPLOYMENT_MODE === "cloud"; +