add stripe (#121)

* add some stripe stuff

* more stripe stuff

* more stripe things

* more stripr stuff

* more stripe stuff

* more stripe stuff

* add more stuff

* add more stripe stuff

* more stuff

* fix types
This commit is contained in:
KM Koushik
2025-03-23 07:06:56 +11:00
committed by GitHub
parent 6cfe41cd86
commit 403ad8b93e
34 changed files with 1352 additions and 238 deletions

View File

@@ -8,6 +8,7 @@ import {
BookUser,
CircleUser,
Code,
Cog,
Globe,
Home,
LayoutDashboard,
@@ -18,6 +19,7 @@ import {
Package,
Package2,
Server,
Settings,
ShoppingCart,
Users,
Volume2,
@@ -34,6 +36,7 @@ import {
DropdownMenuTrigger,
} from "@unsend/ui/src/dropdown-menu";
import { ThemeSwitcher } from "~/components/theme/ThemeSwitcher";
import { isCloud } from "~/utils/common";
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
@@ -87,7 +90,15 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
<Code className="h-4 w-4" />
Developer settings
</NavButton>
{!env.NEXT_PUBLIC_IS_CLOUD || session?.user.isAdmin ? (
{isCloud() ? (
<NavButton href="/settings">
<Cog className="h-4 w-4" />
Settings
</NavButton>
) : null}
{isCloud() || session?.user.isAdmin ? (
<NavButton href="/admin">
<Server className="h-4 w-4" />
Admin

View File

@@ -1,7 +1,5 @@
"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@unsend/ui/src/tabs";
import { useState } from "react";
import { SettingsNavButton } from "./settings-nav-button";
export const dynamic = "force-static";
@@ -15,9 +13,7 @@ export default function ApiKeysPage({
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/dev-settings/api-keys">
API Keys
</SettingsNavButton>
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>

View File

@@ -11,7 +11,7 @@ export const SettingsNavButton: React.FC<{
}> = ({ href, children, comingSoon }) => {
const pathname = usePathname();
const isActive = pathname?.startsWith(href);
const isActive = pathname === href;
if (comingSoon) {
return (

View File

@@ -1,25 +1,15 @@
"use client";
import * as React from "react";
import { Code } from "@unsend/ui/src/code";
import { Button } from "@unsend/ui/src/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@unsend/ui/src/card";
import { TextWithCopyButton } from "@unsend/ui/src/text-with-copy";
export default function ExampleCard() {
const smtpDetails = {
smtp: "smtp.example.com",
port: "587",
user: "user@example.com",
password: "supersecretpassword",
};
return (
<Card className="mt-9 max-w-xl">
<CardHeader>

View File

@@ -0,0 +1,59 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import Spinner from "@unsend/ui/src/spinner";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "~/trpc/react";
export default function PaymentsPage() {
const searchParams = useSearchParams();
const success = searchParams.get("success");
const canceled = searchParams.get("canceled");
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-semibold mb-8">
Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}
</h1>
{canceled ? (
<Link href="/settings/billing">
<Button>Go to billing</Button>
</Link>
) : null}
{success ? <VerifySuccess /> : null}
</div>
);
}
function VerifySuccess() {
const { data: teams, isLoading } = api.team.getTeams.useQuery(undefined, {
refetchInterval: 3000,
});
if (teams?.[0]?.plan !== "FREE") {
return (
<div>
<div className="flex gap-2 items-center">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<p>Your account has been upgraded to the paid plan.</p>
</div>
<Link href="/settings/billing" className="mt-8">
<Button className="mt-8">Go to billing</Button>
</Link>
</div>
);
}
return (
<div className="flex gap-2 items-center">
<Spinner
className="h-5 w-5 stroke-muted-foreground"
innerSvgClass=" stroke-muted-foreground"
/>
<p className="text-muted-foreground">Verifying payment</p>
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { Button } from "@unsend/ui/src/button";
import { Card } from "@unsend/ui/src/card";
import { Spinner } from "@unsend/ui/src/spinner";
import { format } from "date-fns";
import { useTeam } from "~/providers/team-context";
import { api } from "~/trpc/react";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
export default function SettingsPage() {
const { currentTeam } = useTeam();
const manageSessionUrl = api.billing.getManageSessionUrl.useMutation();
const updateBillingEmailMutation =
api.billing.updateBillingEmail.useMutation();
const { data: subscription } = api.billing.getSubscriptionDetails.useQuery();
const [isEditingEmail, setIsEditingEmail] = useState(false);
const [billingEmail, setBillingEmail] = useState(
currentTeam?.billingEmail || ""
);
const apiUtils = api.useUtils();
const onManageClick = async () => {
const url = await manageSessionUrl.mutateAsync();
if (url) {
window.location.href = url;
}
};
const handleEditEmail = () => {
setBillingEmail(currentTeam?.billingEmail || "");
setIsEditingEmail(true);
};
const handleSaveEmail = async () => {
try {
await updateBillingEmailMutation.mutateAsync({ billingEmail });
await apiUtils.team.getTeams.invalidate();
setIsEditingEmail(false);
} catch (error) {
console.error("Failed to update billing email:", error);
}
};
const paymentMethod = JSON.parse(subscription?.paymentMethod || "{}");
if (!currentTeam?.plan) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-4 h-4" />
</div>
);
}
return (
<div className="space-y-8">
<Card className=" rounded-xl mt-10 p-8 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan !== "FREE" ? (
<Button
onClick={onManageClick}
className="mt-4 w-[120px]"
disabled={manageSessionUrl.isPending}
>
{manageSessionUrl.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Manage"
)}
</Button>
) : (
<UpgradeButton />
)}
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Payment Method</div>
{subscription ? (
<div className="mt-2">
<div className="text-lg font-mono uppercase flex items-center gap-2">
{subscription.paymentMethod ? (
<>
<span>💳</span>
<span className="capitalize">
{paymentMethod.card?.brand || ""} {" "}
{paymentMethod.card?.last4 || ""}
</span>
{paymentMethod.card && (
<span className="text-sm text-muted-foreground lowercase">
(Expires: {paymentMethod.card.exp_month}/
{paymentMethod.card.exp_year})
</span>
)}
</>
) : (
"No Payment Method"
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
Next billing date:{" "}
{subscription.currentPeriodEnd
? format(
new Date(subscription.currentPeriodEnd),
"MMM dd, yyyy"
)
: "N/A"}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground mt-2">
No active subscription
</div>
)}
</div>
</Card>
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Billing Email</div>
{isEditingEmail ? (
<div className="mt-2">
<div className="flex items-center gap-2">
<input
type="email"
value={billingEmail}
onChange={(e) => setBillingEmail(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter billing email"
/>
<Button
onClick={handleSaveEmail}
disabled={updateBillingEmailMutation.isPending}
size="sm"
>
{updateBillingEmailMutation.isPending ? (
<Spinner className="w-4 h-4" />
) : (
"Save"
)}
</Button>
<Button
onClick={() => setIsEditingEmail(false)}
variant="outline"
size="sm"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="font-mono">
{currentTeam?.billingEmail || "No billing email set"}
</div>
<Button onClick={handleEditEmail} variant="ghost" size="sm">
Edit
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
export const dynamic = "force-static";
export default function ApiKeysPage({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
<SettingsNavButton href="/settings/billing">Billing</SettingsNavButton>
</div>
<div className="mt-8">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { Button } from "@unsend/ui/src/button";
import { api } from "~/trpc/react";
import UsagePage from "./usage/usage";
export default function SettingsPage() {
return (
<div>
<UsagePage />
</div>
);
}

View File

@@ -0,0 +1,237 @@
"use client";
import { api } from "~/trpc/react";
import { Card } from "@unsend/ui/src/card";
import Spinner from "@unsend/ui/src/spinner";
import { format } from "date-fns";
import {
getCost,
PLAN_CREDIT_UNITS,
UNIT_PRICE,
USAGE_UNIT_PRICE,
} from "~/lib/usage";
import { useTeam } from "~/providers/team-context";
import { EmailUsageType } from "@prisma/client";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
const FREE_PLAN_LIMIT = 3000;
function FreePlanUsage({
usage,
dayUsage,
}: {
usage: { type: EmailUsageType; sent: number }[];
dayUsage: { type: EmailUsageType; sent: number }[];
}) {
const DAILY_LIMIT = 100;
const totalSent = usage?.reduce((acc, item) => acc + item.sent, 0) || 0;
const monthlyPercentageUsed = (totalSent / FREE_PLAN_LIMIT) * 100;
// Calculate daily usage - this is a simplified version, you might want to adjust based on actual daily tracking
const dailyUsage = dayUsage?.reduce((acc, item) => acc + item.sent, 0) || 0;
const dailyPercentageUsed = (dailyUsage / DAILY_LIMIT) * 100;
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{item.type === "TRANSACTIONAL"
? "Mails sent using the send api or SMTP"
: "Mails designed sent from unsend editor"}
</div>
</div>
<div className="font-mono font-medium">
{item.sent.toLocaleString()} emails
</div>
</div>
))}
<div className="flex justify-between items-center pt-3 ">
<div className="font-medium">Total</div>
<div className="font-mono font-medium">
{usage
?.reduce((acc, item) => acc + item.sent, 0)
.toLocaleString()}{" "}
emails
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="w-[300px] space-y-8">
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Monthly Limit</div>
<div className="font-mono font-medium">
{totalSent.toLocaleString()}/
{FREE_PLAN_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
}}
/>
</div>
</div>
</div>
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="">Daily Limit</div>
<div className="font-mono">
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
function PaidPlanUsage({
usage,
}: {
usage: { type: EmailUsageType; sent: number }[];
}) {
const { currentTeam } = useTeam();
if (currentTeam?.plan === "FREE") return null;
const totalCost =
usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0;
const planCreditCost = PLAN_CREDIT_UNITS[currentTeam?.plan!] * UNIT_PRICE;
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-mono">
{item.sent.toLocaleString()}
</span>{" "}
emails at{" "}
<span className="font-mono">
${USAGE_UNIT_PRICE[item.type]}
</span>{" "}
each
</div>
</div>
<div className="font-mono font-medium">
${getCost(item.sent, item.type).toFixed(2)}
</div>
</div>
))}
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
<div>
<div className="font-medium capitalize">Minimum spend</div>
<div className="text-sm text-muted-foreground mt-1">
{currentTeam?.plan}
</div>
</div>
<div className="font-mono font-medium">
${planCreditCost.toFixed(2)}
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div>
<div className="font-medium">Amount Due</div>
<div className="">
<div className="text-2xl font-mono">
{planCreditCost < totalCost
? `$${totalCost.toFixed(2)}`
: `$${planCreditCost.toFixed(2)}`}
</div>
</div>
</div>
</div>
</div>
</Card>
);
}
export default function UsagePage() {
const { data: usage, isLoading } = api.billing.getThisMonthUsage.useQuery();
const { currentTeam } = useTeam();
// Calculate the current billing period
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const firstDayOfNextMonth = new Date(
today.getFullYear(),
today.getMonth() + 1,
1
);
const billingPeriod = `${format(firstDayOfMonth, "MMM dd")} - ${format(firstDayOfNextMonth, "MMM dd")}`;
return (
<div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Usage</h1>
<div className="text-sm text-muted-foreground mt-1">
<span className="font-medium">{billingPeriod}</span>
</div>
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
</div>
) : usage?.month.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No usage data available
</Card>
) : currentTeam?.plan === "FREE" ? (
<FreePlanUsage
usage={usage?.month ?? []}
dayUsage={usage?.day ?? []}
/>
) : (
<PaidPlanUsage usage={usage?.month ?? []} />
)}
</div>
{currentTeam?.plan ? (
<Card className=" rounded-xl mt-10 p-4 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
</div>
</Card>
) : null}
</div>
);
}