Skip to content

Commit

Permalink
clients/web: implement customers dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
frankie567 committed Dec 16, 2024
1 parent 0d2785d commit 98b2e29
Show file tree
Hide file tree
Showing 13 changed files with 423 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const ClientPage = ({
<div className="flex flex-row items-center gap-x-3">
<Avatar
className="h-10 w-10"
avatar_url={null}
avatar_url={selectedLicenseKey.customer.avatar_url}
name={selectedLicenseKey.customer.email}
/>
<div className="flex flex-col">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
'use client'

import CopyToClipboardButton from '@/components/CopyToClipboardButton/CopyToClipboardButton'
import { DashboardBody } from '@/components/Layout/DashboardLayout'
import { InlineModal } from '@/components/Modal/InlineModal'
import { useModal } from '@/components/Modal/useModal'
import { useCustomers, useListSubscriptions } from '@/hooks/queries'
import { useOrders } from '@/hooks/queries/orders'
import useDebouncedCallback from '@/hooks/utils'
import {
DataTablePaginationState,
DataTableSortingState,
getAPIParams,
serializeSearchParams,
} from '@/utils/datatable'
import { List, ListItem } from 'polarkit/components/ui/atoms/list'

import AmountLabel from '@/components/Shared/AmountLabel'
import { Search } from '@mui/icons-material'
import { Customer, Organization } from '@polar-sh/sdk'
import { RowSelectionState } from '@tanstack/react-table'
import { useRouter } from 'next/navigation'
import { FormattedDateTime } from 'polarkit/components/ui/atoms'
import Avatar from 'polarkit/components/ui/atoms/avatar'
import {
DataTable,
DataTableColumnDef,
DataTableColumnHeader,
} from 'polarkit/components/ui/atoms/datatable'
import Input from 'polarkit/components/ui/atoms/input'
import React, { useCallback, useEffect, useState } from 'react'

interface ClientPageProps {
organization: Organization
pagination: DataTablePaginationState
sorting: DataTableSortingState
query: string | undefined
}

const ClientPage: React.FC<ClientPageProps> = ({
organization,
pagination,
sorting,
query: _query,
}) => {
const [query, setQuery] = useState(_query)

const [selectedCustomerState, setSelectedOrderState] =
useState<RowSelectionState>({})
const { hide: hideModal, show: showModal, isShown: isModalShown } = useModal()

const getSearchParams = (
pagination: DataTablePaginationState,
sorting: DataTableSortingState,
query: string | undefined,
) => {
const params = serializeSearchParams(pagination, sorting)

if (query) {
params.append('query', query)
}

return params
}

const router = useRouter()

const setPagination = (
updaterOrValue:
| DataTablePaginationState
| ((old: DataTablePaginationState) => DataTablePaginationState),
) => {
const updatedPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue

router.push(
`/dashboard/${organization.slug}/customers?${getSearchParams(
updatedPagination,
sorting,
query,
)}`,
)
}

const setSorting = (
updaterOrValue:
| DataTableSortingState
| ((old: DataTableSortingState) => DataTableSortingState),
) => {
const updatedSorting =
typeof updaterOrValue === 'function'
? updaterOrValue(sorting)
: updaterOrValue

router.push(
`/dashboard/${organization.slug}/customers?${getSearchParams(
pagination,
updatedSorting,
query,
)}`,
)
}

const debouncedQueryChange = useDebouncedCallback(
(query: string) => {
router.push(
`/dashboard/${organization.slug}/customers?${getSearchParams(
{ ...pagination, pageIndex: 0 },
sorting,
query,
)}`,
)
},
500,
[pagination, sorting, query, router],
)

const onQueryChange = useCallback(
(query: string) => {
setQuery(query)
debouncedQueryChange(query)
},
[debouncedQueryChange],
)

const customersHook = useCustomers(organization.id, {
...getAPIParams(pagination, sorting),
query,
})

const customers = customersHook.data?.items || []
const pageCount = customersHook.data?.pagination.max_page ?? 1

const columns: DataTableColumnDef<Customer>[] = [
{
accessorKey: 'email',
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row: { original: customer } }) => {
return (
<div className="flex flex-row items-center gap-2">
<Avatar
className="h-8 w-8"
avatar_url={customer.avatar_url}
name={customer.name || customer.email}
/>
<div className="fw-medium">{customer.email}</div>
</div>
)
},
},
{
accessorKey: 'name',
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: (props) => {
const name = props.getValue() as string | null
return <>{name || '—'}</>
},
},
{
accessorKey: 'created_at',
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created At" />
),
cell: (props) => (
<FormattedDateTime datetime={props.getValue() as string} />
),
},
]

const selectedCustomer = customers.find(
(order) => selectedCustomerState[order.id],
)

useEffect(() => {
if (selectedCustomer) {
showModal()
} else {
hideModal()
}
}, [selectedCustomer, showModal, hideModal])

return (
<DashboardBody>
<div className="flex flex-col gap-8">
<div className="flex flex-row items-center justify-between gap-6">
<Input
className="w-full max-w-64"
preSlot={<Search fontSize="small" />}
placeholder="Search Customers"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
/>
</div>
{customers && pageCount !== undefined && (
<DataTable
columns={columns}
data={customers}
pageCount={pageCount}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={setSorting}
isLoading={customersHook.isLoading}
onRowSelectionChange={(row) => {
setSelectedOrderState(row)
}}
rowSelection={selectedCustomerState}
getRowId={(row) => row.id.toString()}
enableRowSelection
/>
)}
</div>
<InlineModal
modalContent={
selectedCustomer ? (
<CustomerModal customer={selectedCustomer} />
) : (
<></>
)
}
isShown={isModalShown}
hide={() => {
setSelectedOrderState({})
hideModal()
}}
/>
</DashboardBody>
)
}

interface CustomerModalProps {
customer: Customer
}

const CustomerModal = ({ customer }: CustomerModalProps) => {
const { data: orders } = useOrders(customer.organization_id, {
customerId: customer.id,
limit: 5,
sorting: ['-created_at'],
})

const { data: subscriptions } = useListSubscriptions(
customer.organization_id,
{
customerId: customer.id,
limit: 5,
sorting: ['-started_at'],
},
)

return (
<div className="flex flex-col gap-8 overflow-y-auto px-8 py-12">
<h2 className="mb-4 text-2xl">Customer Details</h2>
<div className="flex flex-row items-center gap-4">
<Avatar
avatar_url={customer.avatar_url}
name={customer.name || customer.email}
className="h-16 w-16"
/>
<div className="flex flex-col gap-1">
<p className="text-xl">{customer.email}</p>
<div className="flex flex-row items-center gap-1 font-mono text-xs">
{customer.id}
<CopyToClipboardButton text={customer.id} />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-lg">Subscriptions</h3>
{subscriptions && subscriptions.items.length > 0 ? (
<List>
{subscriptions.items.map((subscription) => (
<ListItem key={subscription.id}>
<span>{subscription.product.name}</span>
{subscription.amount && subscription.currency && (
<span>
<AmountLabel
amount={subscription.amount}
currency={subscription.currency}
interval={subscription.recurring_interval}
/>
</span>
)}
</ListItem>
))}
</List>
) : (
'No orders found'
)}
</div>
<div className="flex flex-col gap-2">
<h3 className="text-lg">Orders</h3>
{orders && orders.items.length > 0 ? (
<List>
{orders.items.map((order) => (
<ListItem key={order.id}>
<span>{order.product.name}</span>
<span>
<AmountLabel
amount={order.amount}
currency={order.currency}
/>
</span>
</ListItem>
))}
</List>
) : (
'No orders found'
)}
</div>
</div>
)
}

export default ClientPage
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getServerSideAPI } from '@/utils/api/serverside'
import { DataTableSearchParams, parseSearchParams } from '@/utils/datatable'
import { getOrganizationBySlugOrNotFound } from '@/utils/organization'
import { Metadata } from 'next'
import ClientPage from './ClientPage'

export async function generateMetadata(): Promise<Metadata> {
return {
title: 'Customers', // " | Polar is added by the template"
}
}

export default async function Page({
params,
searchParams,
}: {
params: { organization: string }
searchParams: DataTableSearchParams & { query?: string }
}) {
const api = getServerSideAPI()
const organization = await getOrganizationBySlugOrNotFound(
api,
params.organization,
)

const { pagination, sorting } = parseSearchParams(searchParams, [
{ id: 'created_at', desc: true },
])

return (
<ClientPage
organization={organization}
pagination={pagination}
sorting={sorting}
query={searchParams.query}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ const ClientPage: React.FC<ClientPageProps> = ({
<div className="flex flex-row items-center gap-2">
<Avatar
className="h-8 w-8"
avatar_url={null}
avatar_url={customer.avatar_url}
name={customer.name || customer.email}
/>
<div className="fw-medium">{customer.email}</div>
Expand Down Expand Up @@ -248,7 +248,7 @@ const OrderModal = ({ order }: OrderModalProps) => {
<h2 className="mb-4 text-2xl">Order Details</h2>
<div className="flex flex-row items-center gap-4">
<Avatar
avatar_url={null}
avatar_url={order.customer.avatar_url}
name={order.customer.name || order.customer.email}
className="h-16 w-16"
/>
Expand Down
Loading

0 comments on commit 98b2e29

Please sign in to comment.