Skip to content
Merged
Show file tree
Hide file tree
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
11 changes: 11 additions & 0 deletions apps/stripe-listener/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@acme/stripe-listener",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Rename to stripe-tunnel or just stripe in case we add more local development goodies.

"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"clean": "git clean -xdf .cache .turbo node_modules tsconfig.tsbuildinfo",
"dev": "stripe listen --forward-to localhost:3000/api/auth/stripe/webhook",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be configurable (see localtunnel).

"typecheck": "echo 'No TypeScript files to check in stripe-listener'"
}
}
9 changes: 9 additions & 0 deletions apps/stripe-listener/turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"tasks": {
"dev": {
"persistent": true
}
}
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"remark-gfm": "^4.0.1",
"remove-markdown": "^0.6.2",
"sonner": "^2.0.5",
"stripe": "^18.5.0",
"superjson": "2.2.2",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.2.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use client";

import type { ActiveSubscription } from "@acme/api";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "~/auth/client";
import { Button } from "~/components/ui/button";

export const SubscriptionManageButton = ({
activeSubscription,
children,
onClick,
...buttonProps
}: {
activeSubscription: ActiveSubscription;
} & ComponentProps<typeof Button>) => {
const pathname = usePathname();
const { data: sessionData } = authClient.useSession();
const [isLoading, setIsLoading] = useState(false);

if (!activeSubscription) {
return null;
}

const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
setIsLoading(true);

if (!sessionData?.user?.id) {
toast.error("Please sign in to manage your subscription");
return;
}

const result = await authClient.subscription.billingPortal({
locale: "en",
referenceId: sessionData.user.id,
returnUrl: pathname,
});

if (result.error) {
console.error("Billing portal error:", result.error);
toast.error("Failed to open billing portal. Please try again.");
}

// Call the custom onClick handler if provided
onClick?.(event);
setIsLoading(false);
};
Comment on lines +27 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use react-query instead?


return (
<Button
onClick={handleClick}
size="sm"
className="rounded-lg bg-black text-white"
variant="outline"
disabled={isLoading}
{...buttonProps}
>
{children}
</Button>
);
};
53 changes: 53 additions & 0 deletions apps/web/src/app/(app)/(billing)/subscription/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CreditCard } from "lucide-react";
import { redirect } from "next/navigation";
import { Card, CardContent } from "~/components/ui/card";
import { api } from "~/trpc/server";
import { SubscriptionInfo } from "../../_components/billing/subscription-info";
import { SubscriptionManageButton } from "./_components/subscription-manage-button";

export default async function SubscriptionPage() {
const subscription = await api.subscription.getActiveSubscription();
if (!subscription) {
redirect("/");
}

// Format the cancellation date if subscription is set to cancel at period end
const cancelDate =
subscription.cancelAtPeriodEnd && subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
})
: null;

return (
<div className="container mx-full p-8">
<div className="space-y-6">
<div>
<h1 className="font-semibold text-2xl text-foreground">
Current Subscription
</h1>
</div>
<Card>
<CardContent>
<SubscriptionInfo activeSubscription={subscription} />
</CardContent>
</Card>
{/* Action Button */}
<div className="flex justify-end">
{cancelDate ? (
<SubscriptionManageButton activeSubscription={subscription}>
Don't cancel subscription
</SubscriptionManageButton>
) : (
<SubscriptionManageButton activeSubscription={subscription}>
<CreditCard />
Manage Subscription
</SubscriptionManageButton>
)}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import type { ActiveSubscription } from "@acme/api";
import { CreditCard } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "~/components/ui/dropdown-menu";

export const AppSidebarManageSubscription = ({
activeSubscription,
}: {
activeSubscription: ActiveSubscription;
}) => {
const pathname = usePathname();

if (!activeSubscription) {
return null;
}

const isOnSubscriptionPage = pathname === "/subscription";

return (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className={"w-full cursor-pointer"}>
{isOnSubscriptionPage ? (
<div className="flex items-center gap-2">
<CreditCard />
Subscription
</div>
) : (
<Link href="/subscription" className="flex items-center gap-2">
<CreditCard />
Subscription
</Link>
)}
Comment on lines +29 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do we need to do this?

</DropdownMenuItem>
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
"use client";

import { Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthModal } from "~/components/auth/auth-modal-provider";
import { DropdownMenuItem } from "~/components/ui/dropdown-menu";

type AppSidebarUserSettingsProps = ComponentProps<"button">;

export function AppSidebarUserSettings(props: AppSidebarUserSettingsProps) {
export function AppSidebarUserSettings() {
const router = useRouter();
const pathname = usePathname();
const { setCancelUrl } = useAuthModal();

const handleClick = () => {
setCancelUrl(pathname);

// Add a small delay to allow the dropdown to close before navigation
setTimeout(() => {
router.push("/auth/settings");
}, 150);
};
Comment on lines +13 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return (
<button type="button" {...props}>
<Link
className="flex w-full items-center gap-x-2"
href="/auth/settings"
onClick={() => {
setCancelUrl(pathname);
}}
>
<Settings />
Settings
</Link>
</button>
<DropdownMenuItem className="w-full cursor-pointer" onClick={handleClick}>
<Settings />
Settings
</DropdownMenuItem>
);
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { LogOut } from "lucide-react";
import type { ComponentProps } from "react";
import { signOutAction } from "~/auth/sign-out.action";
import { DropdownMenuItem } from "~/components/ui/dropdown-menu";

type AppSidebarUserSignOutProps = ComponentProps<"button">;

export function AppSidebarUserSignOut(props: AppSidebarUserSignOutProps) {
export function AppSidebarUserSignOut() {
return (
<button type="button" onClick={signOutAction} {...props}>
<LogOut />
Sign out
</button>
<DropdownMenuItem asChild className="w-full cursor-pointer">
<form action={signOutAction} className="w-full">
<button type="submit" className="flex w-full items-center gap-2">
<LogOut />
Sign out
</button>
</form>
</DropdownMenuItem>
Comment on lines +7 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happened here?

);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { getUser } from "~/auth/server";
import { Avatar, AvatarImage } from "~/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Expand All @@ -13,6 +12,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "~/components/ui/sidebar";
import { api } from "~/trpc/server";
import { AppSidebarManageSubscription } from "./app-sidebar-manage-subscription";
import {
AppSidebarUserEmail,
AppSidebarUserInformation,
Expand All @@ -24,6 +25,7 @@ import { AppSidebarUserSignOut } from "./app-sidebar-user-sign-out";

export async function AppSidebarUser() {
const user = await getUser();
const activeSubscription = await api.subscription.getActiveSubscription();

return (
<SidebarMenu>
Expand Down Expand Up @@ -70,12 +72,11 @@ export async function AppSidebarUser() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="w-full cursor-pointer">
<AppSidebarUserSettings />
</DropdownMenuItem>
<DropdownMenuItem asChild className="w-full cursor-pointer">
<AppSidebarUserSignOut />
</DropdownMenuItem>
<AppSidebarUserSettings />
<AppSidebarUserSignOut />
<AppSidebarManageSubscription
activeSubscription={activeSubscription}
/>
</AppSidebarUserMenu>
</DropdownMenu>
</SidebarMenuItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Dialog } from "~/components/ui/dialog";
import { BillingErrorBoundary } from "../../_components/billing-error-boundary";

export const SubscriptionModal = ({
children,
}: {
children: React.ReactNode;
}) => {
const pathname = usePathname();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);

// Use effect to prevent hydration mismatch and flashing
useEffect(() => {
setIsOpen(pathname === "/subscription");
}, [pathname]);
Comment on lines +17 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: I'll check this later


const handleOpenChange = (open: boolean) => {
if (!open) {
setIsOpen(false);
router.back();
}
};

return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<BillingErrorBoundary>{children}</BillingErrorBoundary>
</Dialog>
);
};
52 changes: 52 additions & 0 deletions apps/web/src/app/(app)/@billingModal/(.)subscription/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CreditCard } from "lucide-react";
import { redirect } from "next/navigation";
import {
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { api } from "~/trpc/server";
import { SubscriptionInfo } from "../../_components/billing/subscription-info";
import { SubscriptionManageButton } from "../../(billing)/subscription/_components/subscription-manage-button";
import { SubscriptionModal } from "./_components/subscription-modal";

export default async function SubscriptionBillingModalPage() {
const subscription = await api.subscription.getActiveSubscription();
if (!subscription) {
return redirect("/");
}

// Format the cancellation date if subscription is set to cancel at period end
const cancelDate =
subscription.cancelAtPeriodEnd && subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
})
: null;

return (
<SubscriptionModal>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage Subscription</DialogTitle>
</DialogHeader>
<SubscriptionInfo activeSubscription={subscription} />
{/* Action Button */}
<div className="flex justify-end">
{cancelDate ? (
<SubscriptionManageButton activeSubscription={subscription}>
Don't cancel subscription
</SubscriptionManageButton>
) : (
<SubscriptionManageButton activeSubscription={subscription}>
<CreditCard />
Manage Subscription
</SubscriptionManageButton>
)}
</div>
</DialogContent>
</SubscriptionModal>
);
}
Loading
Loading