mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
.
This commit is contained in:
47
packages/web/src/components/billing/BillingStatus.tsx
Normal file
47
packages/web/src/components/billing/BillingStatus.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
import { UsageDisplay } from "./UsageDisplay"
|
||||
import { UpgradeButton } from "./UpgradeButton"
|
||||
|
||||
export function BillingStatus() {
|
||||
const billing = useBilling()
|
||||
|
||||
if (!billing.loaded) {
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg">
|
||||
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white">
|
||||
{hasSubscription ? "Pro Plan" : "Free Plan"}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{hasSubscription ? "$7.99/month" : "Limited requests"}
|
||||
</p>
|
||||
</div>
|
||||
{!hasSubscription && <UpgradeButton />}
|
||||
</div>
|
||||
|
||||
{hasSubscription && (
|
||||
<>
|
||||
<UsageDisplay />
|
||||
{billing.billingPortalUrl && (
|
||||
<a
|
||||
href={billing.billingPortalUrl}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Manage subscription
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal file
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
import { UsageDisplayNew } from "./UsageDisplayNew"
|
||||
import { UpgradeButtonNew } from "./UpgradeButtonNew"
|
||||
import { UsageSubmissionForm } from "./UsageSubmissionForm"
|
||||
|
||||
export function BillingStatusNew() {
|
||||
console.log("BillingStatusNew")
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
console.log("Has currentSubscription:", "currentSubscription" in billing)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg">
|
||||
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
|
||||
<UpgradeButtonNew />
|
||||
<UsageSubmissionForm />
|
||||
<UsageDisplayNew />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal file
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const PRO_PLAN_PRICE_SLUG = "pro_monthly"
|
||||
|
||||
type UpgradeButtonProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function UpgradeButton({ className, children }: UpgradeButtonProps) {
|
||||
const billing = useBilling()
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!billing.createCheckoutSession) {
|
||||
console.error("[billing] createCheckoutSession not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await billing.createCheckoutSession({
|
||||
priceSlug: PRO_PLAN_PRICE_SLUG,
|
||||
successUrl: `${window.location.origin}/settings?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
|
||||
quantity: 1,
|
||||
autoRedirect: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSubscription =
|
||||
billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasSubscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={!billing.loaded}
|
||||
className={
|
||||
className ??
|
||||
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{children ?? "Upgrade to Pro"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal file
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const PRO_PLAN_PRICE_SLUG = "single_8_payment"
|
||||
|
||||
type UpgradeButtonProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function UpgradeButtonNew({ className, children }: UpgradeButtonProps) {
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!billing.createCheckoutSession) {
|
||||
console.error("[billing] createCheckoutSession not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await billing.createCheckoutSession({
|
||||
priceSlug: PRO_PLAN_PRICE_SLUG,
|
||||
successUrl: `${window.location.origin}/settings?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
|
||||
quantity: 1,
|
||||
autoRedirect: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={!billing.loaded}
|
||||
className={
|
||||
className ??
|
||||
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{children ?? "Buy 500 requests"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal file
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const AI_REQUESTS_METER = "ai_requests"
|
||||
|
||||
export function UsageDisplay() {
|
||||
const billing = useBilling()
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
const usage = billing.checkUsageBalance?.(AI_REQUESTS_METER)
|
||||
|
||||
if (!hasSubscription) {
|
||||
return (
|
||||
<div className="text-xs text-zinc-500">
|
||||
Free tier: 20 requests/day
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const remaining = usage?.availableBalance ?? 0
|
||||
const percentage = Math.min(100, (remaining / 1000) * 100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">
|
||||
{remaining} left
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal file
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const FREE_METER = "free_requests"
|
||||
const PAID_METER = "premium_requests"
|
||||
|
||||
export function UsageDisplayNew() {
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const freeUsage = billing.checkUsageBalance?.(FREE_METER)
|
||||
const paidUsage = billing.checkUsageBalance?.(PAID_METER)
|
||||
console.log(freeUsage)
|
||||
console.log(paidUsage)
|
||||
|
||||
const freeRemaining = freeUsage?.availableBalance ?? 0
|
||||
const freePercentage = Math.min(100, (freeRemaining / 1000) * 100)
|
||||
const remaining = paidUsage?.availableBalance ?? 0
|
||||
const percentage = Math.min(100, (remaining / 1000) * 100)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
Free requests
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${freePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">{freeRemaining} left</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
Paid requests
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">{remaining} left</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal file
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react"
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const FREE_METER = "free_requests"
|
||||
const PAID_METER = "premium_requests"
|
||||
|
||||
type MeterSlug = typeof FREE_METER | typeof PAID_METER
|
||||
|
||||
export function UsageSubmissionForm() {
|
||||
const billing = useBilling()
|
||||
const [freeAmount, setFreeAmount] = useState(1)
|
||||
const [paidAmount, setPaidAmount] = useState(1)
|
||||
const [freeError, setFreeError] = useState("")
|
||||
const [paidError, setPaidError] = useState("")
|
||||
const [freeSuccess, setFreeSuccess] = useState("")
|
||||
const [paidSuccess, setPaidSuccess] = useState("")
|
||||
const [freeSubmitting, setFreeSubmitting] = useState(false)
|
||||
const [paidSubmitting, setPaidSubmitting] = useState(false)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const freeBalance = billing.checkUsageBalance?.(FREE_METER)
|
||||
const paidBalance = billing.checkUsageBalance?.(PAID_METER)
|
||||
const freeRemaining = freeBalance?.availableBalance ?? 0
|
||||
const paidRemaining = paidBalance?.availableBalance ?? 0
|
||||
|
||||
const handleSubmit = async (meterSlug: MeterSlug, amount: number) => {
|
||||
const isFree = meterSlug === FREE_METER
|
||||
const setError = isFree ? setFreeError : setPaidError
|
||||
const setSuccess = isFree ? setFreeSuccess : setPaidSuccess
|
||||
const setSubmitting = isFree ? setFreeSubmitting : setPaidSubmitting
|
||||
const currentBalance = isFree ? freeRemaining : paidRemaining
|
||||
|
||||
// Clear previous messages
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
// Client-side validation
|
||||
if (amount <= 0) {
|
||||
setError("Amount must be greater than 0")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentBalance < amount) {
|
||||
setError(`Maximum usage exceeded. Your balance is ${currentBalance}.`)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/usage-events/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ meterSlug, amount }),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as {
|
||||
error?: string
|
||||
success?: boolean
|
||||
currentBalance?: number
|
||||
}
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
setError(data.error || "Failed to submit usage")
|
||||
return
|
||||
}
|
||||
|
||||
// Success!
|
||||
setSuccess(
|
||||
`Successfully recorded ${amount} ${meterSlug.replace("_", " ")}`,
|
||||
)
|
||||
|
||||
// Reset input to default
|
||||
if (isFree) {
|
||||
setFreeAmount(1)
|
||||
} else {
|
||||
setPaidAmount(1)
|
||||
}
|
||||
|
||||
// Reload billing data to update balances
|
||||
if (billing.reload) {
|
||||
await billing.reload()
|
||||
}
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
} catch (error) {
|
||||
console.error("[UsageSubmissionForm] Error:", error)
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Network error occurred",
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-white/10 pt-4">
|
||||
<h4 className="text-sm font-medium text-white">Submit Usage</h4>
|
||||
|
||||
{/* Free Requests Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-zinc-400">Free Requests</label>
|
||||
<span className="text-xs text-zinc-500">
|
||||
Balance: {freeRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={freeAmount}
|
||||
onChange={(e) => {
|
||||
setFreeAmount(parseInt(e.target.value) || 1)
|
||||
setFreeError("")
|
||||
}}
|
||||
disabled={freeSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit(FREE_METER, freeAmount)}
|
||||
disabled={freeSubmitting || !billing.loaded}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{freeSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
{freeError && <p className="text-xs text-red-400">{freeError}</p>}
|
||||
{freeSuccess && (
|
||||
<p className="text-xs text-emerald-400">{freeSuccess}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Premium Requests Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-zinc-400">Premium Requests</label>
|
||||
<span className="text-xs text-zinc-500">
|
||||
Balance: {paidRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={paidAmount}
|
||||
onChange={(e) => {
|
||||
setPaidAmount(parseInt(e.target.value) || 1)
|
||||
setPaidError("")
|
||||
}}
|
||||
disabled={paidSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit(PAID_METER, paidAmount)}
|
||||
disabled={paidSubmitting || !billing.loaded}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{paidSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
{paidError && <p className="text-xs text-red-400">{paidError}</p>}
|
||||
{paidSuccess && (
|
||||
<p className="text-xs text-emerald-400">{paidSuccess}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/components/billing/index.ts
Normal file
7
packages/web/src/components/billing/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { BillingStatus } from "./BillingStatus"
|
||||
export { BillingStatusNew } from "./BillingStatusNew"
|
||||
export { UpgradeButton } from "./UpgradeButton"
|
||||
export { UpgradeButtonNew } from "./UpgradeButtonNew"
|
||||
export { UsageDisplay } from "./UsageDisplay"
|
||||
export { UsageDisplayNew } from "./UsageDisplayNew"
|
||||
export { UsageSubmissionForm } from "./UsageSubmissionForm"
|
||||
Reference in New Issue
Block a user