feat(dashboard): Customer groups domain (#6098)

**What**
- Adds list, detail, edit, create, add customers views to the CG domain.

**Note**
- Missing metadata in forms (coming in separate PR for entire admin)
- Missing table filters (separate PR)

CLOSES CORE-1648
This commit is contained in:
Kasper Fabricius Kristensen
2024-01-17 15:25:59 +01:00
committed by GitHub
parent 19bbae61f8
commit 80feb972cb
33 changed files with 1540 additions and 48 deletions

View File

@@ -35,6 +35,7 @@
"areYouSure": "Are you sure?",
"noRecordsFound": "No records found",
"typeToConfirm": "Please type {val} to confirm:",
"noResultsTitle": "No results",
"noResultsMessage": "Try changing the filters or search query",
"noRecordsTitle": "No records",
"noRecordsMessage": "There are no records to show",
@@ -80,7 +81,15 @@
"firstSeen": "First seen"
},
"customerGroups": {
"domain": "Customer Groups"
"domain": "Customer Groups",
"createGroup": "Create group",
"createCustomerGroup": "Create Customer Group",
"createCustomerGroupHint": "Create a new customer group to segment your customers.",
"customerAlreadyAdded": "The customer has already been added to the group.",
"editCustomerGroup": "Edit Customer Group",
"removeCustomersWarning_one": "You are about to remove {{count}} customer from the customer group. This action cannot be undone.",
"removeCustomersWarning_other": "You are about to remove {{count}} customers from the customer group. This action cannot be undone.",
"deleteCustomerGroupWarning": "You are about to delete the customer group {{name}}. This action cannot be undone."
},
"orders": {
"domain": "Orders"

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
type NoResultsProps = {
title: string
title?: string
message?: string
}
@@ -16,7 +16,7 @@ export const NoResults = ({ title, message }: NoResultsProps) => {
<div className="flex flex-col items-center gap-y-2">
<MagnifyingGlass />
<Text size="small" leading="compact" weight="plus">
{title}
{title ?? t("general.noResultsTitle")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{message ?? t("general.noResultsMessage")}
@@ -51,7 +51,9 @@ export const NoRecords = ({ title, message, action }: NoRecordsProps) => {
</div>
{action && (
<Link to={action.to}>
<Button variant="secondary">{action.label}</Button>
<Button variant="secondary" size="small">
{action.label}
</Button>
</Link>
)}
</div>

View File

@@ -0,0 +1 @@
export * from "./table-row-actions"

View File

@@ -0,0 +1,84 @@
import { EllipsisHorizontal } from "@medusajs/icons"
import { DropdownMenu, IconButton } from "@medusajs/ui"
import { ReactNode } from "react"
import { Link } from "react-router-dom"
type TableRowAction = {
icon: ReactNode
label: string
} & (
| {
to: string
onClick?: never
}
| {
onClick: () => void
to?: never
}
)
type TableRowActionGroup = {
actions: TableRowAction[]
}
type TableRowActionsProps = {
groups: TableRowActionGroup[]
}
export const TableRowActions = ({ groups }: TableRowActionsProps) => {
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{groups.map((group, index) => {
if (!group.actions.length) {
return null
}
const isLast = index === groups.length - 1
return (
<div className="flex flex-col gap-y-1">
{group.actions.map((action, index) => {
if (action.onClick) {
return (
<DropdownMenu.Item
key={index}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
)
}
return (
<Link to={action.to} key={index}>
<DropdownMenu.Item
onClick={(e) => {
e.stopPropagation()
}}
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Link>
)
})}
{!isLast && <DropdownMenu.Separator />}
</div>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -1,4 +1,5 @@
import type {
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminProductsRes,
AdminRegionsRes,
@@ -183,12 +184,43 @@ const router = createBrowserRouter([
},
children: [
{
index: true,
lazy: () => import("../../routes/customer-groups/list"),
path: "",
lazy: () =>
import("../../routes/customer-groups/customer-group-list"),
children: [
{
path: "create",
lazy: () =>
import(
"../../routes/customer-groups/customer-group-create"
),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/customer-groups/details"),
lazy: () =>
import("../../routes/customer-groups/customer-group-detail"),
handle: {
crumb: (data: AdminCustomerGroupsRes) =>
data.customer_group.name,
},
children: [
{
path: "add-customers",
lazy: () =>
import(
"../../routes/customer-groups/customer-group-add-customers"
),
},
{
path: "edit",
lazy: () =>
import(
"../../routes/customer-groups/customer-group-edit"
),
},
],
},
],
},

View File

@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Customer } from "@medusajs/medusa"
import {
Button,
Checkbox,
FocusModal,
Hint,
Table,
Tooltip,
clx,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
adminCustomerKeys,
useAdminAddCustomersToCustomerGroup,
useAdminCustomers,
} from "medusa-react"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import * as zod from "zod"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { Form } from "../../../../../components/common/form"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { queryClient } from "../../../../../lib/medusa"
type AddCustomersFormProps = {
customerGroupId: string
subscribe: (state: boolean) => void
}
const AddCustomersSchema = zod.object({
customer_ids: zod.array(zod.string()).min(1),
})
const PAGE_SIZE = 10
export const AddCustomersForm = ({
customerGroupId,
subscribe,
}: AddCustomersFormProps) => {
const navigate = useNavigate()
const { t } = useTranslation()
const form = useForm<zod.infer<typeof AddCustomersSchema>>({
defaultValues: {
customer_ids: [],
},
resolver: zodResolver(AddCustomersSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
useEffect(() => {
form.setValue(
"customer_ids",
Object.keys(rowSelection).filter((k) => rowSelection[k])
)
}, [rowSelection])
const params = useQueryParams(["q"])
const { customers, count, isLoading, isError, error } = useAdminCustomers({
expand: "groups",
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
})
const columns = useColumns()
const table = useReactTable({
data: customers ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
customerGroupId,
},
})
const { mutateAsync, isLoading: isMutating } =
useAdminAddCustomersToCustomerGroup(customerGroupId)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
customer_ids: data.customer_ids.map((id) => ({ id })),
},
{
onSuccess: () => {
queryClient.invalidateQueries(adminCustomerKeys.lists())
navigate(`/customer-groups/${customerGroupId}`)
},
}
)
})
const noRecords =
!isLoading &&
!customers?.length &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Form {...form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
{form.formState.errors.customer_ids && (
<Hint variant="error">
{form.formState.errors.customer_ids.message}
</Hint>
)}
<FocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isMutating}
>
{t("general.add")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto divide-y">
{noRecords ? (
<div className="w-full flex-1 flex items-center justify-center">
<NoRecords />
</div>
) : (
<div className="divide-y w-full flex-1 flex flex-col">
<div className="flex items-center justify-between w-full px-6 py-4">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
<div className="w-full flex-1 overflow-y-auto">
{(customers?.length || 0) > 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
},
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
row.original.groups
?.map((cg) => cg.id)
.includes(customerGroupId),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="flex-1 flex items-center justify-center min-h-full">
<NoResults />
</div>
)}
</div>
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
)}
</FocusModal.Body>
</form>
</Form>
)
}
const columnHelper = createColumnHelper<Customer>()
const useColumns = () => {
const { t } = useTranslation()
const columns = useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row, table }) => {
const { customerGroupId } = table.options.meta as {
customerGroupId: string
}
const isAdded = row.original.groups
?.map((gc) => gc.id)
.includes(customerGroupId)
const isSelected = row.getIsSelected() || isAdded
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
if (isAdded) {
return (
<Tooltip
content={t("customerGroups.customerAlreadyAdded")}
side="right"
>
{Component}
</Tooltip>
)
}
return Component
},
}),
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const name = [row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(" ")
return name || "-"
},
}),
],
[t]
)
return columns
}

View File

@@ -0,0 +1,18 @@
import { FocusModal } from "@medusajs/ui"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { AddCustomersForm } from "./components/add-customers-form"
export const CustomerGroupAddCustomers = () => {
const [open, onOpenChange, subscribe] = useRouteModalState()
const { id } = useParams()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<AddCustomersForm customerGroupId={id!} subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1 @@
export { CustomerGroupAddCustomers as Component } from "./customer-group-add-customers"

View File

@@ -0,0 +1,105 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, FocusModal, Heading, Input, Text } from "@medusajs/ui"
import { useAdminCreateCustomerGroup } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
type CreateCustomerGroupFormProps = {
subscribe: (state: boolean) => void
}
const CreateCustomerGroupSchema = zod.object({
name: zod.string().min(1),
})
export const CreateCustomerGroupForm = ({
subscribe,
}: CreateCustomerGroupFormProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const form = useForm<zod.infer<typeof CreateCustomerGroupSchema>>({
defaultValues: {
name: "",
},
resolver: zodResolver(CreateCustomerGroupSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading } = useAdminCreateCustomerGroup()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
name: data.name,
},
{
onSuccess: ({ customer_group }) => {
navigate(`/customer-groups/${customer_group.id}`)
},
}
)
})
return (
<Form {...form}>
<form onSubmit={handleSubmit}>
<FocusModal.Header>
<div className="flex items-center gap-x-2 justify-end">
<FocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("general.cancel")}
</Button>
</FocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isLoading}
>
{t("general.create")}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex flex-col items-center pt-[72px]">
<div className="w-full max-w-[720px] flex flex-col gap-y-8">
<div>
<Heading>{t("customerGroups.createCustomerGroup")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("customerGroups.createCustomerGroupHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</FocusModal.Body>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-customer-group-form"

View File

@@ -0,0 +1,15 @@
import { FocusModal } from "@medusajs/ui"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { CreateCustomerGroupForm } from "./components/create-customer-group-form"
export const CustomerGroupCreate = () => {
const [open, onOpenChange, subscribe] = useRouteModalState()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content>
<CreateCustomerGroupForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1 @@
export { CustomerGroupCreate as Component } from "./customer-group-create"

View File

@@ -0,0 +1,387 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { Customer, CustomerGroup } from "@medusajs/medusa"
import {
Button,
Checkbox,
CommandBar,
Container,
Heading,
StatusBadge,
Table,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminCustomerGroupCustomers,
useAdminRemoveCustomersFromCustomerGroup,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { TableRowActions } from "../../../../../components/common/table-row-actions"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
type CustomerGroupCustomerSectionProps = {
group: CustomerGroup
}
const PAGE_SIZE = 10
export const CustomerGroupCustomerSection = ({
group,
}: CustomerGroupCustomerSectionProps) => {
const navigate = useNavigate()
const { t } = useTranslation()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q"])
const { customers, count, isLoading, isError, error } =
useAdminCustomerGroupCustomers(
group.id,
{
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const table = useReactTable({
data: customers ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
getRowId: (row) => row.id,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
meta: {
customerGroupId: group.id,
},
})
const { mutateAsync } = useAdminRemoveCustomersFromCustomerGroup(group.id)
const prompt = usePrompt()
const handleRemoveCustomers = async () => {
const selected = Object.keys(rowSelection).filter((k) => rowSelection[k])
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.removeCustomersWarning", {
count: selected.length,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(
{
customer_ids: selected.map((s) => ({ id: s })),
},
{
onSuccess: () => {
setRowSelection({})
},
}
)
}
const noRecords =
!isLoading &&
!customers?.length &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<Heading level="h2">{t("customers.domain")}</Heading>
<Link to={`/customer-groups/${group.id}/add-customers`}>
<Button variant="secondary" size="small">
{t("general.add")}
</Button>
</Link>
</div>
<div>
{noRecords ? (
<NoRecords />
) : (
<div className="divide-y">
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
</div>
</div>
<div>
{(customers?.length || 0) > 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap [&_td:first-of-type]:w-[1%] [&_td:first-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() =>
navigate(`/customers/${row.original.id}`)
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="border-b">
<NoResults />
</div>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleRemoveCustomers}
shortcut="r"
label={t("general.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</div>
</div>
)}
</div>
</Container>
)
}
const CustomerActions = ({
customer,
customerGroupId,
}: {
customer: Customer
customerGroupId: string
}) => {
const { t } = useTranslation()
const { mutateAsync } =
useAdminRemoveCustomersFromCustomerGroup(customerGroupId)
const prompt = usePrompt()
const handleRemove = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.removeCustomersWarning", {
count: 1,
}),
confirmText: t("general.continue"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync({
customer_ids: [{ id: customer.id }],
})
}
return (
<TableRowActions
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/customers/${customer.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.remove"),
onClick: handleRemove,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Customer>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => <span>{getValue()}</span>,
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const name = [row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(" ")
return name || "-"
},
}),
columnHelper.accessor("has_account", {
header: t("fields.account"),
cell: ({ getValue }) => {
const hasAccount = getValue()
return (
<StatusBadge color={hasAccount ? "green" : "blue"}>
{hasAccount ? t("customers.registered") : t("customers.guest")}
</StatusBadge>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { customerGroupId } = table.options.meta as {
customerGroupId: string
}
return (
<CustomerActions
customer={row.original}
customerGroupId={customerGroupId}
/>
)
},
}),
],
[t]
)
}

View File

@@ -0,0 +1 @@
export * from "./customer-group-customer-section"

View File

@@ -0,0 +1,56 @@
import { EllipsisHorizontal, PencilSquare, Trash } from "@medusajs/icons"
import type { CustomerGroup } from "@medusajs/medusa"
import { Container, DropdownMenu, Heading, IconButton } from "@medusajs/ui"
import { useAdminDeleteCustomerGroup } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
type CustomerGroupGeneralSectionProps = {
group: CustomerGroup
}
export const CustomerGroupGeneralSection = ({
group,
}: CustomerGroupGeneralSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
const handleDelete = async () => {
await mutateAsync(undefined, {
onSuccess: () => {
navigate("/customer-groups", { replace: true })
},
})
}
return (
<Container className="px-6 py-4 flex items-center justify-between">
<Heading>{group.name}</Heading>
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<Link to={`/customer-groups/${group.id}/edit`}>
<DropdownMenu.Item className="flex items-center gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
<span>{t("general.edit")}</span>
</DropdownMenu.Item>
</Link>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="flex items-center gap-x-2"
onClick={handleDelete}
>
<Trash className="text-ui-fg-subtle" />
<span>{t("general.delete")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./customer-group-general-section"

View File

@@ -0,0 +1,40 @@
import { useAdminCustomerGroup } from "medusa-react"
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { CustomerGroupCustomerSection } from "./components/customer-group-customer-section"
import { CustomerGroupGeneralSection } from "./components/customer-group-general-section"
import { customerGroupLoader } from "./loader"
export const CustomerGroupDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof customerGroupLoader>
>
const { id } = useParams()
const { customer_group, isLoading, isError, error } = useAdminCustomerGroup(
id!,
undefined,
{ initialData }
)
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !customer_group) {
if (error) {
throw error
}
throw json("An unknown error occurred", 500)
}
return (
<div className="flex flex-col gap-y-2">
<CustomerGroupGeneralSection group={customer_group} />
<CustomerGroupCustomerSection group={customer_group} />
<JsonViewSection data={customer_group} />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { CustomerGroupDetail as Component } from "./customer-group-detail"
export { customerGroupLoader as loader } from "./loader"

View File

@@ -0,0 +1,22 @@
import { AdminCustomerGroupsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const customerGroupDetailQuery = (id: string) => ({
queryKey: adminProductKeys.detail(id),
queryFn: async () => medusa.admin.customerGroups.retrieve(id),
})
export const customerGroupLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = customerGroupDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminCustomerGroupsRes>>(
query.queryKey
) ?? (await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,91 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CustomerGroup } from "@medusajs/medusa"
import { Button, Drawer, Input } from "@medusajs/ui"
import { useAdminUpdateCustomerGroup } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as z from "zod"
import { Form } from "../../../../../components/common/form"
type EditCustomerGroupFormProps = {
group: CustomerGroup
onSuccessfulSubmit: () => void
subscribe: (state: boolean) => void
}
const EditCustomerGroupSchema = z.object({
name: z.string().min(1),
})
export const EditCustomerGroupForm = ({
group,
onSuccessfulSubmit,
subscribe,
}: EditCustomerGroupFormProps) => {
const { t } = useTranslation()
const form = useForm<z.infer<typeof EditCustomerGroupSchema>>({
defaultValues: {
name: group.name || "",
},
resolver: zodResolver(EditCustomerGroupSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { mutateAsync, isLoading } = useAdminUpdateCustomerGroup(group.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(data, {
onSuccess: () => {
onSuccessfulSubmit()
},
})
})
return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex flex-col overflow-hidden flex-1"
>
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto flex-1 max-w-full">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} size="small" />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
</Button>
</div>
</Drawer.Footer>
</form>
</Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-customer-group-form"

View File

@@ -0,0 +1,42 @@
import { Drawer, Heading } from "@medusajs/ui"
import { useAdminCustomerGroup } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { EditCustomerGroupForm } from "./components/edit-customer-group-form"
export const CustomerGroupEdit = () => {
const [open, onOpenChange, subscribe] = useRouteModalState()
const { id } = useParams()
const { customer_group, isLoading, isError, error } = useAdminCustomerGroup(
id!
)
const { t } = useTranslation()
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("customerGroups.editCustomerGroup")}</Heading>
</Drawer.Header>
{!isLoading && customer_group && (
<EditCustomerGroupForm
subscribe={subscribe}
onSuccessfulSubmit={handleSuccessfulSubmit}
group={customer_group}
/>
)}
</Drawer.Content>
</Drawer>
)
}

View File

@@ -0,0 +1 @@
export { CustomerGroupEdit as Component } from "./customer-group-edit"

View File

@@ -0,0 +1,251 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { CustomerGroup } from "@medusajs/medusa"
import { Button, Container, Heading, Table, clx, usePrompt } from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminCustomerGroups,
useAdminDeleteCustomerGroup,
} from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import {
NoRecords,
NoResults,
} from "../../../../../components/common/empty-table-content"
import { TableRowActions } from "../../../../../components/common/table-row-actions"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
const PAGE_SIZE = 50
export const CustomerGroupListTable = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const params = useQueryParams(["q", "order"])
const { customer_groups, count, isLoading, isError, error } =
useAdminCustomerGroups({
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
})
const columns = useColumns()
const table = useReactTable({
data: customer_groups ?? [],
columns,
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
state: {
pagination,
rowSelection,
},
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
const noRecords =
!isLoading &&
!customer_groups?.length &&
!Object.values(params).filter(Boolean).length
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("customerGroups.domain")}</Heading>
<Link to="/customer-groups/create">
<Button size="small" variant="secondary">
{t("general.create")}
</Button>
</Link>
</div>
<div>
{noRecords ? (
<NoRecords
action={{
label: t("customerGroups.createGroup"),
to: "/customer-groups/create",
}}
/>
) : (
<div className="divide-y">
<div className="flex items-center px-6 py-2 justify-between">
<div className="flex items-center gap-x-2"></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy keys={["name", "created_at", "updated_at"]} />
</div>
</div>
<div>
{(customer_groups?.length || 0) > 0 ? (
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className=" [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
onClick={() =>
navigate(`/customer-groups/${row.original.id}`)
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<div className="border-b">
<NoResults />
</div>
)}
<LocalizedTablePagination
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
count={count ?? 0}
pageIndex={pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</div>
</div>
)}
</div>
</Container>
)
}
const CustomerGroupActions = ({ group }: { group: CustomerGroup }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteCustomerGroup(group.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("customerGroups.deleteCustomerGroupWarning", {
name: group.name,
}),
confirmText: t("general.delete"),
cancelText: t("general.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined)
}
return (
<TableRowActions
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("general.edit"),
to: `/customer-groups/${group.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("general.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<CustomerGroup>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <CustomerGroupActions group={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1 @@
export * from "./customer-group-list-table"

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom"
import { CustomerGroupListTable } from "./components/customer-group-list-table"
export const CustomerGroupsList = () => {
return (
<div className="flex flex-col gap-y-2">
<CustomerGroupListTable />
<Outlet />
</div>
)
}

View File

@@ -0,0 +1 @@
export { CustomerGroupsList as Component } from "./customer-group-list"

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomerGroupDetails = () => {
return (
<div>
<Container>
<Heading>Customers</Heading>
</Container>
</div>
);
};

View File

@@ -1 +0,0 @@
export { CustomerGroupDetails as Component } from "./details";

View File

@@ -1 +0,0 @@
export { CustomerGroupsList as Component } from "./list";

View File

@@ -1,11 +0,0 @@
import { Container, Heading } from "@medusajs/ui";
export const CustomerGroupsList = () => {
return (
<div>
<Container>
<Heading>Customer Groups</Heading>
</Container>
</div>
);
};

View File

@@ -182,30 +182,21 @@ const useColumns = () => {
return useMemo(
() => [
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => <span>{getValue()}</span>,
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const firstName = row.original.first_name
const lastName = row.original.last_name
const name = [row.original.first_name, row.original.last_name]
.filter(Boolean)
.join(" ")
let value = "-"
if (firstName && lastName) {
value = `${firstName} ${lastName}`
} else if (firstName) {
value = firstName
} else if (lastName) {
value = lastName
}
return <span>{value}</span>
return name || "-"
},
}),
columnHelper.accessor("email", {
header: t("fields.email"),
cell: ({ getValue }) => <span>{getValue()}</span>,
}),
columnHelper.accessor("has_account", {
header: t("fields.account"),
cell: ({ getValue }) => {