mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-07 14:22:38 +02:00
add stripe billing
This commit is contained in:
parent
d5302ea2d1
commit
2fda9a7e79
58 changed files with 2348 additions and 485 deletions
148
apps/rowboat/app/billing/app.tsx
Normal file
148
apps/rowboat/app/billing/app.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use client';
|
||||
|
||||
import { Progress, Badge } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/app/lib/components/label";
|
||||
import { Customer, UsageResponse, UsageType } from "@/app/lib/types/billing_types";
|
||||
import { z } from "zod";
|
||||
import { tokens } from "@/app/styles/design-tokens";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { HorizontalDivider } from "@/components/ui/horizontal-divider";
|
||||
import { WithStringId } from "@/app/lib/types/types";
|
||||
import clsx from 'clsx';
|
||||
import { getCustomerPortalUrl } from "../actions/billing_actions";
|
||||
|
||||
const planDetails = {
|
||||
free: {
|
||||
name: "Free Plan",
|
||||
color: "default"
|
||||
},
|
||||
starter: {
|
||||
name: "Starter Plan",
|
||||
color: "primary"
|
||||
},
|
||||
pro: {
|
||||
name: "Pro Plan",
|
||||
color: "secondary"
|
||||
}
|
||||
};
|
||||
|
||||
interface BillingPageProps {
|
||||
customer: WithStringId<z.infer<typeof Customer>>;
|
||||
usage: z.infer<typeof UsageResponse>;
|
||||
}
|
||||
|
||||
export function BillingPage({ customer, usage }: BillingPageProps) {
|
||||
const plan = customer.subscriptionPlan || "free";
|
||||
const isActive = customer.subscriptionActive || false;
|
||||
const planInfo = planDetails[plan];
|
||||
|
||||
async function handleManageSubscription() {
|
||||
const returnUrl = new URL('/billing/callback', window.location.origin);
|
||||
returnUrl.searchParams.set('redirect', window.location.href);
|
||||
const url = await getCustomerPortalUrl(returnUrl.toString());
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-8 py-8 space-y-8">
|
||||
<div className="px-4">
|
||||
<h1 className={clsx(
|
||||
tokens.typography.sizes.xl,
|
||||
tokens.typography.weights.semibold,
|
||||
tokens.colors.light.text.primary,
|
||||
tokens.colors.dark.text.primary
|
||||
)}>
|
||||
Billing
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Subscription Status Panel */}
|
||||
<section className="card">
|
||||
<div className="px-4 pt-4 pb-6">
|
||||
<SectionHeading>
|
||||
Current Plan
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<HorizontalDivider />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={clsx(
|
||||
tokens.typography.sizes.lg,
|
||||
tokens.typography.weights.semibold,
|
||||
tokens.colors.light.text.primary,
|
||||
tokens.colors.dark.text.primary
|
||||
)}>
|
||||
{planInfo.name}
|
||||
</h3>
|
||||
<Badge
|
||||
color={isActive ? "success" : "warning"}
|
||||
variant="flat"
|
||||
className="text-xs"
|
||||
>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<form action={handleManageSubscription}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Usage Metrics Panel */}
|
||||
<section className="card">
|
||||
<div className="px-4 pt-4 pb-6">
|
||||
<SectionHeading>
|
||||
Usage Metrics
|
||||
</SectionHeading>
|
||||
</div>
|
||||
<HorizontalDivider />
|
||||
<div className="p-6 space-y-6">
|
||||
{Object.entries(usage.usage).map(([type, { usage: used, total }]) => {
|
||||
const usageType = type as z.infer<typeof UsageType>;
|
||||
const percentage = Math.min((used / total) * 100, 100);
|
||||
const isOverLimit = used > total;
|
||||
|
||||
return (
|
||||
<div key={type} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<Label label={type.replace(/_/g, ' ')} />
|
||||
<p className={clsx(
|
||||
tokens.typography.sizes.sm,
|
||||
tokens.colors.light.text.secondary,
|
||||
tokens.colors.dark.text.secondary
|
||||
)}>
|
||||
{used.toLocaleString()} / {total.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{isOverLimit && (
|
||||
<Badge color="danger" variant="flat">
|
||||
Over Limit
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
color={isOverLimit ? "danger" : "primary"}
|
||||
className="h-2"
|
||||
aria-label={`${type} usage`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/rowboat/app/billing/callback/page.tsx
Normal file
18
apps/rowboat/app/billing/callback/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { syncWithStripe } from "@/app/lib/billing";
|
||||
import { requireBillingCustomer } from '@/app/lib/billing';
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: {
|
||||
redirect: string;
|
||||
}
|
||||
}) {
|
||||
const customer = await requireBillingCustomer();
|
||||
await syncWithStripe(customer._id);
|
||||
const redirectUrl = searchParams.redirect as string;
|
||||
redirect(redirectUrl || '/projects');
|
||||
}
|
||||
13
apps/rowboat/app/billing/layout.tsx
Normal file
13
apps/rowboat/app/billing/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import AppLayout from '../projects/layout/components/app-layout';
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<AppLayout useRag={false} useAuth={true} useBilling={true}>
|
||||
{children}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
17
apps/rowboat/app/billing/page.tsx
Normal file
17
apps/rowboat/app/billing/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { requireBillingCustomer } from '../lib/billing';
|
||||
import { BillingPage } from './app';
|
||||
import { getUsage } from '../lib/billing';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { USE_BILLING } from '../lib/feature_flags';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Page() {
|
||||
if (!USE_BILLING) {
|
||||
redirect('/projects');
|
||||
}
|
||||
|
||||
const customer = await requireBillingCustomer();
|
||||
const usage = await getUsage(customer._id);
|
||||
return <BillingPage customer={customer} usage={usage} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue