feat(medusa-react,medusa,types,dashboard): added empty state + table for promotions list page (#6827)
what: - adds empty state for promotions list page - lists all promotions with pagination <img width="1663" alt="Screenshot 2024-03-26 at 14 19 27" src="https://github.com/medusajs/medusa/assets/5105988/ed0d5c65-d003-40f5-b899-540970d892f5"> <img width="1664" alt="Screenshot 2024-03-27 at 20 46 17" src="https://github.com/medusajs/medusa/assets/5105988/4aa40f09-fe3f-4f34-af7a-f5c183254c76">
This commit is contained in:
@@ -135,8 +135,8 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
},
|
||||
{
|
||||
icon: <ReceiptPercent />,
|
||||
label: t("discounts.domain"),
|
||||
to: "/discounts",
|
||||
label: t("promotions.domain"),
|
||||
to: "/promotions",
|
||||
},
|
||||
{
|
||||
icon: <CurrencyDollar />,
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
type CellProps = {
|
||||
code: string
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export const CodeCell = ({ code }: CellProps) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
|
||||
{/* // TODO: border color inversion*/}
|
||||
<span className="bg-ui-tag-neutral-bg truncate rounded-md border border-neutral-200 p-1 text-xs">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeHeader = ({ text }: HeaderProps) => {
|
||||
return (
|
||||
<div className=" flex h-full w-full items-center ">
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./code-cell"
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./text-cell"
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
type CellProps = {
|
||||
text?: string | number
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export const TextCell = ({ text }: CellProps) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextHeader = ({ text }: HeaderProps) => {
|
||||
return (
|
||||
<div className=" flex h-full w-full items-center">
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./status-cell"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { PromotionDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import {
|
||||
getPromotionStatus,
|
||||
PromotionStatus,
|
||||
} from "../../../../../lib/promotions"
|
||||
import { StatusCell as StatusCell_ } from "../../common/status-cell"
|
||||
|
||||
type PromotionCellProps = {
|
||||
promotion: PromotionDTO
|
||||
}
|
||||
type StatusColors = "grey" | "orange" | "green" | "red"
|
||||
type StatusMap = Record<string, [StatusColors, string]>
|
||||
|
||||
export const StatusCell = ({ promotion }: PromotionCellProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const statusMap: StatusMap = {
|
||||
[PromotionStatus.DISABLED]: ["grey", t("statuses.disabled")],
|
||||
[PromotionStatus.ACTIVE]: ["green", t("statuses.active")],
|
||||
[PromotionStatus.SCHEDULED]: ["orange", t("statuses.scheduled")],
|
||||
[PromotionStatus.EXPIRED]: ["red", t("statuses.expired")],
|
||||
}
|
||||
|
||||
const [color, text] = statusMap[getPromotionStatus(promotion)]
|
||||
|
||||
return <StatusCell_ color={color}>{text}</StatusCell_>
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { PromotionDTO } from "@medusajs/types"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
CodeCell,
|
||||
CodeHeader,
|
||||
} from "../../../components/table/table-cells/common/code-cell"
|
||||
import {
|
||||
TextCell,
|
||||
TextHeader,
|
||||
} from "../../../components/table/table-cells/common/text-cell"
|
||||
import { StatusCell } from "../../../components/table/table-cells/promotion/status-cell"
|
||||
|
||||
const columnHelper = createColumnHelper<PromotionDTO>()
|
||||
|
||||
export const usePromotionTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "code",
|
||||
header: () => <CodeHeader text={t("fields.code")} />,
|
||||
cell: ({ row }) => <CodeCell code={row.original.code!} />,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
id: "campaign",
|
||||
header: () => <TextHeader text={t("promotions.fields.campaign")} />,
|
||||
cell: ({ row }) => <TextCell text={row.original.campaign?.name} />,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
id: "method",
|
||||
header: () => <TextHeader text={t("promotions.fields.method")} />,
|
||||
cell: ({ row }) => {
|
||||
const text = row.original.is_automatic
|
||||
? "Automatic"
|
||||
: "Promotion Code"
|
||||
|
||||
return <TextCell text={text} />
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
id: "status",
|
||||
header: () => <TextHeader text={t("fields.status")} />,
|
||||
cell: ({ row }) => <StatusCell promotion={row.original} />,
|
||||
}),
|
||||
],
|
||||
[]
|
||||
) as ColumnDef<PromotionDTO>[]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Filter } from "../../../components/table/data-table"
|
||||
|
||||
export const usePromotionTableFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
let filters: Filter[] = [
|
||||
{ label: t("fields.createdAt"), key: "created_at", type: "date" },
|
||||
{ label: t("fields.updatedAt"), key: "updated_at", type: "date" },
|
||||
]
|
||||
|
||||
return filters
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AdminGetPromotionsParams } from "@medusajs/medusa"
|
||||
import { useQueryParams } from "../../use-query-params"
|
||||
|
||||
type UsePromotionTableQueryProps = {
|
||||
prefix?: string
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const usePromotionTableQuery = ({
|
||||
prefix,
|
||||
pageSize = 20,
|
||||
}: UsePromotionTableQueryProps) => {
|
||||
const queryObject = useQueryParams(
|
||||
["offset", "q", "created_at", "updated_at"],
|
||||
prefix
|
||||
)
|
||||
|
||||
const { offset, q, created_at, updated_at } = queryObject
|
||||
|
||||
const searchParams: AdminGetPromotionsParams = {
|
||||
limit: pageSize,
|
||||
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
q,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw: queryObject,
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./auth"
|
||||
export * from "./promotion"
|
||||
export * from "./store"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
AdminGetPromotionsParams,
|
||||
AdminPromotionsListRes,
|
||||
} from "@medusajs/medusa"
|
||||
import { queryKeysFactory, useAdminCustomQuery } from "medusa-react"
|
||||
|
||||
const QUERY_KEY = "admin_promotions"
|
||||
export const adminPromotionKeys = queryKeysFactory<
|
||||
typeof QUERY_KEY,
|
||||
AdminGetPromotionsParams
|
||||
>(QUERY_KEY)
|
||||
|
||||
export const useV2Promotions = (
|
||||
query?: AdminGetPromotionsParams,
|
||||
options?: object
|
||||
) => {
|
||||
const { data, ...rest } = useAdminCustomQuery<
|
||||
AdminGetPromotionsParams,
|
||||
AdminPromotionsListRes
|
||||
>("/admin/promotions", adminPromotionKeys.list(query), query, options)
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { PromotionDTO } from "@medusajs/types"
|
||||
|
||||
export enum PromotionStatus {
|
||||
SCHEDULED = "SCHEDULED",
|
||||
EXPIRED = "EXPIRED",
|
||||
ACTIVE = "ACTIVE",
|
||||
DISABLED = "DISABLED",
|
||||
}
|
||||
|
||||
export const getPromotionStatus = (promotion: PromotionDTO) => {
|
||||
const date = new Date()
|
||||
const campaign = promotion.campaign
|
||||
|
||||
if (!campaign) {
|
||||
return PromotionStatus.ACTIVE
|
||||
}
|
||||
|
||||
if (new Date(campaign.starts_at!) > date) {
|
||||
return PromotionStatus.SCHEDULED
|
||||
}
|
||||
|
||||
const campaignBudget = campaign.budget
|
||||
const overBudget =
|
||||
campaignBudget && campaignBudget.used! > campaignBudget.limit!
|
||||
|
||||
if ((campaign.ends_at && new Date(campaign.ends_at) < date) || overBudget) {
|
||||
return PromotionStatus.EXPIRED
|
||||
}
|
||||
|
||||
return PromotionStatus.ACTIVE
|
||||
}
|
||||
@@ -77,6 +77,18 @@ export const v2Routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/promotions",
|
||||
handle: {
|
||||
crumb: () => "Promotions",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../v2-routes/promotions/promotion-list"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./promotion-list-table"
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { PromotionDTO } from "@medusajs/types"
|
||||
import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useAdminDeleteDiscount } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, Outlet, useLoaderData } from "react-router-dom"
|
||||
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
|
||||
import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters"
|
||||
import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useV2Promotions } from "../../../../../lib/api-v2"
|
||||
import { promotionsLoader } from "../../loader"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const PromotionListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
ReturnType<ReturnType<typeof promotionsLoader>>
|
||||
>
|
||||
|
||||
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
|
||||
const { promotions, count, isLoading, isError, error } = useV2Promotions(
|
||||
{ ...searchParams },
|
||||
{
|
||||
initialData,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const filters = usePromotionTableFilters()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: (promotions ?? []) as PromotionDTO[],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("promotions.domain")}</Heading>
|
||||
|
||||
<Button size="small" variant="secondary" asChild>
|
||||
<Link to="create">{t("actions.create")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
pageSize={PAGE_SIZE}
|
||||
filters={filters}
|
||||
search
|
||||
pagination
|
||||
isLoading={isLoading}
|
||||
queryObject={raw}
|
||||
navigateTo={(row) => `${row.original.id}`}
|
||||
orderBy={["created_at", "updated_at"]}
|
||||
/>
|
||||
<Outlet />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
// TODO: change to promotions delete endpoint
|
||||
const { mutateAsync } = useAdminDeleteDiscount(promotion.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("promotions.deleteWarning", {
|
||||
code: promotion.code,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: handle error scenario here
|
||||
await mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `/promotions/${promotion.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PromotionDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = usePromotionTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...base,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
return <PromotionActions promotion={row.original} />
|
||||
},
|
||||
}),
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { promotionsLoader } from "./loader"
|
||||
export { PromotionsList as Component } from "./promotions-list.tsx"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AdminPromotionsListRes } from "@medusajs/medusa"
|
||||
import { Response } from "@medusajs/medusa-js"
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
import { adminPromotionKeys, useV2Promotions } from "../../../lib/api-v2"
|
||||
import { queryClient } from "../../../lib/medusa"
|
||||
|
||||
const promotionsListQuery = () => ({
|
||||
queryKey: adminPromotionKeys.list(),
|
||||
queryFn: async () => useV2Promotions({ limit: 20, offset: 0 }),
|
||||
})
|
||||
|
||||
export const promotionsLoader = (client: QueryClient) => {
|
||||
return async () => {
|
||||
const query = promotionsListQuery()
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<Response<AdminPromotionsListRes>>(
|
||||
query.queryKey
|
||||
) ?? (await client.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import after from "medusa-admin:widgets/promotion/list/after"
|
||||
import before from "medusa-admin:widgets/promotion/list/before"
|
||||
|
||||
import { PromotionListTable } from "./components/promotion-list-table"
|
||||
|
||||
export const PromotionsList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => (
|
||||
<w.Component key={i} />
|
||||
))}
|
||||
|
||||
<PromotionListTable />
|
||||
|
||||
{after.widgets.map((w, i) => (
|
||||
<w.Component key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user