-
-
Notifications
You must be signed in to change notification settings - Fork 168
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
clients/web: implement customers dashboard
- Loading branch information
1 parent
0d2785d
commit 98b2e29
Showing
13 changed files
with
423 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
324 changes: 324 additions & 0 deletions
324
clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/ClientPage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
38 changes: 38 additions & 0 deletions
38
clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.