feat(dashboard): manage discounts conditions (#6620)

**What**
- forms for managing discount conditions

---

https://github.com/medusajs/medusa/assets/16856471/bb0b3fe8-fa60-479b-bc42-6db9d422bcdf





Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
This commit is contained in:
Frane Polić
2024-03-15 11:56:23 +00:00
committed by GitHub
co-authored by Kasper Fabricius Kristensen
parent 9288f53327
commit a1b4aff127
61 changed files with 2090 additions and 152 deletions
@@ -309,7 +309,7 @@
"discounts": {
"domain": "Discounts",
"startDate": "Start date",
"createDiscountTitle": "Create discount",
"createDiscountTitle": "Create Discount",
"validDuration": "Duration of the discount",
"redemptionsLimit": "Redemptions limit",
"endDate": "End date",
@@ -320,8 +320,8 @@
"fixedAmount": "Fixed amount",
"validRegions": "Valid regions",
"deleteWarning": "You are about to delete the discount {{code}}. This action cannot be undone.",
"editDiscountDetails": "Edit discount details",
"editDiscountConfiguration": "Edit discount configuration",
"editDiscountDetails": "Edit Discount Details",
"editDiscountConfiguration": "Edit Discount Configuration",
"hasStartDate": "Discount has a start date",
"hasEndDate": "Discount has an expiry date",
"startDateHint": "Schedule the discount to activate in the future.",
@@ -329,38 +329,54 @@
"codeHint": "Discount code applies from when you hit the publish button and forever if left untouched.",
"hasUsageLimit": "Limit the number of redemptions?",
"usageLimitHint": "Limit applies across all customers, not per customer.",
"titleHint": "The code your customers will enter during checkout. This will appear on your customers invoice.\nUppercase letters and numbers only.",
"titleHint": "The code your customers will enter during checkout. This will appear on your customer's invoice.\nUppercase letters and numbers only.",
"hasDurationLimit": "Availability duration",
"durationHint": "Set the duration of the discount",
"chooseValidRegions": "Choose valid regions",
"noConditions": "No conditions are defined for this discount.",
"editConditions": "Edit conditions",
"conditionsHint": "Create conditions to apply on the discount",
"isTemplateDiscount": "Is this a template discount?",
"percentageDescription": "Discount applied in %",
"fixedDescription": "Amount discount",
"shippingDescription": "Override delivery amount",
"selectRegionFirst": "Select region first",
"selectRegionFirst": "Select a region first",
"templateHint": "Template discounts allow you to define a set of rules that can be used across a group of discounts. This is useful in campaigns that should generate unique codes for each user, but where the rules for all unique codes should be the same.",
"conditions": {
"editHeader": "Edit Discount Conditions",
"editHint": "Specify conditions for when the discount can be applied to a cart.",
"manageTypesAction": "Manage condition types",
"including": {
"products_one": "Discount applies to <0/> product.",
"products_other": "Discount applies to <0/> products.",
"customer_groups_one": "Discount applies to <0/> customer group.",
"customer_groups_other": "Discount applies to <0/> customer groups.",
"product_tags_one": "Discount applies to <0/> tag.",
"product_tags_other": "Discount applies to <0/> tags.",
"product_collections_one": "Discount applies to <0/> product collection.",
"product_collections_other": "Discount applies to <0/> product collections.",
"product_types_one": "Discount applies to <0/> product type.",
"product_types_other": "Discount applies to <0/> product types."
"products_one": "Discount applies to <0/> product",
"products_other": "Discount applies to <0/> products",
"customer_groups_one": "Discount applies to <0/> customer group",
"customer_groups_other": "Discount applies to <0/> customer groups",
"product_tags_one": "Discount applies to <0/> tag",
"product_tags_other": "Discount applies to <0/> tags",
"product_collections_one": "Discount applies to <0/> product collection",
"product_collections_other": "Discount applies to <0/> product collections",
"product_types_one": "Discount applies to <0/> product type",
"product_types_other": "Discount applies to <0/> product types"
},
"excluding": {
"products": "Discount applies to <1>all</1> products except <0/>.",
"customer_groups": "Discount applies to <1>all</1> customer groups except <0/>.",
"product_tags": "Discount applies to <1>all</1> product tags except <0/>.",
"product_collections": "Discount applies to <1>all</1> product collections except <0/>.",
"product_types": "Discount applies to <1>all</1> product types except <0/>."
"products": "Discount applies to <1>all</1> products except <0/>",
"customer_groups": "Discount applies to <1>all</1> customer groups except <0/>",
"product_tags": "Discount applies to <1>all</1> product tags except <0/>",
"product_collections": "Discount applies to <1>all</1> product collections except <0/>",
"product_types": "Discount applies to <1>all</1> product types except <0/>"
},
"edit": {
"appliesTo": "Discount applies to",
"except": {
"products_one": "product, except",
"products_other": "products, except",
"product_tags_one": "product tag, except",
"product_tags_other": "product tags, except",
"product_types_one": "product type, except",
"product_types_other": "product types, except",
"product_collections_one": "product collection, except",
"product_collections_other": "product collections, except",
"customer_groups_one": "customer group, except",
"customer_groups_other": "customer groups, except"
}
}
},
"discountStatus": {
@@ -692,8 +708,14 @@
"limit": "Limit",
"tags": "Tags",
"type": "Type",
"none": "none",
"all": "all",
"percentage": "Percentage",
"sales_channels": "Sales Channels",
"customer_groups": "Customer Groups",
"product_tags": "Product Tags",
"product_types": "Product Types",
"product_collections": "Product Collections",
"status": "Status",
"code": "Code",
"value": "Value",
@@ -1,4 +1,4 @@
import { Text, Tooltip, clx } from "@medusajs/ui"
import { Tooltip, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type ListSummaryProps = {
@@ -12,46 +12,54 @@ type ListSummaryProps = {
*/
list: string[]
/**
* Is hte summary displayed inline.
* Is the summary displayed inline.
* Determines whether the center text is truncated if there is no space in the container
*/
inline?: boolean
className?: string
}
export const ListSummary = ({ list, inline, n = 2 }: ListSummaryProps) => {
export const ListSummary = ({
list,
className,
inline,
n = 2,
}: ListSummaryProps) => {
const { t } = useTranslation()
const title = t("general.plusCountMore", {
count: list.length - n,
})
return (
<div
className={clx("text-ui-fg-subtle gap-x-2", {
"inline-flex": inline,
flex: !inline,
})}
className={clx(
"text-ui-fg-subtle txt-compact-small gap-x-1 overflow-hidden",
{
"inline-flex": inline,
flex: !inline,
},
className
)}
>
<Text as="span" leading="compact" size="small" className="truncate">
{list.slice(0, n).join(", ")}
</Text>
<div className="flex-1 truncate">
<span className="truncate">{list.slice(0, n).join(", ")}</span>
</div>
{list.length > n && (
<Tooltip
content={
<ul>
{list.slice(n).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
<Text
as="span"
size="small"
weight="plus"
leading="compact"
className="cursor-default whitespace-nowrap"
<div className="whitespace-nowrap">
<Tooltip
content={
<ul>
{list.slice(n).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
}
>
{t("general.plusCountMore", {
count: list.length - n,
})}
</Text>
</Tooltip>
<span className="cursor-default whitespace-nowrap">{title}</span>
</Tooltip>
</div>
)}
</div>
)
@@ -0,0 +1,41 @@
import { Tooltip } from "@medusajs/ui"
import format from "date-fns/format"
import { useTranslation } from "react-i18next"
type DateCellProps = {
date: Date
}
export const CreatedAtCell = ({ date }: DateCellProps) => {
const value = new Date(date)
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12
const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM"
return (
<div className="flex h-full w-full items-center overflow-hidden">
<Tooltip
className="z-10"
content={
<span className="text-pretty">{`${format(
value,
timestampFormat
)}`}</span>
}
>
<span className="truncate">{format(value, "dd MMM yyyy")}</span>
</Tooltip>
</div>
)
}
export const CreatedAtHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.createdAt")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./created-at-cell"
@@ -0,0 +1,34 @@
import { CustomerGroup } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { Text } from "@medusajs/ui"
import { NameHeader } from "../../../components/table/table-cells/common/name-cell"
import {
CreatedAtHeader,
CreatedAtCell,
} from "../../../components/table/table-cells/common/created-at-cell"
const columnHelper = createColumnHelper<CustomerGroup>()
export const useCustomerGroupTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "name",
header: () => <NameHeader />,
cell: ({
row: {
original: { name },
},
}) => <Text size="small">{name}</Text>,
}),
columnHelper.accessor("created_at", {
header: () => <CreatedAtHeader />,
cell: ({ getValue }) => <CreatedAtCell date={getValue()} />,
}),
],
[]
)
}
@@ -0,0 +1,34 @@
import { AdminGetCustomerGroupsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UseCustomerGroupTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useCustomerGroupTableQuery = ({
prefix,
pageSize = 20,
}: UseCustomerGroupTableQueryProps) => {
const queryObject = useQueryParams(
["offset", "q", "has_account", "order", "created_at", "updated_at"],
prefix
)
const { offset, created_at, updated_at, q, order } = queryObject
const searchParams: AdminGetCustomerGroupsParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
order,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
q,
}
return {
searchParams,
raw: queryObject,
}
}
@@ -3,6 +3,7 @@ import type {
AdminCustomerGroupsRes,
AdminCustomersRes,
AdminDraftOrdersRes,
AdminDiscountsRes,
AdminGiftCardsRes,
AdminOrdersRes,
AdminProductsRes,
@@ -430,25 +431,36 @@ export const v1Routes: RouteObject[] = [
},
children: [
{
index: true,
lazy: () => import("../../routes/discounts/list"),
path: "",
lazy: () => import("../../routes/discounts/discount-list"),
},
{
path: "create",
lazy: () => import("../../routes/discounts/create"),
lazy: () => import("../../routes/discounts/discount-create"),
},
{
path: ":id",
lazy: () => import("../../routes/discounts/details"),
lazy: () => import("../../routes/discounts/discount-detail"),
handle: {
crumb: (data: AdminDiscountsRes) => data.discount.code,
},
children: [
{
path: "edit",
lazy: () => import("../../routes/discounts/edit-details"),
lazy: () =>
import("../../routes/discounts/discount-edit-details"),
},
{
path: "configuration",
lazy: () =>
import("../../routes/discounts/edit-configuration"),
import(
"../../routes/discounts/discount-edit-configuration"
),
},
{
path: "conditions",
lazy: () =>
import("../../routes/discounts/discount-edit-conditions"),
},
],
},
@@ -0,0 +1,664 @@
import {
CustomerGroup,
Product,
ProductCollection,
ProductTag,
ProductType,
} from "@medusajs/medusa"
import { Button } from "@medusajs/ui"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import {
useAdminCollections,
useAdminCustomerGroups,
useAdminProductTags,
useAdminProductTypes,
useAdminProducts,
} from "medusa-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { SplitView } from "../../../../../components/layout/split-view"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useProductConditionsTableColumns } from "../../hooks/columns/use-product-conditions-table-columns"
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
import { useCustomerGroupConditionsTableColumns } from "../../hooks/columns/use-customer-group-conditions-table-columns"
import { useCustomerGroupConditionsTableFilters } from "../../hooks/filters/use-customer-group-conditions-table-filters"
import { useProductTypeConditionsTableColumns } from "../../hooks/columns/use-product-type-conditions-table-columns"
import { useProductTypeConditionsTableFilters } from "../../hooks/filters/use-product-type-conditions-table-filters"
import { useProductTypeConditionsTableQuery } from "../../hooks/query/use-product-type-conditions-table-query"
import { useProductCollectionConditionsTableColumns } from "../../hooks/columns/use-product-collection-conditions-table-columns"
import { useProductCollectionConditionsTableFilters } from "../../hooks/filters/use-product-collection-conditions-table-filters"
import { useProductCollectionConditionsTableQuery } from "../../hooks/query/use-product-collection-conditions-table-query"
import { useProductTagConditionsTableColumns } from "../../hooks/columns/use-product-tag-conditions-table-columns"
import { useProductTagConditionsTableFilters } from "../../hooks/filters/use-product-tag-conditions-table-filters"
import { useProductTagConditionsTableQuery } from "../../hooks/query/use-product-tag-conditions-table-query"
import { ConditionEntities } from "../../constants"
import { ConditionsOption } from "../../types"
const PAGE_SIZE = 50
const PRODUCT_PREFIX = "product"
const PRODUCT_TYPE_PREFIX = "product_type"
const PRODUCT_COLLECTION_PREFIX = "product_collection"
const CUSTOMER_GROUP_PREFIX = "customer_group"
const PRODUCT_TAG_PREFIX = "customer_group"
type ConditionsProps = {
selected: ConditionsOption[]
onSave: (options: ConditionsOption[]) => void
}
const initRowState = (selected: ConditionsOption[] = []): RowSelectionState => {
return selected.reduce((acc, { value }) => {
acc[value] = true
return acc
}, {} as RowSelectionState)
}
const ConditionsFooter = ({ onSave }: { onSave: () => void }) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button" asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button size="small" type="button" onClick={onSave}>
{t("actions.save")}
</Button>
</div>
)
}
const ProductConditionsTable = ({ selected = [], onSave }: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_PREFIX,
})
const { products, count, isLoading, isError, error } = useAdminProducts(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const added = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (added.length) {
const addedProducts = (products?.filter((p) => added.includes(p.id!)) ??
[]) as Product[]
if (addedProducts.length > 0) {
const newConditions = addedProducts.map((p) => ({
label: p.title,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) => p.value in newState)
return Array.from(new Set([...filteredPrev, ...newConditions]))
})
}
setRowSelection(newState)
}
const removed = Object.keys(rowSelection).filter(
(k) => newState[k] !== rowSelection[k]
)
if (removed.length) {
setIntermediate((prev) => {
return prev.filter((p) => !removed.includes(p.value))
})
setRowSelection(newState)
}
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductConditionsTableColumns()
const filters = useProductTableFilters()
const { table } = useDataTable({
data: (products ?? []) as Product[],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: PRODUCT_PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["title", "created_at", "updated_at"]}
prefix={PRODUCT_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const CustomerGroupConditionsTable = ({
selected = [],
onSave,
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useCustomerGroupTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_PREFIX,
})
const { customer_groups, count, isLoading, isError, error } =
useAdminCustomerGroups(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const added = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (added.length) {
const addedGroups = (customer_groups?.filter((p) =>
added.includes(p.id!)
) ?? []) as CustomerGroup[]
if (addedGroups.length > 0) {
const newConditions = addedGroups.map((p) => ({
label: p.name,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) => p.value in newState)
return Array.from(new Set([...filteredPrev, ...newConditions]))
})
}
setRowSelection(newState)
}
const removed = Object.keys(rowSelection).filter(
(k) => newState[k] !== rowSelection[k]
)
if (removed.length) {
setIntermediate((prev) => {
return prev.filter((p) => !removed.includes(p.value))
})
setRowSelection(newState)
}
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useCustomerGroupConditionsTableColumns()
const filters = useCustomerGroupConditionsTableFilters()
const { table } = useDataTable({
data: (customer_groups ?? []) as CustomerGroup[],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: CUSTOMER_GROUP_PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["name", "created_at", "updated_at"]}
prefix={CUSTOMER_GROUP_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductTypeConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useProductTypeConditionsTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_TYPE_PREFIX,
})
const { product_types, count, isLoading, isError, error } =
useAdminProductTypes(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const added = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (added.length) {
const addedTypes = (product_types?.filter((p) => added.includes(p.id!)) ??
[]) as ProductType[]
if (addedTypes.length > 0) {
const newConditions = addedTypes.map((p) => ({
label: p.value,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) => p.value in newState)
return Array.from(new Set([...filteredPrev, ...newConditions]))
})
}
setRowSelection(newState)
}
const removed = Object.keys(rowSelection).filter(
(k) => newState[k] !== rowSelection[k]
)
if (removed.length) {
setIntermediate((prev) => {
return prev.filter((p) => !removed.includes(p.value))
})
setRowSelection(newState)
}
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductTypeConditionsTableColumns()
const filters = useProductTypeConditionsTableFilters()
const { table } = useDataTable({
data: product_types ?? [],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: PRODUCT_TYPE_PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["value", "created_at", "updated_at"]}
prefix={PRODUCT_TYPE_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductCollectionConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useProductCollectionConditionsTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_COLLECTION_PREFIX,
})
const { collections, count, isLoading, isError, error } = useAdminCollections(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const added = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (added.length) {
const addedCollections = (collections?.filter((p) =>
added.includes(p.id!)
) ?? []) as ProductCollection[]
if (addedCollections.length > 0) {
const newConditions = addedCollections.map((p) => ({
label: p.title,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) => p.value in newState)
return Array.from(new Set([...filteredPrev, ...newConditions]))
})
}
setRowSelection(newState)
}
const removed = Object.keys(rowSelection).filter(
(k) => newState[k] !== rowSelection[k]
)
if (removed.length) {
setIntermediate((prev) => {
return prev.filter((p) => !removed.includes(p.value))
})
setRowSelection(newState)
}
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductCollectionConditionsTableColumns()
const filters = useProductCollectionConditionsTableFilters()
const { table } = useDataTable({
data: collections ?? [],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: PRODUCT_COLLECTION_PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["title", "handle", "created_at", "updated_at"]}
prefix={PRODUCT_COLLECTION_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductTagConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useProductTagConditionsTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_TAG_PREFIX,
})
const { product_tags, count, isLoading, isError, error } =
useAdminProductTags(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const added = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (added.length) {
const addedTags = (product_tags?.filter((p) => added.includes(p.id!)) ??
[]) as ProductTag[]
if (addedTags.length > 0) {
const newConditions = addedTags.map((p) => ({
label: p.value,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) => p.value in newState)
return Array.from(new Set([...filteredPrev, ...newConditions]))
})
}
setRowSelection(newState)
}
const removed = Object.keys(rowSelection).filter(
(k) => newState[k] !== rowSelection[k]
)
if (removed.length) {
setIntermediate((prev) => {
return prev.filter((p) => !removed.includes(p.value))
})
setRowSelection(newState)
}
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductTagConditionsTableColumns()
const filters = useProductTagConditionsTableFilters()
const { table } = useDataTable({
data: product_tags ?? [],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: PRODUCT_TAG_PREFIX,
})
if (isError) {
throw error
}
return (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["title", "handle", "created_at", "updated_at"]}
prefix={PRODUCT_TAG_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
type ConditionsTableProps = {
product: ConditionsProps
productType: ConditionsProps
productTag: ConditionsProps
productCollection: ConditionsProps
customerGroup: ConditionsProps
selected: ConditionEntities | null
}
export const ConditionsDrawer = ({
product,
productType,
customerGroup,
productCollection,
productTag,
selected,
}: ConditionsTableProps) => {
switch (selected) {
case ConditionEntities.PRODUCT:
return <ProductConditionsTable {...product} />
case ConditionEntities.PRODUCT_TYPE:
return <ProductTypeConditionsTable {...productType} />
case ConditionEntities.PRODUCT_COLLECTION:
return <ProductCollectionConditionsTable {...productCollection} />
case ConditionEntities.PRODUCT_TAG:
return <ProductTagConditionsTable {...productTag} />
case ConditionEntities.CUSTOMER_GROUP:
return <CustomerGroupConditionsTable {...customerGroup} />
default:
return null
}
}
@@ -0,0 +1 @@
export * from "./conditions-drawer"
@@ -0,0 +1,7 @@
export enum ConditionEntities {
PRODUCT = "products",
PRODUCT_TYPE = "product_types",
PRODUCT_COLLECTION = "product_collections",
PRODUCT_TAG = "product_tags",
CUSTOMER_GROUP = "customer_groups",
}
@@ -0,0 +1,46 @@
import { CustomerGroup } from "@medusajs/medusa"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns"
const columnHelper = createColumnHelper<CustomerGroup>()
export const useCustomerGroupConditionsTableColumns = () => {
const base = useCustomerGroupTableColumns()
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()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1,60 @@
import { useTranslation } from "react-i18next"
import { useMemo } from "react"
import { createColumnHelper } from "@tanstack/react-table"
import { ProductCollection } from "@medusajs/medusa"
import { Checkbox } from "@medusajs/ui"
const columnHelper = createColumnHelper<ProductCollection>()
export const useProductCollectionConditionsTableColumns = () => {
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("title", {
header: t("fields.title"),
}),
columnHelper.accessor("handle", {
header: t("fields.handle"),
cell: ({ getValue }) => `/${getValue()}`,
}),
columnHelper.accessor("products", {
header: t("fields.products"),
cell: ({ getValue }) => {
const count = getValue()?.length
return <span>{count || "-"}</span>
},
}),
],
[t]
)
}
@@ -0,0 +1,46 @@
import { Product } from "@medusajs/medusa"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
const columnHelper = createColumnHelper<Product>()
export const useProductConditionsTableColumns = () => {
const base = useProductTableColumns()
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()
}}
/>
)
},
}),
...base,
],
[base]
)
}
@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next"
import { useMemo } from "react"
import { createColumnHelper } from "@tanstack/react-table"
import { ProductTag } from "@medusajs/medusa"
import { Checkbox } from "@medusajs/ui"
const columnHelper = createColumnHelper<ProductTag>()
export const useProductTagConditionsTableColumns = () => {
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("value", {
header: t("fields.value"),
}),
],
[t]
)
}
@@ -0,0 +1,49 @@
import { ProductType } from "@medusajs/medusa"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
const columnHelper = createColumnHelper<ProductType>()
export const useProductTypeConditionsTableColumns = () => {
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("value", {
header: t("fields.value"),
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
}
@@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useCustomerGroupConditionsTableFilters = () => {
const { t } = useTranslation()
const 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",
}))
return filters
}
@@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useProductCollectionConditionsTableFilters = () => {
const { t } = useTranslation()
const 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",
}))
return filters
}
@@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useProductTagConditionsTableFilters = () => {
const { t } = useTranslation()
const 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",
}))
return filters
}
@@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useProductTypeConditionsTableFilters = () => {
const { t } = useTranslation()
const 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",
}))
return filters
}
@@ -0,0 +1,42 @@
import { AdminGetCollectionsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useProductCollectionConditionsTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix: string
}) => {
const raw = useQueryParams(
[
"offset",
"q",
"order",
"title",
"handle",
"discount_condition_id",
"created_at",
"updated_at",
],
prefix
)
const searchParams: AdminGetCollectionsParams = {
limit: pageSize,
offset: raw.offset ? Number(raw.offset) : 0,
q: raw.q,
title: raw.title,
handle: raw.handle,
discount_condition_id: raw.discount_condition_id,
created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined,
updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined,
order: raw.order,
}
return {
searchParams,
raw,
}
}
@@ -0,0 +1,39 @@
import { AdminGetProductTagsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useProductTagConditionsTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix: string
}) => {
const raw = useQueryParams(
[
"offset",
"q",
"order",
"value",
"discount_condition_id",
"created_at",
"updated_at",
],
prefix
)
const searchParams: AdminGetProductTagsParams = {
limit: pageSize,
offset: raw.offset ? Number(raw.offset) : 0,
q: raw.q,
discount_condition_id: raw.discount_condition_id,
created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined,
updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined,
order: raw.order,
}
return {
searchParams,
raw,
}
}
@@ -0,0 +1,29 @@
import { AdminGetProductTypesParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useProductTypeConditionsTableQuery = ({
pageSize = 50,
prefix,
}: {
pageSize?: number
prefix: string
}) => {
const raw = useQueryParams(
["offset", "q", "order", "created_at", "updated_at"],
prefix
)
const searchParams: AdminGetProductTypesParams = {
limit: pageSize,
offset: raw.offset ? Number(raw.offset) : 0,
q: raw.q,
created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined,
updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined,
order: raw.order,
}
return {
searchParams,
raw,
}
}
@@ -0,0 +1,10 @@
import { ConditionEntities } from "./constants"
export type ConditionsOption = {
value: string
label: string
}
export type ConditionsState = {
[K in ConditionEntities]: boolean
}
@@ -1,23 +1,25 @@
import { useEffect, useMemo } from "react"
import {
Checkbox,
CurrencyInput,
DatePicker,
Heading,
Input,
RadioGroup,
Select,
Switch,
Text,
Textarea,
RadioGroup,
CurrencyInput,
} from "@medusajs/ui"
import { Trans, useTranslation } from "react-i18next"
import { useAdminRegions } from "medusa-react"
import { useEffect, useMemo } from "react"
import { Trans, useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { CreateDiscountFormReturn } from "./create-discount-form"
import { useWatch } from "react-hook-form"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import { CreateDiscountFormReturn } from "./create-discount-form"
import { DiscountRuleType } from "./types"
type CreateDiscountPropsProps = {
@@ -29,8 +31,14 @@ export const CreateDiscountDetails = ({ form }: CreateDiscountPropsProps) => {
const { regions } = useAdminRegions()
const watchType = form.watch("type")
const watchRegion = form.watch("regions")
const watchType = useWatch({
control: form.control,
name: "type",
})
const watchRegion = useWatch({
name: "regions",
control: form.control,
})
const isFixedDiscount = watchType === DiscountRuleType.FIXED
const isFreeShipping = watchType === DiscountRuleType.FREE_SHIPPING
@@ -43,10 +51,12 @@ export const CreateDiscountDetails = ({ form }: CreateDiscountPropsProps) => {
return regions.find((r) => r.id === watchRegion[0])
}, [regions, watchRegion])
const { setValue } = form
useEffect(() => {
form.setValue("regions", [])
form.setValue("value", undefined)
}, [watchType])
setValue("regions", [])
setValue("value", undefined)
}, [watchType, setValue])
return (
<div className="flex size-full flex-col items-center overflow-auto p-16">
@@ -197,9 +207,8 @@ export const CreateDiscountDetails = ({ form }: CreateDiscountPropsProps) => {
<Input key="placeholder" disabled />
)
) : (
<Input
<PercentageInput
key="amount"
type="number"
min={0}
max={100}
{...field}
@@ -268,7 +277,7 @@ export const CreateDiscountDetails = ({ form }: CreateDiscountPropsProps) => {
{t("discounts.isTemplateDiscount")}
</Form.Label>
</div>
{/*<Form.ErrorMessage />*/}
{/* <Form.ErrorMessage />*/}
</Form.Item>
)
}}
@@ -1,27 +1,64 @@
import type { Discount, DiscountCondition } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { Trans, useTranslation } from "react-i18next"
import { PencilSquare } from "@medusajs/icons"
import type {
CustomerGroup,
Discount,
DiscountCondition,
Product,
ProductCollection,
ProductTag,
ProductType,
} from "@medusajs/medusa"
import { Badge, Container, Heading } from "@medusajs/ui"
import { useMemo } from "react"
import { Trans, useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { ListSummary } from "../../../../../components/common/list-summary"
import { NoRecords } from "../../../../../components/common/empty-table-content"
import { ListSummary } from "../../../../../components/common/list-summary"
type ConditionTypeProps = {
condition: DiscountCondition
}
const N = 2
const N = 1
const hasTitle = (p: unknown): p is Product | ProductCollection => {
return (p as { title: string }).title !== undefined
}
const hasName = (p: unknown): p is CustomerGroup => {
return (p as { name: string }).name !== undefined
}
const hasValue = (p: unknown): p is ProductTag | ProductType => {
return (p as { value: string }).value !== undefined
}
function ConditionType({ condition }: ConditionTypeProps) {
const { t } = useTranslation()
const operator = condition.operator === "in" ? "including" : "excluding"
const entity = condition.type
const list = condition[entity].map((p) => {
if (hasTitle(p)) {
return p.title
}
if (hasName(p)) {
return p.name
}
if (hasValue(p)) {
return p.value
}
}) as string[]
return (
<div className="bg-ui-bg-subtle shadow-borders-base flex flex-row justify-around rounded-md p-2">
<span className="text-ui-fg-subtle txt-small">
<div className="bg-ui-bg-subtle shadow-borders-base flex justify-around rounded-md p-2">
<span className="text-ui-fg-subtle txt-compact-xsmall whitespace-nowrap">
<Trans
t={t}
count={
condition[entity].length > N
? N - condition[entity].length
@@ -29,22 +66,19 @@ function ConditionType({ condition }: ConditionTypeProps) {
}
i18nKey={`discounts.conditions.${operator}.${entity}`}
components={[
<span
<Badge
size="2xsmall"
key="discounts-incl"
className="bg-ui-tag-neutral-bg mx-1 rounded-md border p-1"
className="mx-1 max-w-[120px]"
>
<ListSummary
inline
n={N}
list={condition[entity].map(
(p) => p.title || p.name || p.value
)}
list={list}
className="!txt-compact-xsmall-plus"
/>
</span>,
<span
key="discounts-excl"
className="bg-ui-tag-neutral-bg mx-1 rounded-md border p-1"
/>,
</Badge>,
<Badge size="2xsmall" key="discounts-excl" className="mx-1" />,
]}
/>
</span>
@@ -88,12 +122,7 @@ export const DiscountConditionsSection = ({
/>
</div>
<div className="text-ui-fg-subtle flex flex-col gap-2 px-6 pb-4 pt-2">
{!conditions.length && (
<NoRecords
className="h-[180px]"
action={{ label: t("discounts.editConditions"), to: "conditions" }}
/>
)}
{!conditions.length && <NoRecords className="h-[180px]" />}
{conditions.map((condition) => (
<ConditionType key={condition.id} condition={condition} />
))}
@@ -1,6 +1,6 @@
import { parse } from "iso8601-duration"
import { format, formatDuration } from "date-fns"
import { PencilSquare } from "@medusajs/icons"
import { format, formatDuration } from "date-fns"
import { parse } from "iso8601-duration"
import { useMemo } from "react"
import { Discount } from "@medusajs/medusa"
@@ -13,9 +13,9 @@ type DiscountConfigurationsSection = {
discount: Discount
}
function formatTime(dateTime?: string) {
function formatTime(dateTime?: string | Date | null) {
if (!dateTime) {
return
return "-"
}
return format(new Date(dateTime), "dd MMM, yyyy, HH:mm:ss")
}
@@ -57,7 +57,7 @@ export const DiscountConfigurationSection = ({
{t("discounts.startDate")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{formatTime(discount.starts_at as unknown as string)}
{formatTime(discount.starts_at)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
@@ -65,7 +65,7 @@ export const DiscountConfigurationSection = ({
{t("discounts.endDate")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{formatTime(discount.ends_at as unknown as string)}
{formatTime(discount.ends_at)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
@@ -60,7 +60,7 @@ export const DiscountGeneralSection = ({
[PromotionStatus.EXPIRED]: ["red", t("discounts.discountStatus.expired")],
}[getDiscountStatus(discount)] as [
"grey" | "orange" | "green" | "red",
string
string,
]
return (
@@ -101,7 +101,7 @@ export const DiscountGeneralSection = ({
{t("fields.description")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{discount.rule.description}
{discount.rule.description || "-"}
</Text>
</div>
</Container>
@@ -2,16 +2,16 @@ import { useAdminDiscount } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { DiscountGeneralSection } from "./components/discounts-general-section"
import { DiscountConfigurationSection } from "./components/discounts-configurations-section"
import { DiscountConfigurationSection } from "./components/discount-configurations-section"
import { DiscountGeneralSection } from "./components/discount-general-section"
import { discountLoader, expand } from "./loader"
import { RedemptionsSection } from "./components/redemptions-section"
import { DetailsSection } from "./components/details-section"
import { DiscountConditionsSection } from "./components/discounts-conditions-section"
import { DiscountConditionsSection } from "./components/discount-conditions-section"
import { RedemptionsSection } from "./components/discount-redemptions-section"
import { discountLoader, expand } from "./loader"
import before from "medusa-admin:widgets/discount/details/before"
import after from "medusa-admin:widgets/discount/details/after"
import before from "medusa-admin:widgets/discount/details/before"
export const DiscountDetail = () => {
const initialData = useLoaderData() as Awaited<
@@ -1,2 +1,2 @@
export { discountLoader as loader } from "./loader"
export { DiscountDetail as Component } from "./discount-detail.tsx"
export { discountLoader as loader } from "./loader.ts"
@@ -0,0 +1,583 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import {
Discount,
DiscountCondition,
DiscountConditionType,
} from "@medusajs/medusa"
import { Button, DropdownMenu, Heading, Text } from "@medusajs/ui"
import { adminDiscountKeys } from "medusa-react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { medusa, queryClient } from "../../../../../lib/medusa"
import { ListSummary } from "../../../../../components/common/list-summary"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { SplitView } from "../../../../../components/layout/split-view"
import { ConditionsDrawer } from "../../../common/components/conditions-drawer"
import { ConditionEntities } from "../../../common/constants"
import { ConditionsOption } from "../../../common/types"
enum DiscountConditionOperator {
IN = "in",
NOT_IN = "not_in",
}
type ConditionProps = {
labels: string[]
isInOperator: boolean
type: ConditionEntities
onClick: (op: DiscountConditionOperator) => void
}
type EditDiscountFormProps = {
discount: Discount
}
const N = 1
const getDefault = (conditionType: ConditionEntities, labelProp: string) => {
return (conditions: DiscountCondition[]) => {
const condition = conditions.find(
(c) => c.type === (conditionType as unknown as DiscountConditionType)
)
if (!condition || !condition[conditionType]) {
return []
}
return condition[conditionType].map((p) => ({
label: p[labelProp as keyof typeof p] as string,
value: p.id,
}))
}
}
const getOperator = (conditionType: ConditionEntities) => {
return (conditions: DiscountCondition[]) => {
return conditions.find((c) => c.type === (conditionType as any))?.operator
}
}
const SelectedConditionTypesSchema = zod.object({
[ConditionEntities.PRODUCT]: zod.boolean().optional(),
[ConditionEntities.PRODUCT_TAG]: zod.boolean().optional(),
[ConditionEntities.PRODUCT_TYPE]: zod.boolean().optional(),
[ConditionEntities.PRODUCT_COLLECTION]: zod.boolean().optional(),
[ConditionEntities.CUSTOMER_GROUP]: zod.boolean().optional(),
})
type SelectedContionTypeKey =
(typeof ConditionEntities)[keyof typeof ConditionEntities]
const EditDiscountConditionsSchema = zod.object({
products: zod.array(
zod.object({
label: zod.string(),
value: zod.string(),
})
),
product_types: zod.array(
zod.object({
label: zod.string(),
value: zod.string(),
})
),
customer_groups: zod.array(
zod.object({
label: zod.string(),
value: zod.string(),
})
),
product_collections: zod.array(
zod.object({
label: zod.string(),
value: zod.string(),
})
),
product_tags: zod.array(
zod.object({
label: zod.string(),
value: zod.string(),
})
),
products_operator: zod.nativeEnum(DiscountConditionOperator).optional(),
product_tags_operator: zod.nativeEnum(DiscountConditionOperator).optional(),
product_collections_operator: zod
.nativeEnum(DiscountConditionOperator)
.optional(),
product_types_operator: zod.nativeEnum(DiscountConditionOperator).optional(),
customer_groups_operator: zod
.nativeEnum(DiscountConditionOperator)
.optional(),
selected_condition_types: SelectedConditionTypesSchema,
})
export const EditDiscountConditionsForm = ({
discount,
}: EditDiscountFormProps) => {
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [conditionType, setConditionType] = useState<ConditionEntities | null>(
null
)
const [, setSearchParams] = useSearchParams()
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const conditions = discount.rule.conditions?.sort((c1, c2) =>
c1.type.localeCompare(c2.type)
) // TODO: memo
const form = useForm<zod.infer<typeof EditDiscountConditionsSchema>>({
defaultValues: {
products: getDefault(
ConditionEntities.PRODUCT,
"title"
)(discount.rule.conditions),
products_operator: getOperator(ConditionEntities.PRODUCT)(
discount.rule.conditions
),
product_types: getDefault(
ConditionEntities.PRODUCT_TYPE,
"value"
)(discount.rule.conditions),
product_types_operator: getOperator(ConditionEntities.PRODUCT_TYPE)(
discount.rule.conditions
),
product_collections: getDefault(
ConditionEntities.PRODUCT_COLLECTION,
"title"
)(discount.rule.conditions),
product_collections_operator: getOperator(
ConditionEntities.PRODUCT_COLLECTION
)(discount.rule.conditions),
product_tags: getDefault(
ConditionEntities.PRODUCT_TAG,
"value"
)(discount.rule.conditions),
product_tags_operator: getOperator(ConditionEntities.PRODUCT_TAG)(
discount.rule.conditions
),
customer_groups: getDefault(
ConditionEntities.CUSTOMER_GROUP,
"name"
)(discount.rule.conditions),
customer_groups_operator: getOperator(ConditionEntities.CUSTOMER_GROUP)(
discount.rule.conditions
),
selected_condition_types: {
[ConditionEntities.CUSTOMER_GROUP]: !!conditions.find(
(c) => c.type === (ConditionEntities.CUSTOMER_GROUP as any)
),
[ConditionEntities.PRODUCT_TYPE]: !!conditions.find(
(c) => c.type === (ConditionEntities.PRODUCT_TYPE as any)
),
[ConditionEntities.PRODUCT_TAG]: !!conditions.find(
(c) => c.type === (ConditionEntities.PRODUCT_TAG as any)
),
[ConditionEntities.PRODUCT_COLLECTION]: !!conditions.find(
(c) => c.type === (ConditionEntities.PRODUCT_COLLECTION as any)
),
[ConditionEntities.PRODUCT]: !!conditions.find(
(c) => c.type === (ConditionEntities.PRODUCT as any)
),
},
},
resolver: zodResolver(EditDiscountConditionsSchema),
})
const toggleSelectedConditionTypes = (type: ConditionEntities) => {
const state = { ...form.getValues().selected_condition_types }
if (state[type]) {
delete state[type]
} else {
state[type] = true
}
form.setValue("selected_condition_types", state, {
shouldDirty: true,
shouldTouch: true,
})
}
const clearAllSelectedConditions = () => {
form.setValue(
"selected_condition_types",
{
[ConditionEntities.PRODUCT]: false,
[ConditionEntities.PRODUCT_TAG]: false,
[ConditionEntities.PRODUCT_TYPE]: false,
[ConditionEntities.PRODUCT_COLLECTION]: false,
[ConditionEntities.CUSTOMER_GROUP]: false,
},
{
shouldDirty: true,
shouldTouch: true,
}
)
}
const selectedConditionTypes = useWatch({
name: "selected_condition_types",
control: form.control,
})
const selectedTypes = Object.keys(selectedConditionTypes)
.filter(
(k) => selectedConditionTypes[k as keyof typeof selectedConditionTypes]
)
.sort() as ConditionEntities[]
const selectedProducts = useWatch({
control: form.control,
name: "products",
})
const selectedProductTypes = useWatch({
control: form.control,
name: "product_types",
})
const selectedCustomerGroups = useWatch({
control: form.control,
name: "customer_groups",
})
const selectedProductCollections = useWatch({
control: form.control,
name: "product_collections",
})
const selectedProductTags = useWatch({
control: form.control,
name: "product_tags",
})
const handleSubmit = form.handleSubmit(async (data) => {
setIsLoading(true)
const handleCondition = async (selectedType: string) => {
if (
!selectedConditionTypes[
selectedType as keyof typeof selectedConditionTypes
]
) {
return
}
const currentCondition = conditions.find((c) => c.type === selectedType)
if (currentCondition) {
// delete condition first (in case operator changed since we cannot update that trough API)
await medusa.admin.discounts.deleteCondition(
discount.id,
currentCondition.id
)
}
const payload = data[selectedType as SelectedContionTypeKey]
if (payload?.length) {
await medusa.admin.discounts.createCondition(discount.id, {
operator: data[
`${selectedType}_operator` as keyof typeof data
] as DiscountConditionOperator,
[selectedType]: payload.map((d) => d.value),
})
}
}
const deleteUnselectedConditions = async () => {
const unselectedConditions = conditions.filter(
(c) => !selectedConditionTypes[c.type]
)
const deletePromises = unselectedConditions.map((c) =>
medusa.admin.discounts.deleteCondition(discount.id, c.id)
)
await Promise.all(deletePromises)
}
for (const selectedType of Object.keys(selectedConditionTypes)) {
await handleCondition(selectedType)
}
await deleteUnselectedConditions()
await queryClient.invalidateQueries(adminDiscountKeys.detail(discount.id))
setIsLoading(false)
handleSuccess()
})
const handleOpenDrawer = (
type: ConditionEntities,
operator: DiscountConditionOperator
) => {
form.setValue(`${type}_operator`, operator)
setConditionType(type)
setOpen(true)
}
const handleSaveConditions = (type: ConditionEntities) => {
return (options: ConditionsOption[]) => {
form.setValue(type, options, {
shouldDirty: true,
shouldTouch: true,
})
setOpen(false)
}
}
const handleOpenChange = (open: boolean) => {
if (!open) {
setConditionType(null)
setSearchParams(
{},
{
replace: true,
}
)
}
setOpen(open)
}
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-hidden">
<SplitView open={open} onOpenChange={handleOpenChange}>
<SplitView.Content>
<div className="flex size-full flex-col items-center px-16">
<div className="flex w-full max-w-[720px] flex-col gap-y-8 py-16">
<div>
<Heading>{t("discounts.conditions.editHeader")}</Heading>
<Text
size="small"
className="text-ui-fg-subtle text-pretty"
>
{t("discounts.conditions.editHint")}
</Text>
</div>
{selectedTypes.length > 0 && (
<div className="flex flex-col items-start gap-y-4">
{selectedTypes.map((t) => {
if (t in selectedConditionTypes) {
const field = form.getValues(t)
const operator = form.getValues(`${t}_operator`)
return field ? (
<Condition
key={t}
type={t}
labels={field.map((f) => f.label)}
isInOperator={operator === "in"}
onClick={(op) => handleOpenDrawer(t, op)}
/>
) : (
<Condition
key={t}
type={t}
isInOperator
labels={[]}
onClick={(op) => handleOpenDrawer(t, op)}
/>
)
}
})}
</div>
)}
<div className="flex items-center gap-x-2">
<DropdownMenu
open={isDropdownOpen}
onOpenChange={(v) => {
v && setIsDropdownOpen(v)
}}
>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
{t("discounts.conditions.manageTypesAction")}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
onInteractOutside={() => setIsDropdownOpen(false)}
>
{Object.values(ConditionEntities).map((type) => (
<DropdownMenu.CheckboxItem
key={type}
checked={selectedConditionTypes[type]}
onCheckedChange={() =>
toggleSelectedConditionTypes(type)
}
>
<Text
size="small"
weight={
selectedConditionTypes[type]
? "plus"
: "regular"
}
>
{t(`fields.${type}`)}
</Text>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu>
{selectedTypes.length > 0 && (
<Button
variant="transparent"
size="small"
type="button"
onClick={clearAllSelectedConditions}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
{t("actions.clearAll")}
</Button>
)}
</div>
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<ConditionsDrawer
product={{
selected: selectedProducts,
onSave: handleSaveConditions(ConditionEntities.PRODUCT),
}}
productType={{
selected: selectedProductTypes,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TYPE),
}}
productCollection={{
selected: selectedProductCollections,
onSave: handleSaveConditions(
ConditionEntities.PRODUCT_COLLECTION
),
}}
productTag={{
selected: selectedProductTags,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TAG),
}}
customerGroup={{
selected: selectedCustomerGroups,
onSave: handleSaveConditions(
ConditionEntities.CUSTOMER_GROUP
),
}}
selected={conditionType}
/>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
function Condition({ labels, isInOperator, type, onClick }: ConditionProps) {
const { t } = useTranslation()
const isInButtonDisabled = !!labels.length && !isInOperator
const isExButtonDisabled = !!labels.length && isInOperator
return (
<div className="text-center">
<div className="bg-ui-bg-field shadow-borders-base inline-flex items-center divide-x overflow-hidden rounded-md">
<Text
as="span"
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-muted shrink-0 px-2 py-1"
>
{t("discounts.conditions.edit.appliesTo")}
</Text>
<div className="text-ui-fg-subtle max-w-[240px] shrink-0">
<Button
type="button"
variant="transparent"
size="small"
disabled={isInButtonDisabled}
onClick={() => onClick("in" as DiscountConditionOperator)}
className="txt-compact-small-plus disabled:text-ui-fg-subtle rounded-none"
>
{isInOperator && labels.length ? (
<ListSummary
inline
n={N}
className="!txt-compact-small-plus max-w-[200px]"
list={labels}
/>
) : (
t(labels.length ? "fields.all" : "fields.none")
)}
</Button>
</div>
<Text
as="span"
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-muted shrink-0 px-2 py-1"
>
{t(`discounts.conditions.edit.except.${type}`, {
count: labels.length > 1 ? labels.length - 1 : labels.length,
})}
</Text>
<div className="text-ui-fg-subtle shrink-0 overflow-hidden">
<Button
type="button"
variant="transparent"
size="small"
disabled={isExButtonDisabled}
onClick={() => onClick("not_in" as DiscountConditionOperator)}
className="txt-compact-small-plus disabled:text-ui-fg-subtle max-w-[200px] rounded-none"
>
{!isInOperator && labels.length ? (
<ListSummary
inline
n={N}
className="!txt-compact-small-plus"
list={labels}
/>
) : (
t("fields.none")
)}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./edit-discount-conditions-form"
@@ -0,0 +1,26 @@
import { useAdminDiscount } from "medusa-react"
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal/index.ts"
import { expand } from "../discount-detail/loader.ts"
import { EditDiscountConditionsForm } from "./components/edit-discount-form/index.ts"
export const DiscountEditConditions = () => {
const { id } = useParams()
const { discount, isLoading, isError, error } = useAdminDiscount(id!, {
expand,
})
if (isError) {
throw error
}
return (
<RouteFocusModal>
{!isLoading && discount && (
<EditDiscountConditionsForm discount={discount} />
)}
</RouteFocusModal>
)
}
@@ -0,0 +1 @@
export { DiscountEditConditions as Component } from "./discount-edit-conditions"
@@ -1,13 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { formatISODuration } from "date-fns"
import { Duration, parse } from "iso8601-duration"
import { useMemo } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Trans, useTranslation } from "react-i18next"
import { parse, Duration } from "iso8601-duration"
import { formatISODuration } from "date-fns"
import * as zod from "zod"
import { Discount } from "@medusajs/medusa"
import { Button, Input, Text, Switch, DatePicker } from "@medusajs/ui"
import { Button, DatePicker, Input, Switch, Text } from "@medusajs/ui"
import { useAdminUpdateDiscount } from "medusa-react"
import { Form } from "../../../../../components/common/form"
@@ -29,15 +29,15 @@ const EditDiscountSchema = zod.object({
end_date: zod.date().nullish(),
enable_usage_limit: zod.boolean(),
usage_limit: zod.number().nullish(),
usage_limit: zod.union([zod.string(), zod.number()]).optional(),
enable_duration: zod.boolean(),
years: zod.number().optional(),
months: zod.number().optional(),
days: zod.number().optional(),
hours: zod.number().optional(),
minutes: zod.number().optional(),
years: zod.union([zod.string(), zod.number()]).optional(),
months: zod.union([zod.string(), zod.number()]).optional(),
days: zod.union([zod.string(), zod.number()]).optional(),
hours: zod.union([zod.string(), zod.number()]).optional(),
minutes: zod.union([zod.string(), zod.number()]).optional(),
})
export const EditDiscountConfigurationForm = ({
@@ -46,13 +46,11 @@ export const EditDiscountConfigurationForm = ({
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const duration = useMemo(
() =>
discount.valid_duration
? parse(discount.valid_duration)
: ({ years: 0, months: 0, days: 0, hours: 0, minutes: 0 } as Duration),
[discount]
)
const duration = useMemo(() => {
return discount.valid_duration
? parse(discount.valid_duration)
: ({ years: 0, months: 0, days: 0, hours: 0, minutes: 0 } as Duration)
}, [discount])
const form = useForm<zod.infer<typeof EditDiscountSchema>>({
defaultValues: {
@@ -60,37 +58,66 @@ export const EditDiscountConfigurationForm = ({
start_date: new Date(discount.starts_at),
enable_usage_limit: !!discount.usage_limit,
usage_limit: discount.usage_limit,
usage_limit: discount.usage_limit || "",
enable_duration: !!discount.valid_duration,
end_date_enabled: !!discount.ends_at,
end_date: discount.ends_at ? new Date(discount.ends_at) : null,
years: duration.years,
months: duration.months,
days: duration.days,
hours: duration.hours,
minutes: duration.minutes,
years: duration.years || "",
months: duration.months || "",
days: duration.days || "",
hours: duration.hours || "",
minutes: duration.minutes || "",
},
resolver: zodResolver(EditDiscountSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateDiscount(discount.id)
const formatDuration = (raw: Record<string, number | string>) => {
const isEmpty = Object.values(raw).every((v) => !v)
if (isEmpty) {
return null
}
const durationObject = Object.entries(raw).reduce((acc, [key, value]) => {
if (value) {
return {
...acc,
[key]: Number(value),
}
}
return acc
}, {} as Duration)
return formatISODuration(durationObject)
}
const handleSubmit = form.handleSubmit(async (data) => {
const duration = pick(data, ["years", "months", "days", "hours", "minutes"])
const isDurationEmpty = Object.values(duration).every((v) => !v)
const rawDuration = pick(data, [
"years",
"months",
"days",
"hours",
"minutes",
]) as Record<string, number | string>
const duration = formatDuration(rawDuration)
const usageLimit =
data.enable_usage_limit && !isNaN(Number(data.usage_limit))
? Number(data.usage_limit)
: null
await mutateAsync(
{
starts_at: data.start_date,
ends_at: data.end_date_enabled ? data.end_date : null,
usage_limit: data.enable_usage_limit ? data.usage_limit : null,
valid_duration:
data.enable_duration && !isDurationEmpty
? formatISODuration(duration)
: null,
usage_limit: usageLimit,
valid_duration: data.enable_duration && duration ? duration : null,
},
{
onSuccess: () => {
@@ -254,7 +281,7 @@ export const EditDiscountConfigurationForm = ({
<Form.Field
control={form.control}
name="usage_limit"
render={({ field }) => {
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Control>
@@ -262,13 +289,14 @@ export const EditDiscountConfigurationForm = ({
{...field}
type="number"
min={0}
value={value ?? ""}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
onChange(null)
} else {
field.onChange(Number(value))
onChange(Number(value))
}
}}
/>
@@ -1,29 +1,30 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Discount } from "@medusajs/medusa"
import {
CurrencyInput,
Button,
CurrencyInput,
Input,
Select,
Text,
Textarea,
Select,
} from "@medusajs/ui"
import { useAdminRegions, useAdminUpdateDiscount } from "medusa-react"
import { useForm } from "react-hook-form"
import { Trans, useTranslation } from "react-i18next"
import * as zod from "zod"
import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import { Combobox } from "../../../../../components/common/combobox"
type EditDiscountFormProps = {
discount: Discount
@@ -186,9 +187,8 @@ export const EditDiscountDetailsForm = ({
{...field}
/>
) : (
<Input
<PercentageInput
onChange={onChange}
type="number"
min={0}
max={100}
{...field}
@@ -0,0 +1,2 @@
export { DiscountsList as Component } from "./discount-list"
export { discountsLoader } from "./loader"
@@ -1,2 +0,0 @@
export { discountsLoader } from "./loader"
export { DiscountsList as Component } from "./list"
@@ -125,7 +125,7 @@ const DrawerFooter = ({
return (
<div
className={clx(
"border-ui-border-base flex items-center justify-end space-x-2 overflow-y-scroll border-t px-6 py-4",
"border-ui-border-base flex items-center justify-end space-x-2 overflow-y-auto border-t px-6 py-4",
className
)}
{...props}