-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/stripe #66
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
Feat/stripe #66
Changes from all commits
3122fb6
51a1ca6
4b0e42d
cc4362f
6a30d6b
07cb023
5e18a6c
ecbc82a
0826652
9eabc6a
8f06488
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "name": "@acme/stripe-listener", | ||
| "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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be configurable (see |
||
| "typecheck": "echo 'No TypeScript files to check in stripe-listener'" | ||
| } | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
| } |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
|
|
||
| return ( | ||
| <Button | ||
| onClick={handleClick} | ||
| size="sm" | ||
| className="rounded-lg bg-black text-white" | ||
| variant="outline" | ||
| disabled={isLoading} | ||
| {...buttonProps} | ||
| > | ||
| {children} | ||
| </Button> | ||
| ); | ||
| }; | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
| @@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| ); | ||
| }; | ||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Rename to
stripe-tunnelor juststripein case we add more local development goodies.