Skip to content

Commit b47fade

Browse files
authored
Feat/stripe (#66)
* Chore: setup better auth stripe. fix types. initial stripe setup (webhook, local listener) * minor changes * Subscription management navigation + ui fixes * Manage subscription * only current user can manage their own subscriptions * Fix dialog content z position * Remove unused * no ts for stripe-listener * Remove empty dev dependencies
1 parent d8dba4f commit b47fade

38 files changed

+785
-123
lines changed

apps/stripe-listener/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@acme/stripe-listener",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"clean": "git clean -xdf .cache .turbo node_modules tsconfig.tsbuildinfo",
8+
"dev": "stripe listen --forward-to localhost:3000/api/auth/stripe/webhook",
9+
"typecheck": "echo 'No TypeScript files to check in stripe-listener'"
10+
}
11+
}

apps/stripe-listener/turbo.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "https://turborepo.org/schema.json",
3+
"extends": ["//"],
4+
"tasks": {
5+
"dev": {
6+
"persistent": true
7+
}
8+
}
9+
}

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"remark-gfm": "^4.0.1",
7575
"remove-markdown": "^0.6.2",
7676
"sonner": "^2.0.5",
77+
"stripe": "^18.5.0",
7778
"superjson": "2.2.2",
7879
"tailwind-merge": "^3.3.0",
7980
"tw-animate-css": "^1.2.4",
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import type { ActiveSubscription } from "@acme/api";
4+
import { usePathname } from "next/navigation";
5+
import type { ComponentProps } from "react";
6+
import { useState } from "react";
7+
import { toast } from "sonner";
8+
import { authClient } from "~/auth/client";
9+
import { Button } from "~/components/ui/button";
10+
11+
export const SubscriptionManageButton = ({
12+
activeSubscription,
13+
children,
14+
onClick,
15+
...buttonProps
16+
}: {
17+
activeSubscription: ActiveSubscription;
18+
} & ComponentProps<typeof Button>) => {
19+
const pathname = usePathname();
20+
const { data: sessionData } = authClient.useSession();
21+
const [isLoading, setIsLoading] = useState(false);
22+
23+
if (!activeSubscription) {
24+
return null;
25+
}
26+
27+
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
28+
setIsLoading(true);
29+
30+
if (!sessionData?.user?.id) {
31+
toast.error("Please sign in to manage your subscription");
32+
return;
33+
}
34+
35+
const result = await authClient.subscription.billingPortal({
36+
locale: "en",
37+
referenceId: sessionData.user.id,
38+
returnUrl: pathname,
39+
});
40+
41+
if (result.error) {
42+
console.error("Billing portal error:", result.error);
43+
toast.error("Failed to open billing portal. Please try again.");
44+
}
45+
46+
// Call the custom onClick handler if provided
47+
onClick?.(event);
48+
setIsLoading(false);
49+
};
50+
51+
return (
52+
<Button
53+
onClick={handleClick}
54+
size="sm"
55+
className="rounded-lg bg-black text-white"
56+
variant="outline"
57+
disabled={isLoading}
58+
{...buttonProps}
59+
>
60+
{children}
61+
</Button>
62+
);
63+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CreditCard } from "lucide-react";
2+
import { redirect } from "next/navigation";
3+
import { Card, CardContent } from "~/components/ui/card";
4+
import { api } from "~/trpc/server";
5+
import { SubscriptionInfo } from "../../_components/billing/subscription-info";
6+
import { SubscriptionManageButton } from "./_components/subscription-manage-button";
7+
8+
export default async function SubscriptionPage() {
9+
const subscription = await api.subscription.getActiveSubscription();
10+
if (!subscription) {
11+
redirect("/");
12+
}
13+
14+
// Format the cancellation date if subscription is set to cancel at period end
15+
const cancelDate =
16+
subscription.cancelAtPeriodEnd && subscription.periodEnd
17+
? new Date(subscription.periodEnd).toLocaleDateString("en-US", {
18+
day: "numeric",
19+
month: "long",
20+
year: "numeric",
21+
})
22+
: null;
23+
24+
return (
25+
<div className="container mx-full p-8">
26+
<div className="space-y-6">
27+
<div>
28+
<h1 className="font-semibold text-2xl text-foreground">
29+
Current Subscription
30+
</h1>
31+
</div>
32+
<Card>
33+
<CardContent>
34+
<SubscriptionInfo activeSubscription={subscription} />
35+
</CardContent>
36+
</Card>
37+
{/* Action Button */}
38+
<div className="flex justify-end">
39+
{cancelDate ? (
40+
<SubscriptionManageButton activeSubscription={subscription}>
41+
Don't cancel subscription
42+
</SubscriptionManageButton>
43+
) : (
44+
<SubscriptionManageButton activeSubscription={subscription}>
45+
<CreditCard />
46+
Manage Subscription
47+
</SubscriptionManageButton>
48+
)}
49+
</div>
50+
</div>
51+
</div>
52+
);
53+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import type { ActiveSubscription } from "@acme/api";
4+
import { CreditCard } from "lucide-react";
5+
import Link from "next/link";
6+
import { usePathname } from "next/navigation";
7+
import {
8+
DropdownMenuItem,
9+
DropdownMenuSeparator,
10+
} from "~/components/ui/dropdown-menu";
11+
12+
export const AppSidebarManageSubscription = ({
13+
activeSubscription,
14+
}: {
15+
activeSubscription: ActiveSubscription;
16+
}) => {
17+
const pathname = usePathname();
18+
19+
if (!activeSubscription) {
20+
return null;
21+
}
22+
23+
const isOnSubscriptionPage = pathname === "/subscription";
24+
25+
return (
26+
<>
27+
<DropdownMenuSeparator />
28+
<DropdownMenuItem asChild className={"w-full cursor-pointer"}>
29+
{isOnSubscriptionPage ? (
30+
<div className="flex items-center gap-2">
31+
<CreditCard />
32+
Subscription
33+
</div>
34+
) : (
35+
<Link href="/subscription" className="flex items-center gap-2">
36+
<CreditCard />
37+
Subscription
38+
</Link>
39+
)}
40+
</DropdownMenuItem>
41+
</>
42+
);
43+
};
Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
"use client";
2+
23
import { Settings } from "lucide-react";
3-
import Link from "next/link";
4-
import { usePathname } from "next/navigation";
5-
import type { ComponentProps } from "react";
4+
import { usePathname, useRouter } from "next/navigation";
65
import { useAuthModal } from "~/components/auth/auth-modal-provider";
6+
import { DropdownMenuItem } from "~/components/ui/dropdown-menu";
77

8-
type AppSidebarUserSettingsProps = ComponentProps<"button">;
9-
10-
export function AppSidebarUserSettings(props: AppSidebarUserSettingsProps) {
8+
export function AppSidebarUserSettings() {
9+
const router = useRouter();
1110
const pathname = usePathname();
1211
const { setCancelUrl } = useAuthModal();
12+
13+
const handleClick = () => {
14+
setCancelUrl(pathname);
15+
16+
// Add a small delay to allow the dropdown to close before navigation
17+
setTimeout(() => {
18+
router.push("/auth/settings");
19+
}, 150);
20+
};
21+
1322
return (
14-
<button type="button" {...props}>
15-
<Link
16-
className="flex w-full items-center gap-x-2"
17-
href="/auth/settings"
18-
onClick={() => {
19-
setCancelUrl(pathname);
20-
}}
21-
>
22-
<Settings />
23-
Settings
24-
</Link>
25-
</button>
23+
<DropdownMenuItem className="w-full cursor-pointer" onClick={handleClick}>
24+
<Settings />
25+
Settings
26+
</DropdownMenuItem>
2627
);
2728
}
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { LogOut } from "lucide-react";
2-
import type { ComponentProps } from "react";
32
import { signOutAction } from "~/auth/sign-out.action";
3+
import { DropdownMenuItem } from "~/components/ui/dropdown-menu";
44

5-
type AppSidebarUserSignOutProps = ComponentProps<"button">;
6-
7-
export function AppSidebarUserSignOut(props: AppSidebarUserSignOutProps) {
5+
export function AppSidebarUserSignOut() {
86
return (
9-
<button type="button" onClick={signOutAction} {...props}>
10-
<LogOut />
11-
Sign out
12-
</button>
7+
<DropdownMenuItem asChild className="w-full cursor-pointer">
8+
<form action={signOutAction} className="w-full">
9+
<button type="submit" className="flex w-full items-center gap-2">
10+
<LogOut />
11+
Sign out
12+
</button>
13+
</form>
14+
</DropdownMenuItem>
1315
);
1416
}

apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-user.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { getUser } from "~/auth/server";
33
import { Avatar, AvatarImage } from "~/components/ui/avatar";
44
import {
55
DropdownMenu,
6-
DropdownMenuItem,
76
DropdownMenuLabel,
87
DropdownMenuSeparator,
98
DropdownMenuTrigger,
@@ -13,6 +12,8 @@ import {
1312
SidebarMenuButton,
1413
SidebarMenuItem,
1514
} from "~/components/ui/sidebar";
15+
import { api } from "~/trpc/server";
16+
import { AppSidebarManageSubscription } from "./app-sidebar-manage-subscription";
1617
import {
1718
AppSidebarUserEmail,
1819
AppSidebarUserInformation,
@@ -24,6 +25,7 @@ import { AppSidebarUserSignOut } from "./app-sidebar-user-sign-out";
2425

2526
export async function AppSidebarUser() {
2627
const user = await getUser();
28+
const activeSubscription = await api.subscription.getActiveSubscription();
2729

2830
return (
2931
<SidebarMenu>
@@ -70,12 +72,11 @@ export async function AppSidebarUser() {
7072
</div>
7173
</DropdownMenuLabel>
7274
<DropdownMenuSeparator />
73-
<DropdownMenuItem asChild className="w-full cursor-pointer">
74-
<AppSidebarUserSettings />
75-
</DropdownMenuItem>
76-
<DropdownMenuItem asChild className="w-full cursor-pointer">
77-
<AppSidebarUserSignOut />
78-
</DropdownMenuItem>
75+
<AppSidebarUserSettings />
76+
<AppSidebarUserSignOut />
77+
<AppSidebarManageSubscription
78+
activeSubscription={activeSubscription}
79+
/>
7980
</AppSidebarUserMenu>
8081
</DropdownMenu>
8182
</SidebarMenuItem>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use client";
2+
3+
import { usePathname, useRouter } from "next/navigation";
4+
import { useEffect, useState } from "react";
5+
import { Dialog } from "~/components/ui/dialog";
6+
import { BillingErrorBoundary } from "../../_components/billing-error-boundary";
7+
8+
export const SubscriptionModal = ({
9+
children,
10+
}: {
11+
children: React.ReactNode;
12+
}) => {
13+
const pathname = usePathname();
14+
const router = useRouter();
15+
const [isOpen, setIsOpen] = useState(false);
16+
17+
// Use effect to prevent hydration mismatch and flashing
18+
useEffect(() => {
19+
setIsOpen(pathname === "/subscription");
20+
}, [pathname]);
21+
22+
const handleOpenChange = (open: boolean) => {
23+
if (!open) {
24+
setIsOpen(false);
25+
router.back();
26+
}
27+
};
28+
29+
return (
30+
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
31+
<BillingErrorBoundary>{children}</BillingErrorBoundary>
32+
</Dialog>
33+
);
34+
};

0 commit comments

Comments
 (0)