Skip to content

Kirimase Init 5. Stripe #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: kirimase@0.57.0_init_bun_shadcnui_drizzle_Postgres_Postgre.JS_Lucia_None
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME}
DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME}
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID=
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID=
Binary file modified bun.lockb
Binary file not shown.
13 changes: 7 additions & 6 deletions kirimase.config.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
{
"hasSrc": true,
"packages": [
"shadcn-ui",
"drizzle",
"lucia"
"lucia",
"shadcn-ui",
"stripe"
],
"preferredPackageManager": "bun",
"t3": false,
"alias": "@",
"analytics": true,
"rootPath": "src/",
"componentLib": "shadcn-ui",
"driver": "pg",
"provider": "postgresjs",
"orm": "drizzle",
"auth": "lucia"
"auth": "lucia",
"componentLib": "shadcn-ui",
"provider": "node-postgres",
"driver": "pg"
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -12,7 +12,8 @@
"db:drop": "drizzle-kit drop",
"db:pull": "drizzle-kit introspect:pg",
"db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check:pg"
"db:check": "drizzle-kit check:pg",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
},
"dependencies": {
"@lucia-auth/adapter-drizzle": "^1.0.4",
@@ -22,6 +23,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"@stripe/stripe-js": "^3.0.10",
"@t3-oss/env-nextjs": "^0.9.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
@@ -37,6 +39,7 @@
"react": "^18",
"react-dom": "^18",
"sonner": "^1.4.3",
"stripe": "^14.21.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4"
73 changes: 73 additions & 0 deletions src/app/(app)/account/PlanSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use client";
import {
AccountCard,
AccountCardBody,
AccountCardFooter,
} from "./AccountCard";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { AuthSession } from "@/lib/auth/utils";

interface PlanSettingsProps {
stripeSubscriptionId: string | null;
stripeCurrentPeriodEnd: Date | null;
stripeCustomerId: string | null;
isSubscribed: boolean | "" | null;
isCanceled: boolean;
id?: string | undefined;
name?: string | undefined;
description?: string | undefined;
stripePriceId?: string | undefined;
price?: number | undefined;
}
export default function PlanSettings({
subscriptionPlan,
session,
}: {
subscriptionPlan: PlanSettingsProps;
session: AuthSession["session"];
}) {
return (
<AccountCard
params={{
header: "Your Plan",
description: subscriptionPlan.isSubscribed
? `You are currently on the ${subscriptionPlan.name} plan.`
: `You are not subscribed to any plan.`.concat(
!session?.user?.email || session?.user?.email.length < 5
? " Please add your email to upgrade your account."
: ""
),
}}
>
<AccountCardBody>
{subscriptionPlan.isSubscribed ? (
<h3 className="font-semibold text-lg">
${subscriptionPlan.price ? subscriptionPlan.price / 100 : 0} / month
</h3>
) : null}
{subscriptionPlan.stripeCurrentPeriodEnd ? (
<p className="text-sm mb-4 text-muted-foreground ">
Your plan will{" "}
{!subscriptionPlan.isSubscribed
? null
: subscriptionPlan.isCanceled
? "cancel"
: "renew"}
{" on "}
<span className="font-semibold">
{subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString(
"en-us"
)}
</span>
</p>
) : null}
</AccountCardBody>
<AccountCardFooter description="Manage your subscription on Stripe.">
<Link href="/account/billing">
<Button variant="outline">Go to billing</Button>
</Link>
</AccountCardFooter>
</AccountCard>
);
}
67 changes: 67 additions & 0 deletions src/app/(app)/account/billing/ManageSubscription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { Button } from "@/components/ui/button";
import React from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";

interface ManageUserSubscriptionButtonProps {
userId: string;
email: string;
isCurrentPlan: boolean;
isSubscribed: boolean;
stripeCustomerId?: string | null;
stripePriceId: string;
}

export function ManageUserSubscriptionButton({
userId,
email,
isCurrentPlan,
isSubscribed,
stripeCustomerId,
stripePriceId,
}: ManageUserSubscriptionButtonProps) {
const [isPending, startTransition] = React.useTransition();

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

startTransition(async () => {
try {
const res = await fetch("/api/billing/manage-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
userId,
isSubscribed,
isCurrentPlan,
stripeCustomerId,
stripePriceId,
}),
});
const session: { url: string } = await res.json();
if (session) {
window.location.href = session.url ?? "/dashboard/billing";
}
} catch (err) {
console.error((err as Error).message);
toast.error("Something went wrong, please try again later.");
}
});
};

return (
<form onSubmit={handleSubmit} className="w-full">
<Button
disabled={isPending}
className="w-full"
variant={isCurrentPlan ? "default" : "outline"}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isCurrentPlan ? "Manage Subscription" : "Subscribe"}
</Button>
</form>
);
}
18 changes: 18 additions & 0 deletions src/app/(app)/account/billing/SuccessToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client";

import { toast } from "sonner";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";

export default function SuccessToast() {
const searchParams = useSearchParams();

const success = searchParams.get("success") as Boolean | null;
useEffect(() => {
if (success) {
toast.success("Successfully updated subscription.");
}
}, [success]);

return null;
}
112 changes: 112 additions & 0 deletions src/app/(app)/account/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import SuccessToast from "./SuccessToast";
import { ManageUserSubscriptionButton } from "./ManageSubscription";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { storeSubscriptionPlans } from "@/config/subscriptions";
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";
import { CheckCircle2Icon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";

export default async function Billing() {
await checkAuth();
const { session } = await getUserAuth();
const subscriptionPlan = await getUserSubscriptionPlan();

if (!session) return redirect("/");

return (
<div className="min-h-[calc(100vh-57px)] ">
<SuccessToast />
<Link href="/account">
<Button variant={"link"} className="px-0">
Back
</Button>
</Link>
<h1 className="text-3xl font-semibold mb-4">Billing</h1>
<Card className="p-6 mb-2">
<h3 className="uppercase text-xs font-bold text-muted-foreground">
Subscription Details
</h3>
<p className="text-lg font-semibold leading-none my-2">
{subscriptionPlan.name}
</p>
<p className="text-sm text-muted-foreground">
{!subscriptionPlan.isSubscribed
? "You are not subscribed to any plan."
: subscriptionPlan.isCanceled
? "Your plan will be canceled on "
: "Your plan renews on "}
{subscriptionPlan?.stripeCurrentPeriodEnd
? subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString()
: null}
</p>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-4">
{storeSubscriptionPlans.map((plan) => (
<Card
key={plan.id}
className={
plan.name === subscriptionPlan.name ? "border-primary" : ""
}
>
{plan.name === subscriptionPlan.name ? (
<div className="w-full relative">
<div className="text-center px-3 py-1 bg-secondary-foreground text-secondary text-xs w-fit rounded-l-lg rounded-t-none absolute right-0 font-semibold">
Current Plan
</div>
</div>
) : null}
<CardHeader className="mt-2">
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="mt-2 mb-8">
<h3 className="font-bold">
<span className="text-3xl">${plan.price / 100}</span> / month
</h3>
</div>
<ul className="space-y-2">
{plan.features.map((feature, i) => (
<li key={`feature_${i + 1}`} className="flex gap-x-2 text-sm">
<CheckCircle2Icon className="text-green-400 h-5 w-5" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="flex items-end justify-center">
{session?.user.email ? (
<ManageUserSubscriptionButton
userId={session.user.id}
email={session.user.email || ""}
stripePriceId={plan.stripePriceId}
stripeCustomerId={subscriptionPlan?.stripeCustomerId}
isSubscribed={!!subscriptionPlan.isSubscribed}
isCurrentPlan={subscriptionPlan?.name === plan.name}
/>
) : (
<div>
<Link href="/account">
<Button className="text-center" variant="ghost">
Add Email to Subscribe
</Button>
</Link>
</div>
)}
</CardFooter>
</Card>
))}
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions src/app/(app)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import UserSettings from "./UserSettings";
import PlanSettings from "./PlanSettings";
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
import { getUserSubscriptionPlan } from "@/lib/stripe/subscription";

export default async function Account() {
await checkAuth();
const { session } = await getUserAuth();
const subscriptionPlan = await getUserSubscriptionPlan();

return (
<main>
<h1 className="text-2xl font-semibold my-4">Account</h1>
<div className="space-y-4">
<PlanSettings subscriptionPlan={subscriptionPlan} session={session} />
<UserSettings session={session} />
</div>
</main>
Loading