mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
feat(refactor): refactor payment system to implement unified credit wallet.
- Updated environment variables and - configurations for credit purchases via Stripe, replacing legacy page pack system. - Introduced auto-reload feature for credit top-ups and modified database models to track credit transactions. - Updated notification system to handle insufficient credits and auto-reload failures. - Adjusted API routes and schemas to reflect changes in credit management.
This commit is contained in:
parent
4fe216856d
commit
a7407502d3
88 changed files with 3229 additions and 2261 deletions
|
|
@ -12,6 +12,7 @@ export type {
|
|||
export {
|
||||
ChatListItem,
|
||||
CreateSearchSpaceDialog,
|
||||
CreditBalanceDisplay,
|
||||
Header,
|
||||
IconRail,
|
||||
LayoutShell,
|
||||
|
|
@ -19,7 +20,6 @@ export {
|
|||
MobileSidebarTrigger,
|
||||
NavIcon,
|
||||
NavSection,
|
||||
PageUsageDisplay,
|
||||
SearchSpaceAvatar,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
|
|
|
|||
|
|
@ -186,40 +186,40 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setStatusInboxItems(statusInbox.inboxItems);
|
||||
}, [statusInbox.inboxItems, setStatusInboxItems]);
|
||||
|
||||
// Track seen notification IDs to detect new page_limit_exceeded notifications
|
||||
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
|
||||
// Track seen notification IDs to detect new insufficient_credits notifications
|
||||
const seenCreditNotifications = useRef<Set<number>>(new Set());
|
||||
const isInitialLoad = useRef(true);
|
||||
|
||||
// Effect to show toast for new page_limit_exceeded notifications
|
||||
// Effect to show toast for new insufficient_credits notifications
|
||||
useEffect(() => {
|
||||
if (statusInbox.loading) return;
|
||||
|
||||
const pageLimitNotifications = statusInbox.inboxItems.filter(
|
||||
(item) => item.type === "page_limit_exceeded"
|
||||
const creditNotifications = statusInbox.inboxItems.filter(
|
||||
(item) => item.type === "insufficient_credits"
|
||||
);
|
||||
|
||||
if (isInitialLoad.current) {
|
||||
for (const notification of pageLimitNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
for (const notification of creditNotifications) {
|
||||
seenCreditNotifications.current.add(notification.id);
|
||||
}
|
||||
isInitialLoad.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newNotifications = pageLimitNotifications.filter(
|
||||
(notification) => !seenPageLimitNotifications.current.has(notification.id)
|
||||
const newNotifications = creditNotifications.filter(
|
||||
(notification) => !seenCreditNotifications.current.has(notification.id)
|
||||
);
|
||||
|
||||
for (const notification of newNotifications) {
|
||||
seenPageLimitNotifications.current.add(notification.id);
|
||||
seenCreditNotifications.current.add(notification.id);
|
||||
|
||||
toast.error(notification.title, {
|
||||
description: notification.message,
|
||||
duration: 8000,
|
||||
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||
action: {
|
||||
label: "Get More Pages",
|
||||
onClick: () => router.push(`/dashboard/${searchSpaceId}/more-pages`),
|
||||
label: "Buy credits",
|
||||
onClick: () => router.push(`/dashboard/${searchSpaceId}/buy-more`),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -696,6 +696,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||
const useWorkspacePanel =
|
||||
pathname?.endsWith("/buy-more") === true ||
|
||||
pathname?.endsWith("/earn-credits") === true ||
|
||||
pathname?.endsWith("/more-pages") === true ||
|
||||
isUserSettingsPage ||
|
||||
isSearchSpaceSettingsPage ||
|
||||
|
|
|
|||
|
|
@ -74,11 +74,6 @@ export interface ChatsSectionProps {
|
|||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
}
|
||||
|
||||
export interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
searchSpaceId?: string;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
|||
export { LayoutShell } from "./shell";
|
||||
export {
|
||||
ChatListItem,
|
||||
CreditBalanceDisplay,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
NavSection,
|
||||
PageUsageDisplay,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { queries } from "@/zero/queries";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
|
||||
export function AuthenticatedPageUsageDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
||||
if (isAnonymous || !me) return null;
|
||||
|
||||
return <PageUsageDisplay pagesUsed={me.pagesUsed} pagesLimit={me.pagesLimit} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
// Show the low-balance warning state once the wallet drops below $0.50.
|
||||
const LOW_BALANCE_WARNING_MICROS = 500_000;
|
||||
|
||||
function formatUsd(micros: number): string {
|
||||
// Clamp at $0.00 — the balance can dip slightly negative when the actual
|
||||
// cost of a job exceeds the pre-charge estimate.
|
||||
const dollars = Math.max(0, micros) / 1_000_000;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
// Sub-dollar balances need extra precision so the user can still tell what
|
||||
// is left ("$0.042 of credit") instead of rounding to "$0.00".
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0.00";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified credit-wallet balance shown in the sidebar.
|
||||
*
|
||||
* The single ``creditMicrosBalance`` replaces the former page-limit and
|
||||
* premium-credit meters. Values come from Zero (live-replicated from Postgres)
|
||||
* as integer micro-USD (1_000_000 == $1.00). A low-balance warning highlights
|
||||
* the amount when it falls below $0.50 so the user knows to top up or enable
|
||||
* auto-reload.
|
||||
*/
|
||||
export function CreditBalanceDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
||||
if (isAnonymous || !me) return null;
|
||||
|
||||
const balanceMicros = me.creditMicrosBalance ?? 0;
|
||||
const isLow = balanceMicros < LOW_BALANCE_WARNING_MICROS;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Credits</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
isLow ? "text-amber-600 dark:text-amber-500" : "text-foreground"
|
||||
)}
|
||||
title={isLow ? "Low balance — buy credits or enable auto-reload" : undefined}
|
||||
>
|
||||
{formatUsd(balanceMicros)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ import {
|
|||
isConnectorIndexingMetadata,
|
||||
isDocumentProcessingMetadata,
|
||||
isNewMentionMetadata,
|
||||
isPageLimitExceededMetadata,
|
||||
isInsufficientCreditsMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
|
|
@ -291,7 +291,7 @@ export function InboxSidebarContent({
|
|||
(item: InboxItem): boolean => {
|
||||
if (activeFilter === "unread") return !item.read;
|
||||
if (activeFilter === "errors") {
|
||||
if (item.type === "page_limit_exceeded") return true;
|
||||
if (item.type === "insufficient_credits") return true;
|
||||
const meta = item.metadata as Record<string, unknown> | undefined;
|
||||
return typeof meta?.status === "string" && meta.status === "failed";
|
||||
}
|
||||
|
|
@ -397,8 +397,8 @@ export function InboxSidebarContent({
|
|||
router.push(url);
|
||||
}
|
||||
}
|
||||
} else if (item.type === "page_limit_exceeded") {
|
||||
if (isPageLimitExceededMetadata(item.metadata)) {
|
||||
} else if (item.type === "insufficient_credits") {
|
||||
if (isInsufficientCreditsMetadata(item.metadata)) {
|
||||
const actionUrl = item.metadata.action_url;
|
||||
if (actionUrl) {
|
||||
onOpenChange(false);
|
||||
|
|
@ -470,7 +470,7 @@ export function InboxSidebarContent({
|
|||
);
|
||||
}
|
||||
|
||||
if (item.type === "page_limit_exceeded") {
|
||||
if (item.type === "insufficient_credits") {
|
||||
return (
|
||||
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
}
|
||||
|
||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@rocicorp/zero/react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
/**
|
||||
* Premium credit balance shown in the sidebar.
|
||||
*
|
||||
* Values come from Zero (live-replicated from Postgres) and are stored as
|
||||
* integer micro-USD (1_000_000 == $1.00). We render in dollars because
|
||||
* users top up at $1/pack and the credit gets debited at actual provider
|
||||
* cost.
|
||||
*/
|
||||
export function PremiumTokenUsageDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
||||
if (isAnonymous || !me) return null;
|
||||
|
||||
const usagePercentage = Math.min(
|
||||
(me.premiumCreditMicrosUsed / Math.max(me.premiumCreditMicrosLimit, 1)) * 100,
|
||||
100
|
||||
);
|
||||
|
||||
const formatUsd = (micros: number) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
// Sub-dollar balances need extra precision so the bar still tells the
|
||||
// user what's left ("$0.04 of credit") instead of rounding to "$0".
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatUsd(me.premiumCreditMicrosUsed)} / {formatUsd(me.premiumCreditMicrosLimit)} of
|
||||
credit
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5 [&>div]:bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { CreditCard, Dot, SquarePen, Zap } from "lucide-react";
|
||||
import { CreditCard, SquarePen, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -13,10 +13,9 @@ import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
|
||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||
import { AuthenticatedPageUsageDisplay } from "./AuthenticatedPageUsageDisplay";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
|
||||
import { SidebarButton } from "./SidebarButton";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
|
|
@ -404,17 +403,16 @@ function SidebarUsageFooter({
|
|||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<PremiumTokenUsageDisplay />
|
||||
<AuthenticatedPageUsageDisplay />
|
||||
<CreditBalanceDisplay />
|
||||
<div className="space-y-0.5">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/more-pages`}
|
||||
href={`/dashboard/${searchSpaceId}/earn-credits`}
|
||||
onClick={onNavigate}
|
||||
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<Zap className="h-3 w-3 shrink-0" />
|
||||
Get Free Pages
|
||||
Earn credits
|
||||
</span>
|
||||
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
|
||||
FREE
|
||||
|
|
@ -427,12 +425,7 @@ function SidebarUsageFooter({
|
|||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
Buy More
|
||||
</span>
|
||||
<span className="flex items-center text-[10px] font-medium text-muted-foreground">
|
||||
$1/1k
|
||||
<Dot className="h-3 w-3" />
|
||||
$1/1M
|
||||
Buy credits
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ export { ChatListItem } from "./ChatListItem";
|
|||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
export { SidebarHeader } from "./SidebarHeader";
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ const demoPlans = [
|
|||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "500 pages + $5 in premium credits included",
|
||||
billingText: "$5 of credit included to start",
|
||||
features: [
|
||||
"Self Hostable",
|
||||
"500 pages included to start",
|
||||
"$5 in premium credits for paid AI models and premium AI features",
|
||||
"$5 of credit included to start",
|
||||
"One credit balance for document processing and premium AI features",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"AI automations and agents: scheduled and event-triggered workflows",
|
||||
"Desktop app: Quick, General and Screenshot Assist plus local folder sync",
|
||||
|
|
@ -38,7 +38,7 @@ const demoPlans = [
|
|||
billingText: "No subscription, buy only when you need more",
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"Buy 1,000-page packs or $1 in premium credits at $1 each",
|
||||
"Buy credit in $1 packs — $1 buys $1 of credit, with optional auto-reload",
|
||||
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
|
||||
"Connector write-back to Notion, Slack, Linear & Jira",
|
||||
"Priority support on Discord",
|
||||
|
|
@ -84,32 +84,32 @@ interface FAQSection {
|
|||
|
||||
const faqData: FAQSection[] = [
|
||||
{
|
||||
title: "Pages & Document Billing",
|
||||
title: "Credits & Document Billing",
|
||||
items: [
|
||||
{
|
||||
question: 'What exactly is a "page" in SurfSense?',
|
||||
question: "What are credits in SurfSense?",
|
||||
answer:
|
||||
"A page is a simple billing unit that measures how much content you add to your knowledge base. For PDFs, one page equals one real PDF page. For other document types like Word, PowerPoint, and Excel files, pages are automatically estimated based on the file. Every file uses at least 1 page.",
|
||||
"Credits are a single prepaid balance shown in dollars that powers everything in SurfSense — both document processing and premium AI features. New accounts start with $5 of credit. Your balance goes down as you use the product and back up when you top up or earn more, so there's just one number to keep an eye on.",
|
||||
},
|
||||
{
|
||||
question: "What are Basic and Premium processing modes?",
|
||||
question: "How much does document processing cost?",
|
||||
answer:
|
||||
"When uploading documents, you can choose between two processing modes. Basic mode uses standard extraction and costs 1 page credit per page, great for most documents. Premium processing mode uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables, layouts, and formatting. It costs 10 page credits per page and does not use your premium AI credits.",
|
||||
"Document processing is billed per page out of your credit balance. For PDFs, one page equals one real PDF page; for other document types like Word, PowerPoint, and Excel files, pages are automatically estimated. Basic mode costs $0.001 per page and Premium mode costs $0.01 per page. Premium processing uses advanced extraction optimized for complex financial, medical, and legal documents with intricate tables and layouts. Every file uses at least 1 page.",
|
||||
},
|
||||
{
|
||||
question: "How does the Pay As You Go plan work?",
|
||||
answer:
|
||||
"There's no monthly subscription. When you need more pages, simply purchase 1,000-page packs at $1 each. Purchased pages are added to your account immediately so you can keep indexing right away. You only pay when you actually need more.",
|
||||
"There's no monthly subscription. When you need more credit, simply buy $1 packs — $1 buys exactly $1 of credit. Purchased credit is added to your balance immediately so you can keep working right away. You only pay when you actually need more, and you can enable auto-reload to top up automatically.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of pages?",
|
||||
question: "What happens if I run out of credit?",
|
||||
answer:
|
||||
"SurfSense checks your remaining pages before processing each file. If you don't have enough, the upload is paused and you'll be notified. You can purchase additional page packs at any time to continue. For cloud connector syncs, a small overage may be allowed so your sync doesn't partially fail.",
|
||||
"SurfSense checks your remaining credit before processing each file. If you don't have enough, the upload is paused and you'll be notified so you can buy more credit and continue. For cloud connector syncs, a small overage may be allowed so your sync doesn't partially fail.",
|
||||
},
|
||||
{
|
||||
question: "If I delete a document, do I get my pages back?",
|
||||
question: "If I delete a document, do I get my credit back?",
|
||||
answer:
|
||||
"No. Deleting a document removes it from your knowledge base, but the pages it used are not refunded. Pages track your total usage over time, not how much is currently stored. So be mindful of what you index. Once pages are spent, they're spent even if you later remove the document.",
|
||||
"No. Deleting a document removes it from your knowledge base, but the credit it used is not refunded. Credit tracks your total usage over time, not how much is currently stored, so be mindful of what you index. Once credit is spent, it's spent even if you later remove the document.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -117,49 +117,49 @@ const faqData: FAQSection[] = [
|
|||
title: "File Types & Connectors",
|
||||
items: [
|
||||
{
|
||||
question: "Which file types count toward my page limit?",
|
||||
question: "Which file types use credit?",
|
||||
answer:
|
||||
"Page limits only apply to document files that need processing, including PDFs, Word documents (DOC, DOCX, ODT, RTF), presentations (PPT, PPTX, ODP), spreadsheets (XLS, XLSX, ODS), ebooks (EPUB), and images (JPG, PNG, TIFF, WebP, BMP). Plain text files, code files, Markdown, CSV, TSV, HTML, audio, and video files do not consume any pages.",
|
||||
"Credit is only used for document files that need processing, including PDFs, Word documents (DOC, DOCX, ODT, RTF), presentations (PPT, PPTX, ODP), spreadsheets (XLS, XLSX, ODS), ebooks (EPUB), and images (JPG, PNG, TIFF, WebP, BMP). Plain text files, code files, Markdown, CSV, TSV, HTML, audio, and video files do not consume any credit.",
|
||||
},
|
||||
{
|
||||
question: "How are pages consumed?",
|
||||
question: "How is credit consumed for documents?",
|
||||
answer:
|
||||
"Pages are deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode, each page costs 1 page credit; in Premium mode, each page costs 10 page credits. SurfSense checks your remaining credits before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra pages.",
|
||||
"Credit is deducted whenever a document file is successfully indexed into your knowledge base, whether through direct uploads or file-based connector syncs (Google Drive, OneDrive, Dropbox, Local Folder). In Basic mode each page costs $0.001; in Premium mode each page costs $0.01. SurfSense checks your remaining credit before processing and only charges you after the file is indexed. Duplicate documents are automatically detected and won't cost you extra.",
|
||||
},
|
||||
{
|
||||
question: "Do connectors like Slack, Notion, or Gmail use pages?",
|
||||
question: "Do connectors like Slack, Notion, or Gmail use credit?",
|
||||
answer:
|
||||
"No. Connectors that work with structured text data like Slack, Discord, Notion, Confluence, Jira, Linear, ClickUp, GitHub, Gmail, Google Calendar, Microsoft Teams, Airtable, Elasticsearch, Web Crawler, BookStack, Obsidian, and Luma do not use pages at all. Page limits only apply to file-based connectors that need document processing, such as Google Drive, OneDrive, Dropbox, and Local Folder syncs.",
|
||||
"No. Connectors that work with structured text data like Slack, Discord, Notion, Confluence, Jira, Linear, ClickUp, GitHub, Gmail, Google Calendar, Microsoft Teams, Airtable, Elasticsearch, Web Crawler, BookStack, Obsidian, and Luma do not use credit at all. Document-processing charges only apply to file-based connectors such as Google Drive, OneDrive, Dropbox, and Local Folder syncs.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Premium Credits",
|
||||
title: "Premium AI & Credit",
|
||||
items: [
|
||||
{
|
||||
question: 'What are "premium credits"?',
|
||||
question: "How is credit used for premium AI?",
|
||||
answer:
|
||||
"Premium credits are your USD balance for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.",
|
||||
"The same credit balance pays for paid AI usage in SurfSense, including premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro, plus premium AI features such as image generation, podcasts, and video presentations when they use paid models. Each request debits the actual USD provider cost, so cheaper and more expensive models bill proportionally.",
|
||||
},
|
||||
{
|
||||
question: "How many premium credits do I get for free?",
|
||||
question: "How much credit do I get for free?",
|
||||
answer:
|
||||
"Every registered SurfSense account starts with $5 in premium credits at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included premium credits run out, you can top up at any time.",
|
||||
"Every registered SurfSense account starts with $5 of credit at no cost. Anonymous users (no login) get 500,000 free tokens across free models before creating an account. Once your included credit runs out, you can top up at any time or earn more by completing tasks.",
|
||||
},
|
||||
{
|
||||
question: "How does buying premium credits work?",
|
||||
question: "How does buying credit work?",
|
||||
answer:
|
||||
"Premium credit top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately. You can buy up to $100 at a time.",
|
||||
"Top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately, and you can buy up to $100 at a time. Enable auto-reload to top up automatically when your balance runs low.",
|
||||
},
|
||||
{
|
||||
question: "Are premium credits the same as page credits?",
|
||||
question: "Is there a separate balance for documents and AI?",
|
||||
answer:
|
||||
"No. Page credits pay for document indexing and file-based connector processing. Premium credits pay for paid AI usage, such as premium model chats and premium AI generation features. Premium document processing mode sounds similar, but it consumes page credits, not premium credits.",
|
||||
"No. SurfSense uses one unified credit balance for everything — document indexing, file-based connector processing, premium model chats, and premium AI generation features all draw from the same wallet. Premium document processing mode simply costs more per page ($0.01 vs $0.001), but it's the same credit.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium credits?",
|
||||
question: "What happens if I run out of credit?",
|
||||
answer:
|
||||
"When your premium credit balance runs low, you'll see a warning. Once you run out, paid model requests and premium AI features are paused until you top up. You can still use non-premium models and features that do not consume premium credits.",
|
||||
"When your credit balance runs low, you'll see a warning. Once you run out, paid model requests, premium AI features, and document processing are paused until you top up. You can still use non-premium models and features that do not consume credit.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -174,7 +174,7 @@ const faqData: FAQSection[] = [
|
|||
{
|
||||
question: "Do automations and agents cost extra?",
|
||||
answer:
|
||||
"No. There is no separate subscription or add-on fee for automations. Agents use the same page credits and premium credits as the rest of SurfSense. Indexing documents consumes page credits, and premium AI model usage during a workflow consumes premium credits at provider cost. If a workflow only uses free models, it does not touch your premium credits.",
|
||||
"No. There is no separate subscription or add-on fee for automations. Agents draw from the same unified credit balance as the rest of SurfSense. Indexing documents and premium AI model usage during a workflow both consume credit at provider cost. If a workflow only uses free models and indexes no documents, it does not touch your credit.",
|
||||
},
|
||||
{
|
||||
question: "How do event-triggered automations work?",
|
||||
|
|
@ -192,9 +192,9 @@ const faqData: FAQSection[] = [
|
|||
title: "Self-Hosting",
|
||||
items: [
|
||||
{
|
||||
question: "Can I self-host SurfSense with unlimited pages and credit?",
|
||||
question: "Can I self-host SurfSense with unlimited usage?",
|
||||
answer:
|
||||
"Yes! When self-hosting, you have full control over your page and premium credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credits, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
"Yes! When self-hosting, you have full control over billing. The default self-hosted setup leaves document-processing credit billing off and gives you effectively unlimited credit, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -286,7 +286,7 @@ function PricingFAQ() {
|
|||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything you need to know about SurfSense pages, premium credits, and billing.
|
||||
Everything you need to know about SurfSense credits and billing.
|
||||
Can't find what you need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
|
|
@ -372,7 +372,7 @@ function PricingBasic() {
|
|||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 500 pages & $5 in premium credits. Run AI automations and agents, and pay as you go."
|
||||
description="Start free with $5 of credit. Run AI automations and agents, and pay as you go."
|
||||
/>
|
||||
<PricingFAQ />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -7,46 +7,45 @@ import { useParams } from "next/navigation";
|
|||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
// One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the
|
||||
// backend. Premium turns are debited at the actual provider cost
|
||||
// reported by LiteLLM, so $1 of credit always buys $1 of provider
|
||||
// usage at cost.
|
||||
// One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the backend.
|
||||
// ETL page processing and premium turns are both debited from the same wallet
|
||||
// at the actual cost, so $1 of credit always buys $1 of usage at cost.
|
||||
const CREDIT_PER_PACK_MICROS = 1_000_000;
|
||||
const PRICE_PER_PACK_USD = 1;
|
||||
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
|
||||
|
||||
const formatUsd = (micros: number, options?: { compact?: boolean }) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (options?.compact && dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
const formatUsd = (micros: number) => {
|
||||
// Clamp at $0.00 — the balance can dip slightly negative when actual cost
|
||||
// exceeds the pre-charge estimate.
|
||||
const dollars = Math.max(0, micros) / 1_000_000;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
return "$0.00";
|
||||
};
|
||||
|
||||
export function BuyTokensContent() {
|
||||
export function BuyCreditsContent() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params?.search_space_id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
|
||||
// Server config flag: stays on REST, not per-user.
|
||||
const { data: tokenStatus } = useQuery({
|
||||
queryKey: ["token-status"],
|
||||
queryFn: () => stripeApiService.getTokenStatus(),
|
||||
const { data: creditStatus } = useQuery({
|
||||
queryKey: ["credit-status"],
|
||||
queryFn: () => stripeApiService.getCreditStatus(),
|
||||
});
|
||||
|
||||
// Live per-user balance via Zero.
|
||||
const [me] = useZeroQuery(queries.user.me({}));
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
mutationFn: stripeApiService.createTokenCheckoutSession,
|
||||
mutationFn: stripeApiService.createCreditCheckoutSession,
|
||||
onSuccess: (response) => {
|
||||
window.location.assign(response.checkout_url);
|
||||
},
|
||||
|
|
@ -62,10 +61,10 @@ export function BuyTokensContent() {
|
|||
const totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS;
|
||||
const totalPrice = quantity * PRICE_PER_PACK_USD;
|
||||
|
||||
if (tokenStatus && !tokenStatus.token_buying_enabled) {
|
||||
if (creditStatus && !creditStatus.credit_buying_enabled) {
|
||||
return (
|
||||
<div className="w-full space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Credits</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Credit purchases are temporarily unavailable.
|
||||
</p>
|
||||
|
|
@ -73,35 +72,23 @@ export function BuyTokensContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const used = me?.premiumCreditMicrosUsed ?? 0;
|
||||
const limit = me?.premiumCreditMicrosLimit ?? 0;
|
||||
// Mirrors the backend formula in stripe_routes.py (max(0, limit - used)).
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const usagePercentage = me ? Math.min((used / Math.max(limit, 1)) * 100, 100) : 0;
|
||||
const balanceMicros = me?.creditMicrosBalance ?? creditStatus?.credit_micros_balance ?? 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Credits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
$1 buys $1 of credit, billed at provider cost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{me && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatUsd(used)} / {formatUsd(limit)} of credit
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5 [&>div]:bg-purple-500" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{formatUsd(remaining)} of credit remaining
|
||||
</p>
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Current balance</span>
|
||||
<span className="font-semibold tabular-nums">{formatUsd(balanceMicros)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
"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 (
|
||||
<div className="w-full space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">Page purchases are temporarily unavailable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleBuyNow = () => {
|
||||
if (!hasValidSearchSpace) {
|
||||
toast.error("Unable to determine the current workspace for checkout.");
|
||||
return;
|
||||
}
|
||||
purchaseMutation.mutate({
|
||||
quantity,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">$1 per 1,000 pages, pay as you go</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
disabled={quantity <= 1 || purchaseMutation.isPending}
|
||||
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-28 text-center text-lg font-semibold tabular-nums">
|
||||
{totalPages.toLocaleString()}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setQuantity((q) => Math.min(100, q + 1))}
|
||||
disabled={quantity >= 100 || purchaseMutation.isPending}
|
||||
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick-pick presets */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESET_MULTIPLIERS.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setQuantity(m)}
|
||||
disabled={purchaseMutation.isPending}
|
||||
className={cn(
|
||||
"h-auto rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",
|
||||
quantity === m
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{(m * PAGE_PACK_SIZE).toLocaleString()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{totalPages.toLocaleString()} pages
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={purchaseMutation.isPending || !hasValidSearchSpace}
|
||||
onClick={handleBuyNow}
|
||||
>
|
||||
{purchaseMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Buy {totalPages.toLocaleString()} Pages for ${totalPrice}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-[11px] text-muted-foreground">Secure checkout via Stripe</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,14 @@ import {
|
|||
} from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MorePagesContent() {
|
||||
// Compact dollar label for a task's reward (e.g. "+$0.03").
|
||||
const formatRewardUsd = (micros: number) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 1) return `+$${dollars.toFixed(2)}`;
|
||||
return `+$${dollars.toFixed(2)}`;
|
||||
};
|
||||
|
||||
export function EarnCreditsContent() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
|
|
@ -35,11 +42,11 @@ export function MorePagesContent() {
|
|||
queryKey: ["incentive-tasks"],
|
||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||
});
|
||||
const { data: stripeStatus } = useQuery({
|
||||
queryKey: ["stripe-status"],
|
||||
queryFn: () => stripeApiService.getStatus(),
|
||||
const { data: creditStatus } = useQuery({
|
||||
queryKey: ["credit-status"],
|
||||
queryFn: () => stripeApiService.getCreditStatus(),
|
||||
});
|
||||
const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true;
|
||||
const creditBuyingEnabled = creditStatus?.credit_buying_enabled ?? true;
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
|
|
@ -48,7 +55,7 @@ export function MorePagesContent() {
|
|||
toast.success(response.message);
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
|
|
@ -69,12 +76,14 @@ export function MorePagesContent() {
|
|||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Earn bonus pages by completing tasks</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Earn bonus credits by completing tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Earn Bonus Pages</h3>
|
||||
<h3 className="text-sm font-semibold">Earn Bonus Credits</h3>
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{["github", "reddit", "discord"].map((task) => (
|
||||
|
|
@ -97,14 +106,16 @@ export function MorePagesContent() {
|
|||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"flex h-9 min-w-9 shrink-0 items-center justify-center rounded-full px-2",
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold">+{task.pages_reward}</span>
|
||||
<span className="text-[11px] font-semibold tabular-nums">
|
||||
{formatRewardUsd(task.credit_micros_reward)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
|
|
@ -151,15 +162,13 @@ export function MorePagesContent() {
|
|||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Need more?</p>
|
||||
{pageBuyingEnabled ? (
|
||||
{creditBuyingEnabled ? (
|
||||
<Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-pages`}>
|
||||
Buy page packs at $1 per 1,000
|
||||
</Link>
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits at $1 per $1</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page purchases are temporarily unavailable.
|
||||
Credit purchases are temporarily unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue