feat(admin-next) discounts list page (#6490)

This commit is contained in:
Frane Polić
2024-02-27 12:33:19 +01:00
committed by GitHub
parent a223566d96
commit 608c10383a
23 changed files with 683 additions and 10 deletions
@@ -0,0 +1,28 @@
import type { Discount } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
export const CodeCell = ({ discount }: DiscountCellProps) => {
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">
{discount.code}
</span>
</div>
)
}
export const CodeHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.description")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./code-cell"
@@ -0,0 +1,25 @@
import type { Discount } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
export const DescriptionCell = ({ discount }: DiscountCellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<span className="truncate">{discount.rule.description}</span>
</div>
)
}
export const DescriptionHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.description")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./description-cell"
@@ -0,0 +1 @@
export * from "./redemption-cell.tsx"
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
redemptions: number
}
export const RedemptionCell = ({ redemptions }: DiscountCellProps) => {
return (
<div className="flex h-full w-full items-center justify-end gap-x-3 text-right">
<span>{redemptions}</span>
</div>
)
}
export const RedemptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-end text-right">
<span className="truncate">{t("fields.totalRedemptions")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./status-cell"
@@ -0,0 +1,72 @@
import { Discount } from "@medusajs/medusa"
import { end, parse } from "iso8601-duration"
import { StatusCell as StatusCell_ } from "../../common/status-cell"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
enum PromotionStatus {
SCHEDULED = "SCHEDULED",
EXPIRED = "EXPIRED",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
const getDiscountStatus = (discount: Discount) => {
if (discount.is_disabled) {
return PromotionStatus.DISABLED
}
const date = new Date()
if (new Date(discount.starts_at) > date) {
return PromotionStatus.SCHEDULED
}
if (
(discount.ends_at && new Date(discount.ends_at) < date) ||
(discount.valid_duration &&
date >
end(parse(discount.valid_duration), new Date(discount.starts_at))) ||
discount.usage_count === discount.usage_limit
) {
return PromotionStatus.EXPIRED
}
return PromotionStatus.ACTIVE
}
export const StatusCell = ({ discount }: DiscountCellProps) => {
const { t } = useTranslation()
const [color, text] = {
[PromotionStatus.DISABLED]: [
"grey",
t("discounts.discountStatus.disabled"),
],
[PromotionStatus.ACTIVE]: ["green", t("discounts.discountStatus.active")],
[PromotionStatus.SCHEDULED]: [
"orange",
t("discounts.discountStatus.scheduled"),
],
[PromotionStatus.EXPIRED]: ["red", t("discounts.discountStatus.expired")],
}[getDiscountStatus(discount)] as [
"grey" | "orange" | "green" | "red",
string
]
return <StatusCell_ color={color}>{text}</StatusCell_>
}
export const StatusHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.status")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./value-cell.tsx"
@@ -0,0 +1,35 @@
import { DiscountRule } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
type DiscountCellProps = {
rule: DiscountRule
currencyCode: string
}
export const ValueCell = ({ currencyCode, rule }: DiscountCellProps) => {
const isFixed = rule.type === "fixed"
const isPercentage = rule.type === "percentage"
const isFreeShipping = rule.type === "free_shipping"
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
{isFreeShipping && <span>Free shipping</span>}
{isPercentage && <span className="">{rule.value}%</span>}
{isFixed && (
<MoneyAmountCell currencyCode={currencyCode} amount={rule.value} />
)}
</div>
)
}
export const ValueHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.value")}</span>
</div>
)
}
@@ -0,0 +1,65 @@
import { useMemo } from "react"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import type { Discount } from "@medusajs/medusa"
import {
CodeCell,
CodeHeader,
} from "../../../components/table/table-cells/discount/code-cell"
import {
DescriptionHeader,
DescriptionCell,
} from "../../../components/table/table-cells/discount/description-cell"
import {
ValueCell,
ValueHeader,
} from "../../../components/table/table-cells/discount/value-cell"
import {
RedemptionCell,
RedemptionHeader,
} from "../../../components/table/table-cells/discount/redemption-cell"
import {
StatusHeader,
StatusCell,
} from "../../../components/table/table-cells/discount/status-cell"
const columnHelper = createColumnHelper<Discount>()
export const useDiscountTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "discount",
header: () => <CodeHeader />,
cell: ({ row }) => <CodeCell discount={row.original} />,
}),
columnHelper.accessor("rule.description", {
header: () => <DescriptionHeader />,
cell: ({ row }) => <DescriptionCell discount={row.original} />,
}),
columnHelper.accessor("rule.value", {
header: () => <ValueHeader />,
cell: ({ row }) => (
<ValueCell
rule={row.original.rule}
currencyCode={row.original.regions[0]?.currency_code}
/>
),
}),
columnHelper.display({
id: "status",
header: () => <StatusHeader />,
cell: ({ row }) => <StatusCell discount={row.original} />,
}),
columnHelper.accessor("usage_count", {
header: () => <RedemptionHeader />,
cell: ({ row }) => (
<RedemptionCell redemptions={row.original.usage_count} />
),
}),
],
[]
) as ColumnDef<Discount>[]
}
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
export const useDiscountTableFilters = () => {
const { t } = useTranslation()
let filters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at" },
{ label: t("fields.updatedAt"), key: "updated_at" },
].map((f) => ({
key: f.key,
label: f.label,
type: "date",
}))
const isDisabledFilter: Filter = {
key: "is_disabled",
label: t("fields.disabled"),
type: "select",
options: [
{
label: t("fields.true"),
value: "true",
},
{
label: t("fields.false"),
value: "false",
},
],
}
const isDynamicFilter: Filter = {
key: "is_dynamic",
label: t("fields.type"),
type: "select",
options: [
{
label: t("fields.dynamic"),
value: "true",
},
{
label: t("fields.normal"),
value: "false",
},
],
}
filters = [...filters, isDisabledFilter, isDynamicFilter]
return filters
}
@@ -0,0 +1,45 @@
import { AdminGetDiscountsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UseDiscountTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useDiscountTableQuery = ({
prefix,
pageSize = 20,
}: UseDiscountTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"order",
"q",
"is_dynamic",
"is_disabled",
"created_at",
"updated_at",
],
prefix
)
const { offset, order, q, is_dynamic, is_disabled, created_at, updated_at } =
queryObject
const searchParams: AdminGetDiscountsParams = {
limit: pageSize,
is_disabled: is_disabled ? is_disabled === "true" : undefined,
is_dynamic: is_dynamic ? is_dynamic === "true" : undefined,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
offset: offset ? Number(offset) : 0,
order,
q,
}
return {
searchParams,
raw: queryObject,
}
}
@@ -0,0 +1,145 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Discount } from "@medusajs/medusa"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteDiscount, useAdminDiscounts } 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 { useDiscountTableFilters } from "../../../../../hooks/table/filters/use-discount-table-filters"
import { useDiscountTableColumns } from "../../../../../hooks/table/columns/use-discount-table-columns"
import { useDiscountTableQuery } from "../../../../../hooks/table/query/use-discount-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { discountsLoader } from "../../loader"
const PAGE_SIZE = 20
export const DiscountListTable = () => {
const { t } = useTranslation()
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof discountsLoader>>
>
const { searchParams, raw } = useDiscountTableQuery({ pageSize: PAGE_SIZE })
const { discounts, count, isLoading, isError, error } = useAdminDiscounts(
{
...searchParams,
},
{
initialData,
keepPreviousData: true,
}
)
const filters = useDiscountTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (discounts ?? []) as Discount[],
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("discounts.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={["code", "created_at", "updated_at"]}
/>
<Outlet />
</Container>
)
}
const DiscountActions = ({ discount }: { discount: Discount }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteDiscount(discount.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("discounts.deleteWarning", {
title: discount.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/discounts/${discount.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Discount>()
const useColumns = () => {
const base = useDiscountTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <DiscountActions discount={row.original} />
},
}),
],
[base]
)
}
@@ -0,0 +1 @@
export * from "./discount-list-table.tsx"
@@ -1 +1,2 @@
export { DiscountsList as Component } from "./list";
export { discountsLoader } from "./loader"
export { DiscountsList as Component } from "./list"
@@ -1,11 +1,18 @@
import { Container, Heading } from "@medusajs/ui";
import after from "medusa-admin:widgets/discount/list/after"
import before from "medusa-admin:widgets/discount/list/before"
import { DiscountListTable } from "./components/discount-list-table"
export const DiscountsList = () => {
return (
<div>
<Container>
<Heading>Discounts</Heading>
</Container>
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
<w.Component key={i} />
))}
<DiscountListTable />
{after.widgets.map((w, i) => (
<w.Component key={i} />
))}
</div>
);
};
)
}
@@ -0,0 +1,24 @@
import { QueryClient } from "@tanstack/react-query"
import { Response } from "@medusajs/medusa-js"
import { AdminDiscountsListRes } from "@medusajs/medusa"
import { adminDiscountKeys } from "medusa-react"
import { medusa, queryClient } from "../../../lib/medusa"
const discountsListQuery = () => ({
queryKey: adminDiscountKeys.list({ limit: 20, offset: 0 }),
queryFn: async () => medusa.admin.discounts.list({ limit: 20, offset: 0 }),
})
export const discountsLoader = (client: QueryClient) => {
return async () => {
const query = discountsListQuery()
return (
queryClient.getQueryData<Response<AdminDiscountsListRes>>(
query.queryKey
) ?? (await client.fetchQuery(query))
)
}
}