Skip to content
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/invoice direct #9

Merged
merged 23 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
32 changes: 13 additions & 19 deletions actions/billingPortal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ import { logger } from "@/lib/logger"
export async function updatePaymentMethodViaBillingPortal() {
let url: string
try {
const user = await currentUser.customerId()
if (!user?.customerId) {
const customerId = await currentUser.customerId()
if (!customerId) {
throw new Error(
"An error occurred while creating a billing portal session"
)
}

url = await billingPortal.sessions.createURL({
customer: user.customerId,
customer: customerId,
return_url: `${baseUrl}/account`,
flow_data: {
type: "payment_method_update",
Expand All @@ -52,24 +52,21 @@ export async function updatePaymentMethodViaBillingPortal() {
export async function cancelSubscriptionViaBillingPortal() {
let url: string
try {
const userWithCustomerId = await currentUser.customerId()
const userWithSubscriptionId = await currentUser.subscriptionId()
if (
!userWithCustomerId?.customerId ||
!userWithSubscriptionId?.subscriptionId
) {
const customerId = await currentUser.customerId()
const subscriptionId = await currentUser.subscriptionId()
if (!customerId || !subscriptionId) {
throw new Error(
"An error occurred while creating a billing portal session"
)
}

url = await billingPortal.sessions.createURL({
customer: userWithCustomerId.customerId,
customer: customerId,
return_url: `${baseUrl}/account`,
flow_data: {
type: "subscription_cancel",
subscription_cancel: {
subscription: userWithSubscriptionId.subscriptionId,
subscription: subscriptionId,
},
after_completion: {
type: "redirect",
Expand All @@ -90,24 +87,21 @@ export async function cancelSubscriptionViaBillingPortal() {
export async function updateSubscriptionViaBillingPortal() {
let url: string
try {
const userWithCustomerId = await currentUser.customerId()
const userWithSubscriptionId = await currentUser.subscriptionId()
if (
!userWithCustomerId?.customerId ||
!userWithSubscriptionId?.subscriptionId
) {
const customerId = await currentUser.customerId()
const subscriptionId = await currentUser.subscriptionId()
if (!customerId || !subscriptionId) {
throw new Error(
"An error occurred while creating a billing portal session"
)
}

url = await billingPortal.sessions.createURL({
customer: userWithCustomerId.customerId,
customer: customerId,
return_url: `${baseUrl}/account`,
flow_data: {
type: "subscription_update",
subscription_update: {
subscription: userWithSubscriptionId.subscriptionId,
subscription: subscriptionId,
},
after_completion: {
type: "redirect",
Expand Down
143 changes: 24 additions & 119 deletions app/(app)/account/_components/invoice-card.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import { Suspense } from "react"
import Link from "next/link"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
ExternalLink,
} from "lucide-react"
import { ExternalLink } from "lucide-react"
import { currentUser } from "@/services/currentUser"
import { invoicesLimit } from "@/lib/constants"
import { centsToCurrency, cn } from "@/lib/utils"
import { centsToCurrency } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
Expand All @@ -31,6 +22,8 @@ import {
} from "@/components/ui/table"
import { ErrorBoundary } from "@/components/error-boundary"
import { LinkExternal } from "@/components/link-external"
import { Paginator } from "./paginator"
import { PaginatorProvider } from "./paginator-provider"

function InvoicesErrorFallback() {
return (
Expand Down Expand Up @@ -59,10 +52,14 @@ function InvoicesSkeleton() {
)
}

async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) {
const user = await currentUser.invoices({ page })
async function LoadInvoiceTableRows({
dataPromise,
}: Readonly<{
dataPromise: Promise<Awaited<ReturnType<typeof currentUser.invoices>> | null>
}>) {
const invoices = await dataPromise

if (!user?.invoices || user.invoices.length === 0) {
if (!invoices?.data || invoices.data.length === 0) {
return (
<TableBody>
<TableRow>
Expand All @@ -76,21 +73,23 @@ async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) {

return (
<TableBody>
{user.invoices.map(invoice => (
{invoices.data.map(invoice => (
<TableRow key={invoice.id}>
<TableCell>
<LinkExternal
href={invoice.hostedInvoiceUrl ?? "#"}
href={invoice.hosted_invoice_url ?? "#"}
className="group font-medium"
>
<div className="flex flex-row items-center md:w-60">
{invoice.invoiceNumber}
{invoice.number}
<ExternalLink className="ml-1 hidden size-4 group-hover:block" />
</div>
</LinkExternal>
</TableCell>
<TableCell>{invoice.created.toLocaleDateString()}</TableCell>
<TableCell>{centsToCurrency(invoice.amountPaid)}</TableCell>
<TableCell>
{new Date(invoice.created).toLocaleDateString()}
</TableCell>
<TableCell>{centsToCurrency(invoice.amount_paid)}</TableCell>
<TableCell className="hidden md:block">
<Badge>{invoice.status}</Badge>
</TableCell>
Expand All @@ -100,104 +99,8 @@ async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) {
)
}

async function LoadPagination({ page }: Readonly<{ page: number }>) {
const user = await currentUser.invoicesTotal()

if (!user?.invoicesTotal) {
return null
}

const totalPages = Math.ceil(user.invoicesTotal / invoicesLimit)
const firstPageDisabled = page === 1
const lastPageDisabled = totalPages === page
const previousPageDisabled = page === 1
const nextPageDisabled = totalPages === page
const nextPage = page + 1
const previousPage = page - 1

if (totalPages <= 1) {
return null
}

return (
<div className="flex w-full items-center justify-center space-x-2">
<Button
asChild
variant="outline"
className={cn(
"size-8 p-0",
firstPageDisabled && "bg-muted text-muted-foreground"
)}
>
<Link
href="/account?page=1"
aria-disabled={firstPageDisabled}
tabIndex={firstPageDisabled ? -1 : 0}
className={firstPageDisabled ? "pointer-events-none" : ""}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="size-4" />
</Link>
</Button>
<Button
asChild
variant="outline"
className={cn(
"size-8 p-0",
previousPageDisabled && "bg-muted text-muted-foreground"
)}
>
<Link
href={`/account?page=${previousPage}`}
aria-disabled={previousPageDisabled}
tabIndex={previousPageDisabled ? -1 : 0}
className={previousPageDisabled ? "pointer-events-none" : ""}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="size-4" />
</Link>
</Button>
<Button
asChild
variant="outline"
className={cn(
"size-8 p-0",
nextPageDisabled && "bg-muted text-muted-foreground"
)}
>
<Link
href={`/account?page=${nextPage}`}
aria-disabled={nextPageDisabled}
tabIndex={nextPageDisabled ? -1 : 0}
className={nextPageDisabled ? "pointer-events-none" : ""}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="size-4" />
</Link>
</Button>
<Button
asChild
variant="outline"
className={cn(
"size-8 p-0",
lastPageDisabled && "bg-muted text-muted-foreground"
)}
>
<Link
href={`/account?page=${totalPages}`}
aria-disabled={lastPageDisabled}
tabIndex={lastPageDisabled ? -1 : 0}
className={lastPageDisabled ? "pointer-events-none" : ""}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="size-4" />
</Link>
</Button>
</div>
)
}

export function InvoiceCard({ page }: Readonly<{ page: number }>) {
export function InvoiceCard({ cursor }: Readonly<{ cursor: string }>) {
const invoicesPromise = currentUser.invoices({ cursor })
return (
<Card className="w-full">
<CardHeader>
Expand All @@ -216,15 +119,17 @@ export function InvoiceCard({ page }: Readonly<{ page: number }>) {
</TableHeader>
<ErrorBoundary fallback={<InvoicesErrorFallback />}>
<Suspense fallback={<InvoicesSkeleton />}>
<LoadInvoiceTableRows page={page} />
<LoadInvoiceTableRows dataPromise={invoicesPromise} />
</Suspense>
</ErrorBoundary>
</Table>
</CardContent>
<CardFooter>
<ErrorBoundary fallback={null}>
<Suspense fallback={null}>
<LoadPagination page={page} />
<PaginatorProvider responseToPaginatePromise={invoicesPromise}>
<Paginator />
</PaginatorProvider>
</Suspense>
</ErrorBoundary>
</CardFooter>
Expand Down
113 changes: 113 additions & 0 deletions app/(app)/account/_components/paginator-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"use client"

import {
createContext,
ReactNode,
use,
useEffect,
useState,
useTransition,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"

interface GenericData {
id: string
}

export interface PaginatorContextInterface {
showPaginator: boolean
prevPageDisabled: boolean
nextPageDisabled: boolean
handleNextPage: () => void
handlePrevPage: () => void
isPending: boolean
isPendingNext: boolean
isPendingPrev: boolean
}

export const PaginatorContext = createContext<PaginatorContextInterface | null>(
null
)

export function PaginatorProvider<T extends GenericData>({
children,
responseToPaginatePromise,
}: Readonly<{
children: ReactNode
responseToPaginatePromise: Promise<{ data: T[]; hasMore: boolean } | null>
}>) {
const responseToPaginate = use(responseToPaginatePromise)
const router = useRouter()
const searchParams = useSearchParams()
const [hasMore, setHasMore] = useState(true)
const [prevCursors, setPrevCursors] = useState<string[]>([])
const [isPending, startTransition] = useTransition()
const [isPendingNext, setIsPendingNext] = useState(false)
const [isPendingPrev, setIsPendingPrev] = useState(false)
const cursor = searchParams.get("cursor")

useEffect(() => {
if (cursor) {
setPrevCursors(prev => [...prev, cursor])
} else {
setPrevCursors([])
}

setHasMore(responseToPaginate?.hasMore || false)
}, [cursor])

const handleNextPage = () => {
if (responseToPaginate?.data.length) {
setIsPendingNext(true)
startTransition(() => {
const lastCustomerId =
responseToPaginate.data[responseToPaginate.data.length - 1].id
const params = new URLSearchParams(searchParams)
params.set("cursor", lastCustomerId)
router.push(`?${params.toString()}`)
setIsPendingNext(false)
})
}
}

const handlePrevPage = () => {
if (prevCursors.length > 0) {
setIsPendingPrev(true)
startTransition(() => {
const newPrevCursors = [...prevCursors]
const prevCursor = newPrevCursors.pop() // Remove the current cursor
setPrevCursors(newPrevCursors)

const params = new URLSearchParams(searchParams)
if (prevCursor && newPrevCursors.length > 0) {
params.set("cursor", newPrevCursors[newPrevCursors.length - 1])
} else {
params.delete("cursor")
}
router.push(`?${params.toString()}`)
setIsPendingPrev(false)
})
}
}

const prevPageDisabled = prevCursors.length === 0
const nextPageDisabled = !hasMore
const showPaginator = !prevPageDisabled && !nextPageDisabled

return (
<PaginatorContext.Provider
value={{
showPaginator,
prevPageDisabled,
nextPageDisabled,
handleNextPage,
handlePrevPage,
isPending,
isPendingNext,
isPendingPrev,
}}
>
{children}
</PaginatorContext.Provider>
)
}
Loading
Loading