feat: Admin V2 Customers (#6998)

This commit is contained in:
Oli Juhl
2024-04-07 21:38:50 +02:00
committed by GitHub
parent f132929c7e
commit 4f88743591
53 changed files with 619 additions and 724 deletions
@@ -0,0 +1,53 @@
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { client } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import {
AdminCustomerGroupResponse,
AdminCustomerGroupListResponse,
} from "@medusajs/types"
const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const
const customerGroupsQueryKeys = queryKeysFactory(CUSTOMER_GROUPS_QUERY_KEY)
export const useCustomerGroup = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
AdminCustomerGroupResponse,
Error,
AdminCustomerGroupResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: customerGroupsQueryKeys.detail(id),
queryFn: async () => client.customerGroups.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}
export const useCustomerGroups = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
AdminCustomerGroupListResponse,
Error,
AdminCustomerGroupListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.customerGroups.list(query),
queryKey: customerGroupsQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
@@ -9,7 +9,10 @@ import { client } from "../../lib/client"
import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { CreateCustomerReq, UpdateCustomerReq } from "../../types/api-payloads"
import { CustomerListRes, CustomerRes } from "../../types/api-responses"
import {
AdminCustomerResponse,
AdminCustomerListResponse,
} from "@medusajs/types"
const CUSTOMERS_QUERY_KEY = "customers" as const
const customersQueryKeys = queryKeysFactory(CUSTOMERS_QUERY_KEY)
@@ -18,7 +21,12 @@ export const useCustomer = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<CustomerRes, Error, CustomerRes, QueryKey>,
UseQueryOptions<
AdminCustomerResponse,
Error,
AdminCustomerResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
@@ -34,7 +42,12 @@ export const useCustomer = (
export const useCustomers = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<CustomerListRes, Error, CustomerListRes, QueryKey>,
UseQueryOptions<
AdminCustomerListResponse,
Error,
AdminCustomerListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
@@ -48,7 +61,7 @@ export const useCustomers = (
}
export const useCreateCustomer = (
options?: UseMutationOptions<CustomerRes, Error, CreateCustomerReq>
options?: UseMutationOptions<AdminCustomerResponse, Error, CreateCustomerReq>
) => {
return useMutation({
mutationFn: (payload) => client.customers.create(payload),
@@ -62,7 +75,7 @@ export const useCreateCustomer = (
export const useUpdateCustomer = (
id: string,
options?: UseMutationOptions<CustomerRes, Error, UpdateCustomerReq>
options?: UseMutationOptions<AdminCustomerResponse, Error, UpdateCustomerReq>
) => {
return useMutation({
mutationFn: (payload) => client.customers.update(id, payload),
@@ -1,6 +1,6 @@
import { useAdminCustomerGroups } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
import { useCustomerGroups } from "../../api/customer-groups"
const excludeableFields = ["groups"] as const
@@ -11,7 +11,7 @@ export const useCustomerTableFilters = (
const isGroupsExcluded = exclude?.includes("groups")
const { customer_groups } = useAdminCustomerGroups(
const { customer_groups } = useCustomerGroups(
{
limit: 1000,
expand: "",
@@ -4,6 +4,7 @@ import { campaigns } from "./campaigns"
import { categories } from "./categories"
import { collections } from "./collections"
import { currencies } from "./currencies"
import { customerGroups } from "./customer-groups"
import { customers } from "./customers"
import { invites } from "./invites"
import { payments } from "./payments"
@@ -25,6 +26,7 @@ export const client = {
campaigns: campaigns,
categories: categories,
customers: customers,
customerGroups: customerGroups,
currencies: currencies,
collections: collections,
promotions: promotions,
@@ -0,0 +1,24 @@
import {
AdminCustomerGroupListResponse,
AdminCustomerGroupResponse,
} from "@medusajs/types"
import { getRequest } from "./common"
async function retrieveCustomerGroup(id: string, query?: Record<string, any>) {
return getRequest<AdminCustomerGroupResponse>(
`/admin/customer-groups/${id}`,
query
)
}
async function listCustomerGroups(query?: Record<string, any>) {
return getRequest<AdminCustomerGroupListResponse>(
`/admin/customer-groups`,
query
)
}
export const customerGroups = {
retrieve: retrieveCustomerGroup,
list: listCustomerGroups,
}
@@ -1,21 +1,24 @@
import {
AdminCustomerListResponse,
AdminCustomerResponse,
} from "@medusajs/types"
import { CreateCustomerReq, UpdateCustomerReq } from "../../types/api-payloads"
import { CustomerListRes, CustomerRes } from "../../types/api-responses"
import { getRequest, postRequest } from "./common"
async function retrieveCustomer(id: string, query?: Record<string, any>) {
return getRequest<CustomerRes>(`/admin/customers/${id}`, query)
return getRequest<AdminCustomerResponse>(`/admin/customers/${id}`, query)
}
async function listCustomers(query?: Record<string, any>) {
return getRequest<CustomerListRes>(`/admin/customers`, query)
return getRequest<AdminCustomerListResponse>(`/admin/customers`, query)
}
async function createCustomer(payload: CreateCustomerReq) {
return postRequest<CustomerRes>(`/admin/customers`, payload)
return postRequest<AdminCustomerResponse>(`/admin/customers`, payload)
}
async function updateCustomer(id: string, payload: UpdateCustomerReq) {
return postRequest<CustomerRes>(`/admin/customers/${id}`, payload)
return postRequest<AdminCustomerResponse>(`/admin/customers/${id}`, payload)
}
export const customers = {
@@ -235,85 +235,6 @@ export const v1Routes: RouteObject[] = [
},
],
},
{
path: "/customers",
handle: {
crumb: () => "Customers",
},
children: [
{
path: "",
lazy: () => import("../../routes/customers/customer-list"),
children: [
{
path: "create",
lazy: () =>
import("../../routes/customers/customer-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../routes/customers/customer-detail"),
handle: {
crumb: (data: AdminCustomersRes) => data.customer.email,
},
children: [
{
path: "edit",
lazy: () => import("../../routes/customers/customer-edit"),
},
],
},
],
},
{
path: "/customer-groups",
handle: {
crumb: () => "Customer Groups",
},
children: [
{
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/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"
),
},
],
},
],
},
{
path: "/gift-cards",
handle: {
@@ -1,8 +1,12 @@
import { SalesChannelDTO, UserDTO } from "@medusajs/types"
import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom"
import { Spinner } from "@medusajs/icons"
import { AdminCollectionsRes, AdminProductsRes } from "@medusajs/medusa"
import {
AdminCollectionsRes,
AdminProductsRes,
AdminPromotionRes,
AdminRegionsRes,
} from "@medusajs/medusa"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { MainLayout } from "../../components/layout-v2/main-layout"
import { SettingsLayout } from "../../components/layout/settings-layout"
@@ -10,6 +14,7 @@ import { useMe } from "../../hooks/api/users"
import { AdminApiKeyResponse } from "@medusajs/types"
import { SearchProvider } from "../search-provider"
import { SidebarProvider } from "../sidebar-provider"
import { AdminCustomersRes } from "@medusajs/client-types"
export const ProtectedRoute = () => {
const { user, isLoading } = useMe()
@@ -232,6 +237,39 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "/customers",
handle: {
crumb: () => "Customers",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/customers/customer-list"),
children: [
{
path: "create",
lazy: () =>
import("../../v2-routes/customers/customer-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../v2-routes/customers/customer-detail"),
handle: {
crumb: (data: AdminCustomersRes) => data.customer.email,
},
children: [
{
path: "edit",
lazy: () =>
import("../../v2-routes/customers/customer-edit"),
},
],
},
],
},
],
},
],
@@ -1,52 +0,0 @@
import { Customer, CustomerGroup } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminCustomerGroups } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
// TODO: Continue working on this when there is a natural way to get customer groups related to a customer.
type CustomerGroupSectionProps = {
customer: Customer
}
export const CustomerGroupSection = ({
customer,
}: CustomerGroupSectionProps) => {
const { customer_groups, isLoading, isError, error } = useAdminCustomerGroups(
{
id: customer.groups.map((g) => g.id).join(","),
}
)
if (isError) {
throw error
}
return (
<Container className="p-0 divide-y">
<div className="px-6 py-4">
<Heading level="h2">Groups</Heading>
</div>
</Container>
)
}
const columnHelper = createColumnHelper<CustomerGroup>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
}),
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
}
@@ -5,7 +5,6 @@
import {
CampaignDTO,
CurrencyDTO,
CustomerDTO,
InviteDTO,
PaymentProviderDTO,
ProductCategoryDTO,
@@ -39,10 +38,6 @@ type DeleteRes = {
// Auth
export type EmailPassRes = { token: string }
// Customers
export type CustomerRes = { customer: CustomerDTO }
export type CustomerListRes = { customers: CustomerDTO[] } & ListRes
// Promotions
export type PromotionRes = { promotion: PromotionDTO }
export type PromotionListRes = { promotions: PromotionDTO[] } & ListRes
@@ -1,53 +1,37 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminCreateCustomer } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { Form } from "../../../../../components/common/form"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCreateCustomer } from "../../../../../hooks/api/customers"
const CreateCustomerSchema = zod
.object({
email: zod.string().email(),
first_name: zod.string().min(1),
last_name: zod.string().min(1),
phone: zod.string().min(1).optional(),
password: zod.string().min(8),
password_confirmation: zod.string().min(8),
})
.superRefine(({ password, password_confirmation }, ctx) => {
if (password !== password_confirmation) {
return ctx.addIssue({
code: zod.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["password_confirmation"],
})
}
})
const CreateCustomerSchema = zod.object({
email: zod.string().email(),
first_name: zod.string().min(1),
last_name: zod.string().min(1),
phone: zod.string().min(1).optional(),
})
export const CreateCustomerForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { mutateAsync, isLoading } = useCreateCustomer()
const form = useForm<zod.infer<typeof CreateCustomerSchema>>({
defaultValues: {
email: "",
first_name: "",
last_name: "",
phone: "",
password: "",
password_confirmation: "",
},
resolver: zodResolver(CreateCustomerSchema),
})
const { mutateAsync, isLoading } = useAdminCreateCustomer()
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
@@ -55,7 +39,6 @@ export const CreateCustomerForm = () => {
first_name: data.first_name,
last_name: data.last_name,
phone: data.phone,
password: data.password,
},
{
onSuccess: ({ customer }) => {
@@ -164,46 +147,6 @@ export const CreateCustomerForm = () => {
{t("customers.passwordHint")}
</Text>
</div>
<div className="grid grid-cols-2 gap-4">
<Form.Field
control={form.control}
name="password"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.password")}</Form.Label>
<Form.Control>
<Input
autoComplete="off"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="password_confirmation"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.confirmPassword")}</Form.Label>
<Form.Control>
<Input
autoComplete="off"
type="password"
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</RouteFocusModal.Body>
@@ -1,11 +1,11 @@
import { PencilSquare } from "@medusajs/icons"
import { Customer } from "@medusajs/medusa"
import { Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { AdminCustomerResponse } from "@medusajs/types"
type CustomerGeneralSectionProps = {
customer: Customer
customer: AdminCustomerResponse["customer"]
}
export const CustomerGeneralSection = ({
@@ -0,0 +1,54 @@
// TODO: Should be added with Customer Groups UI
// import { Customer, CustomerGroup } from "@medusajs/medusa"
// import { Container, Heading } from "@medusajs/ui"
// import { createColumnHelper } from "@tanstack/react-table"
// import { useAdminCustomerGroups } from "medusa-react"
// import { useMemo } from "react"
// import { useTranslation } from "react-i18next"
// // TODO: Continue working on this when there is a natural way to get customer groups related to a customer.
// type CustomerGroupSectionProps = {
// customer: Customer
// }
// export const CustomerGroupSection = ({
// customer,
// }: CustomerGroupSectionProps) => {
// const { customer_groups, isLoading, isError, error } = useAdminCustomerGroups(
// {
// id: customer.groups.map((g) => g.id).join(","),
// }
// )
// if (isError) {
// throw error
// }
// return (
// <Container className="p-0 divide-y">
// <div className="px-6 py-4">
// <Heading level="h2">Groups</Heading>
// </div>
// </Container>
// )
// }
// const columnHelper = createColumnHelper<CustomerGroup>()
// const useColumns = () => {
// const { t } = useTranslation()
// return useMemo(
// () => [
// columnHelper.display({
// id: "select",
// }),
// columnHelper.accessor("name", {
// header: t("fields.name"),
// cell: ({ getValue }) => getValue(),
// }),
// ],
// [t]
// )
// }
@@ -1,9 +1,8 @@
import { useAdminCustomer } from "medusa-react"
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { CustomerGeneralSection } from "./components/customer-general-section"
import { CustomerOrderSection } from "./components/customer-order-section"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { customerLoader } from "./loader"
import { useCustomer } from "../../../hooks/api/customers"
export const CustomerDetail = () => {
const { id } = useParams()
@@ -11,7 +10,7 @@ export const CustomerDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof customerLoader>
>
const { customer, isLoading, isError, error } = useAdminCustomer(id!, {
const { customer, isLoading, isError, error } = useCustomer(id!, undefined, {
initialData,
})
@@ -30,7 +29,9 @@ export const CustomerDetail = () => {
return (
<div className="flex flex-col gap-y-2">
<CustomerGeneralSection customer={customer} />
<CustomerOrderSection customer={customer} />
{/* <CustomerOrderSection customer={customer} />
// TODO: re-add when order endpoints are added to api-v2
*/}
{/* <CustomerGroupSection customer={customer} /> */}
<JsonViewSection data={customer} />
<Outlet />
@@ -1,8 +1,7 @@
import { AdminCustomersRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { AdminCustomerResponse } from "@medusajs/types"
import { adminProductKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const customerDetailQuery = (id: string) => ({
@@ -15,7 +14,7 @@ export const customerLoader = async ({ params }: LoaderFunctionArgs) => {
const query = customerDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminCustomersRes>>(query.queryKey) ??
queryClient.getQueryData<Response<AdminCustomerResponse>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -1,19 +1,18 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Customer } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { useAdminUpdateCustomer } from "medusa-react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { Button, Input } from "@medusajs/ui"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { Form } from "../../../../../components/common/form"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { zodResolver } from "@hookform/resolvers/zod"
import { AdminCustomerResponse } from "@medusajs/types"
import { useUpdateCustomer } from "../../../../../hooks/api/customers"
type EditCustomerFormProps = {
customer: Customer
customer: AdminCustomerResponse["customer"]
}
const EditCustomerSchema = zod.object({
@@ -37,7 +36,7 @@ export const EditCustomerForm = ({ customer }: EditCustomerFormProps) => {
resolver: zodResolver(EditCustomerSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateCustomer(customer.id)
const { mutateAsync, isLoading } = useUpdateCustomer(customer.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
@@ -1,15 +1,15 @@
import { Heading } from "@medusajs/ui"
import { useAdminCustomer } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditCustomerForm } from "./components/edit-customer-form"
import { useCustomer } from "../../../hooks/api/customers"
export const CustomerEdit = () => {
const { t } = useTranslation()
const { id } = useParams()
const { customer, isLoading, isError, error } = useAdminCustomer(id!)
const { customer, isLoading, isError, error } = useCustomer(id!)
if (isError) {
throw error
@@ -1,18 +1,17 @@
import { PencilSquare } from "@medusajs/icons"
import { Customer } from "@medusajs/medusa"
import { Button, Container, Heading } from "@medusajs/ui"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useAdminCustomers } from "medusa-react"
import { AdminCustomerResponse } from "@medusajs/types"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useCustomerTableColumns } from "../../../../../hooks/table/columns/use-customer-table-columns"
import { useCustomerTableFilters } from "../../../../../hooks/table/filters/use-customer-table-filters"
import { useCustomerTableQuery } from "../../../../../hooks/table/query/use-customer-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCustomers } from "../../../../../hooks/api/customers"
const PAGE_SIZE = 20
@@ -20,14 +19,9 @@ export const CustomerListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useCustomerTableQuery({ pageSize: PAGE_SIZE })
const { customers, count, isLoading, isError, error } = useAdminCustomers(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const { customers, count, isLoading, isError, error } = useCustomers({
...searchParams,
})
const filters = useCustomerTableFilters()
const columns = useColumns()
@@ -78,7 +72,11 @@ export const CustomerListTable = () => {
)
}
const CustomerActions = ({ customer }: { customer: Customer }) => {
const CustomerActions = ({
customer,
}: {
customer: AdminCustomerResponse["customer"]
}) => {
const { t } = useTranslation()
return (
@@ -98,7 +96,7 @@ const CustomerActions = ({ customer }: { customer: Customer }) => {
)
}
const columnHelper = createColumnHelper<Customer>()
const columnHelper = createColumnHelper<AdminCustomerResponse["customer"]>()
const useColumns = () => {
const columns = useCustomerTableColumns()
@@ -112,5 +110,5 @@ const useColumns = () => {
}),
],
[columns]
) as ColumnDef<Customer>[]
) as ColumnDef<AdminCustomerResponse["customer"]>[]
}