"use client"; import { useQuery as useZeroQuery } from "@rocicorp/zero/react"; 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"; import { queries } from "@/zero/queries"; // 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, 100] as const; const MIN_QUANTITY = 1; const MAX_QUANTITY = 10_000; const clampQuantity = (value: number) => Math.min(MAX_QUANTITY, Math.max(MIN_QUANTITY, Math.floor(value))); 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.00"; }; export function BuyCreditsContent() { const params = useParams(); const searchSpaceId = Number(params?.search_space_id); const [quantity, setQuantity] = useState(1); // Raw text of the amount field so the user can clear it while typing; // committed back to a clamped integer on blur. const [amountInput, setAmountInput] = useState("1"); const commitQuantity = (value: number) => { const clamped = clampQuantity(Number.isFinite(value) ? value : MIN_QUANTITY); setQuantity(clamped); setAmountInput(String(clamped)); }; // Server config flag: stays on REST, not per-user. 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.createCreditCheckoutSession, 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 totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS; const totalPrice = quantity * PRICE_PER_PACK_USD; if (creditStatus && !creditStatus.credit_buying_enabled) { return (

Buy Credits

Credit purchases are temporarily unavailable.

); } const balanceMicros = me?.creditMicrosBalance ?? creditStatus?.credit_micros_balance ?? 0; return (

Buy Credits

Current balance {formatUsd(balanceMicros)}
$ { const raw = e.target.value.replace(/[^0-9]/g, ""); setAmountInput(raw); const parsed = Number.parseInt(raw, 10); if (Number.isFinite(parsed)) { setQuantity(clampQuantity(parsed)); } }} onBlur={() => commitQuantity(Number.parseInt(amountInput, 10))} disabled={purchaseMutation.isPending} aria-label="Credit amount in US dollars" className="w-20 rounded-md border bg-transparent px-2 py-1 text-center text-lg font-semibold tabular-nums outline-none focus:ring-2 focus:ring-ring disabled:opacity-60" /> of credit
{PRESET_MULTIPLIERS.map((m) => ( ))}
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit ${totalPrice}

Secure checkout via Stripe

); }