feat(dashboard): Discounts details + edits (#6547)

**What**
- Discounts details page
- Edit discount details
- Edit discount configurations
- `ListSummary` component

**NOTE**
- conditions edit form will be implemented in a separate PR
- edit details from is missing metadata component which will be added later

---


https://github.com/medusajs/medusa/assets/16856471/c878af4a-48c2-4c45-b824-662784c7a139
This commit is contained in:
Frane Polić
2024-03-06 15:08:15 +01:00
committed by GitHub
parent a5a2395622
commit fcb03d60ea
43 changed files with 1783 additions and 131 deletions

View File

@@ -190,7 +190,7 @@
"partiallyRefunded": "Partially refunded",
"refunded": "Refunded",
"canceled": "Canceled",
"requresAction": "Requires action"
"requiresAction": "Requires action"
}
},
"reservations": {
@@ -213,7 +213,7 @@
"partiallyReturned": "Partially returned",
"returned": "Returned",
"canceled": "Canceled",
"requresAction": "Requires action"
"requiresAction": "Requires action"
},
"trackingLabel": "Tracking",
"shippingFromLabel": "Shipping from",
@@ -232,7 +232,51 @@
},
"discounts": {
"domain": "Discounts",
"deleteWarning": "You are about to delete the discount {{title}}. This action cannot be undone.",
"startDate": "Start date",
"validDuration": "Duration of the discount",
"redemptionsLimit": "Redemptions limit",
"endDate": "End date",
"percentageDiscount": "Percentage discount",
"freeShipping": "Free shipping",
"fixedDiscount": "Fixed discount",
"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",
"hasStartDate": "Discount has a start date",
"hasEndDate": "Discount has an expiry date",
"startDateHint": "Schedule the discount to activate in the future.",
"endDateHint": "Schedule the discount to deactivate in the future.",
"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.",
"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",
"conditions": {
"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."
},
"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/>."
}
},
"discountStatus": {
"scheduled": "Scheduled",
"expired": "Expired",
@@ -414,6 +458,7 @@
"invalidCredentials": "Wrong email or password"
},
"fields": {
"amount": "Amount",
"name": "Name",
"lastName": "Last Name",
"firstName": "First Name",
@@ -426,13 +471,17 @@
"newPassword": "New Password",
"repeatNewPassword": "Repeat New Password",
"categories": "Categories",
"configurations": "Configurations",
"conditions": "Conditions",
"category": "Category",
"collection": "Collection",
"discountable": "Discountable",
"handle": "Handle",
"subtitle": "Subtitle",
"limit": "Limit",
"tags": "Tags",
"type": "Type",
"percentage": "Percentage",
"sales_channels": "Sales Channels",
"status": "Status",
"code": "Code",
@@ -440,6 +489,11 @@
"disabled": "Disabled",
"dynamic": "Dynamic",
"normal": "Normal",
"years": "Years",
"months": "Months",
"days": "Days",
"hours": "Hours",
"minutes": "Minutes",
"totalRedemptions": "Total Redemptions",
"countries": "Countries",
"paymentProviders": "Payment Providers",
@@ -513,6 +567,23 @@
"minSubtotal": "Min. Subtotal",
"maxSubtotal": "Max. Subtotal",
"shippingProfile": "Shipping Profile",
"summary": "Summary"
"summary": "Summary",
"shippingProfile": "Shipping Profile"
},
"dateTime" : {
"years_one": "Year",
"years_other": "Years",
"months_one": "Month",
"months_other": "Months",
"weeks_one": "Week",
"weeks_other": "Weeks",
"days_one": "Day",
"days_other": "Days",
"hours_one": "Hour",
"hours_other": "Hours",
"minutes_one": "Minute",
"minutes_other": "Minutes",
"seconds_one": "Second",
"seconds_other": "Seconds"
}
}

View File

@@ -62,7 +62,7 @@ export const NoRecords = ({
<Text size="small" leading="compact" weight="plus">
{title ?? t("general.noRecordsTitle")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
<Text size="small" className="text-ui-fg-muted">
{message ?? t("general.noRecordsMessage")}
</Text>
</div>

View File

@@ -0,0 +1 @@
export { ListSummary } from "./list-summary"

View File

@@ -0,0 +1,58 @@
import { Text, Tooltip, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type ListSummaryProps = {
/**
* Number of initial items to display
* @default 2
*/
n?: number
/**
* List of strings to display as abbreviated list
*/
list: string[]
/**
* Is hte summary displayed inline.
* Determines whether the center text is truncated if there is no space in the container
*/
inline?: boolean
}
export const ListSummary = ({ list, inline, n = 2 }: ListSummaryProps) => {
const { t } = useTranslation()
return (
<div
className={clx("text-ui-fg-subtle gap-x-2", {
"inline-flex": inline,
flex: !inline,
})}
>
<Text as="span" leading="compact" size="small" className="truncate">
{list.slice(0, n).join(", ")}
</Text>
{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"
>
{t("general.plusCountMore", {
count: list.length - n,
})}
</Text>
</Tooltip>
)}
</div>
)
}

View File

@@ -1,44 +1,17 @@
import { Discount } from "@medusajs/medusa"
import { end, parse } from "iso8601-duration"
import { StatusCell as StatusCell_ } from "../../common/status-cell"
import { useTranslation } from "react-i18next"
import {
getDiscountStatus,
PromotionStatus,
} from "../../../../../lib/discounts.ts"
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()

View File

@@ -29,7 +29,7 @@ export const FulfillmentStatusCell = ({
],
returned: [t("orders.fulfillment.status.returned"), "green"],
canceled: [t("orders.fulfillment.status.canceled"), "red"],
requires_action: [t("orders.fulfillment.status.requresAction"), "orange"],
requires_action: [t("orders.fulfillment.status.requiresAction"), "orange"],
}[status] as [string, "red" | "orange" | "green"]
return <StatusCell color={color}>{label}</StatusCell>

View File

@@ -19,7 +19,7 @@ export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
"orange",
],
canceled: [t("orders.payment.status.canceled"), "red"],
requires_action: [t("orders.payment.status.requresAction"), "orange"],
requires_action: [t("orders.payment.status.requiresAction"), "orange"],
}[status] as [string, "red" | "orange" | "green"]
return <StatusCell color={color}>{label}</StatusCell>

View File

@@ -83,7 +83,7 @@ export const useOrderTableFilters = (): Filter[] => {
value: "canceled",
},
{
label: t("orders.payment.status.requresAction"),
label: t("orders.payment.status.requiresAction"),
value: "requires_action",
},
],
@@ -128,7 +128,7 @@ export const useOrderTableFilters = (): Filter[] => {
value: "canceled",
},
{
label: t("orders.fulfillment.status.requresAction"),
label: t("orders.fulfillment.status.requiresAction"),
value: "requires_action",
},
],

View File

@@ -0,0 +1,16 @@
/**
* Pick properties from an object and copy them to a new object
* @param obj
* @param keys
*/
export function pick(obj: Record<string, any>, keys: string[]) {
const ret: Record<string, any> = {}
keys.forEach((k) => {
if (k in obj) {
ret[k] = obj[k]
}
})
return ret
}

View File

@@ -728,3 +728,7 @@ export const currencies: Record<string, CurrencyInfo> = {
decimal_digits: 0,
},
}
export function getCurrencySymbol(code: string) {
return currencies[code.toUpperCase()].symbol_native
}

View File

@@ -0,0 +1,32 @@
import { Discount } from "@medusajs/medusa"
import { end, parse } from "iso8601-duration"
export enum PromotionStatus {
SCHEDULED = "SCHEDULED",
EXPIRED = "EXPIRED",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
export 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
}

View File

@@ -352,9 +352,24 @@ const router = createBrowserRouter([
index: true,
lazy: () => import("../../routes/discounts/list"),
},
{
path: "create",
lazy: () => import("../../routes/discounts/create"),
},
{
path: ":id",
lazy: () => import("../../routes/discounts/details"),
children: [
{
path: "edit",
lazy: () => import("../../routes/discounts/edit-details"),
},
{
path: "configuration",
lazy: () =>
import("../../routes/discounts/edit-configuration"),
},
],
},
],
},

View File

@@ -0,0 +1,73 @@
import { Heading, Input, Text } from "@medusajs/ui"
import { Trans, useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { CreateDiscountFormReturn } from "./create-discount-form.tsx"
type CreateDiscountPropsProps = {
form: CreateDiscountFormReturn
}
export const CreateDiscountDetails = ({ form }: CreateDiscountPropsProps) => {
const { t } = useTranslation()
return (
<div className="flex size-full flex-col items-center overflow-auto p-16">
<div className="flex w-full max-w-[736px] flex-col justify-center px-2 pb-2">
<div className="flex flex-col gap-y-1">
<Heading>{t("discount.createDiscountTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("discounts.createDiscountHint")}
</Text>
</div>
<div className="flex flex-col gap-y-8 divide-y [&>div]:pt-8">
<div id="general" className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-2 gap-x-4">
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.percentage")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
</div>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
i18nKey="discounts.titleHint"
t={t}
components={[<br key="break" />]}
/>
</Text>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
import * as zod from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { UseFormReturn, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Button } from "@medusajs/ui"
import { useAdminCreateDiscount } from "medusa-react"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { CreateDiscountDetails } from "./create-discount-details.tsx"
const CreateDiscountSchema = zod.object({
code: zod.string(),
})
type Schema = zod.infer<typeof CreateDiscountSchema>
export type CreateDiscountFormReturn = UseFormReturn<Schema>
export const CreateDiscountForm = () => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<Schema>({
defaultValues: {
code: "",
},
resolver: zodResolver(CreateDiscountSchema),
})
const { mutateAsync, isLoading } = useAdminCreateDiscount()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
code: values.code,
},
{
onSuccess: ({ discount }) => {
handleSuccess(`../${discount.id}`)
},
}
)
})
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<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 flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col items-center overflow-y-auto">
<div className="flex h-full w-full">
<CreateDiscountDetails form={form} />
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./create-discount-form.tsx"

View File

@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateDiscountForm } from "./components/create-discount-form"
export const DiscountCreate = () => {
return (
<RouteFocusModal>
<CreateDiscountForm />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1 @@
export { DiscountCreate as Component } from "./discount-create"

View File

@@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next"
import { PencilSquare } from "@medusajs/icons"
import type { Discount } from "@medusajs/medusa"
import { Container, Copy, Heading, Text } from "@medusajs/ui"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { ListSummary } from "../../../../../components/common/list-summary"
export const DetailsSection = ({ discount }: { discount: Discount }) => {
const { t } = useTranslation()
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("general.details")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "edit",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.code")}
</Text>
<div className="flex items-center gap-1">
<Text
size="small"
weight="plus"
leading="compact"
className="text-ui-fg-base text-pretty"
>
{discount.code}
</Text>
<Copy content={discount.code} variant="mini" />
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.type")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{discount.rule.type === "percentage"
? t("discounts.percentageDiscount")
: discount.rule.type === "free_shipping"
? t("discounts.freeShipping")
: t("discounts.fixedDiscount")}
</Text>
</div>
{discount.rule.type === "percentage" && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.value")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{discount.rule.value}
</Text>
</div>
)}
{discount.rule.type === "fixed" && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.amount")}
</Text>
<Text size="small" className="text-pretty">
<MoneyAmountCell
currencyCode={discount.regions[0]?.currency_code}
amount={discount.rule.value}
/>
</Text>
</div>
)}
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("discounts.validRegions")}
</Text>
<Text size="small" className="text-pretty">
<ListSummary list={discount.regions.map((r) => r.name)} />
</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./details-section.tsx"

View File

@@ -0,0 +1,103 @@
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 { useMemo } from "react"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { ListSummary } from "../../../../../components/common/list-summary"
import { NoRecords } from "../../../../../components/common/empty-table-content"
type ConditionTypeProps = {
condition: DiscountCondition
}
const N = 2
function ConditionType({ condition }: ConditionTypeProps) {
const operator = condition.operator === "in" ? "including" : "excluding"
const entity = condition.type
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">
<Trans
count={
condition[entity].length > N
? N - condition[entity].length
: condition[entity].length
}
i18nKey={`discounts.conditions.${operator}.${entity}`}
components={[
<span
key="discounts-incl"
className="bg-ui-tag-neutral-bg mx-1 rounded-md border p-1"
>
<ListSummary
inline
n={N}
list={condition[entity].map(
(p) => p.title || p.name || p.value
)}
/>
</span>,
<span
key="discounts-excl"
className="bg-ui-tag-neutral-bg mx-1 rounded-md border p-1"
/>,
]}
/>
</span>
</div>
)
}
type DiscountConditionsSectionProps = {
discount: Discount
}
export const DiscountConditionsSection = ({
discount,
}: DiscountConditionsSectionProps) => {
const { t } = useTranslation()
const conditions = useMemo(
() =>
discount.rule.conditions.sort((c1, c2) => c1.type.localeCompare(c2.type)),
[discount]
)
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>{t("fields.conditions")}</Heading>
</div>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `conditions`,
},
],
},
]}
/>
</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.map((condition) => (
<ConditionType key={condition.id} condition={condition} />
))}
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./discount-conditions-section"

View File

@@ -0,0 +1,89 @@
import { parse } from "iso8601-duration"
import { format, formatDuration } from "date-fns"
import { PencilSquare } from "@medusajs/icons"
import { useMemo } from "react"
import { Discount } from "@medusajs/medusa"
import { Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
type DiscountConfigurationsSection = {
discount: Discount
}
function formatTime(dateTime?: string) {
if (!dateTime) {
return
}
return format(new Date(dateTime), "dd MMM, yyyy, HH:mm:ss")
}
export const DiscountConfigurationSection = ({
discount,
}: DiscountConfigurationsSection) => {
const { t } = useTranslation()
const duration = useMemo(() => {
if (!discount.valid_duration) {
return "-"
}
return formatDuration(parse(discount.valid_duration))
}, [discount.valid_duration])
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>{t("fields.configurations")}</Heading>
</div>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `configuration`,
},
],
},
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("discounts.startDate")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{formatTime(discount.starts_at as unknown as string)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("discounts.endDate")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{formatTime(discount.ends_at as unknown as string)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("discounts.redemptionsLimit")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{discount.usage_limit || "-"}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("discounts.validDuration")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{duration}
</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./discount-configurations-section.tsx"

View File

@@ -0,0 +1,109 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { Discount } from "@medusajs/medusa"
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeleteDiscount } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
getDiscountStatus,
PromotionStatus,
} from "../../../../../lib/discounts"
type DiscountGeneralSectionProps = {
discount: Discount
}
export const DiscountGeneralSection = ({
discount,
}: DiscountGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const { mutateAsync } = useAdminDeleteDiscount(discount.id)
const handleDelete = async () => {
const confirm = await prompt({
title: t("general.areYouSure"),
description: t("discounts.deleteWarning", {
code: discount.code,
}),
verificationInstruction: t("general.typeToConfirm"),
verificationText: discount.code,
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!confirm) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
navigate("/discounts", { replace: true })
},
})
}
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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex flex-col">
<Heading>{discount.code}</Heading>
</div>
<div className="flex items-center gap-x-2">
<StatusBadge color={color}>{text}</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/discounts/${discount.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.description")}
</Text>
<Text size="small" leading="compact" className="text-pretty">
{discount.rule.description}
</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./discount-general-section.tsx"

View File

@@ -0,0 +1 @@
export * from "./redemptions-section.tsx"

View File

@@ -0,0 +1,40 @@
import { Gift } from "@medusajs/icons"
import { Container, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
export const RedemptionsSection = ({
redemptions,
}: {
redemptions: number
}) => {
const { t } = useTranslation()
return (
<Container className="flex flex-col gap-y-6 px-6 py-4">
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3">
<div className="bg-ui-bg-base shadow-borders-base flex size-7 items-center justify-center rounded-md">
<div className="bg-ui-bg-component flex size-6 items-center justify-center rounded-[4px]">
<Gift className="text-ui-fg-subtle" />
</div>
</div>
<Text
className="text-ui-fg-subtle"
weight="plus"
size="small"
leading="compact"
>
{t("fields.totalRedemptions")}
</Text>
</div>
<div className="border-ui-fg-muted border-l-2 pl-2">
<Text
className="text-ui-fg-base leading-[100%]"
weight="plus"
size="xlarge"
>
{redemptions}
</Text>
</div>
</Container>
)
}

View File

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

View File

@@ -0,0 +1,69 @@
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 { discountLoader, expand } from "./loader"
import { RedemptionsSection } from "./components/redemptions-section"
import { DetailsSection } from "./components/details-section"
import { DiscountConditionsSection } from "./components/discounts-conditions-section"
import before from "medusa-admin:widgets/discount/details/before"
import after from "medusa-admin:widgets/discount/details/after"
export const DiscountDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof discountLoader>
>
const { id } = useParams()
const { discount, isLoading } = useAdminDiscount(
id!,
{ expand },
{
initialData,
}
)
if (isLoading || !discount) {
return <div>Loading...</div>
}
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<div className="grid grid-cols-1 gap-x-4 lg:grid-cols-[1fr,400px]">
<div className="flex flex-col gap-y-2">
<DiscountGeneralSection discount={discount} />
<DiscountConfigurationSection discount={discount} />
<DiscountConditionsSection discount={discount} />
<div className="flex flex-col gap-y-2 lg:hidden">
<RedemptionsSection redemptions={discount.usage_count} />
<DetailsSection discount={discount} />
</div>
{after.widgets.map((w, i) => {
return (
<div key={i}>
<w.Component />
</div>
)
})}
<JsonViewSection data={discount} />
</div>
<div className="hidden flex-col gap-y-2 lg:flex">
<RedemptionsSection redemptions={discount.usage_count} />
<DetailsSection discount={discount} />
</div>
</div>
<Outlet />
</div>
)
}

View File

@@ -1 +1,2 @@
export { DiscountsDetails as Component } from "./details";
export { discountLoader as loader } from "./loader"
export { DiscountDetail as Component } from "./discount-detail.tsx"

View File

@@ -0,0 +1,32 @@
import { AdminDiscountsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminDiscountKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
export const expand =
"regions," +
"rule.conditions.products," +
"rule.conditions.product_types," +
"rule.conditions.product_tags," +
"rule.conditions.product_collections," +
"rule.conditions.customer_groups"
const discountDetailQuery = (id: string) => ({
queryKey: adminDiscountKeys.detail(id),
queryFn: async () =>
medusa.admin.discounts.retrieve(id, {
expand,
}),
})
export const discountLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = discountDetailQuery(id!)
return (
queryClient.getQueryData<Response<AdminDiscountsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,482 @@
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 { useAdminUpdateDiscount } from "medusa-react"
import { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { pick } from "../../../../../lib/common"
type EditDiscountFormProps = {
discount: Discount
}
const EditDiscountSchema = zod.object({
start_date_enabled: zod.boolean(),
start_date: zod.date(),
end_date_enabled: zod.boolean(),
end_date: zod.date().nullish(),
enable_usage_limit: zod.boolean(),
usage_limit: zod.number().nullish(),
enable_duration: zod.boolean(),
years: zod.number().optional(),
months: zod.number().optional(),
days: zod.number().optional(),
hours: zod.number().optional(),
minutes: zod.number().optional(),
})
export const EditDiscountConfigurationForm = ({
discount,
}: EditDiscountFormProps) => {
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 form = useForm<zod.infer<typeof EditDiscountSchema>>({
defaultValues: {
start_date_enabled: !!discount.starts_at,
start_date: new Date(discount.starts_at),
enable_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,
},
resolver: zodResolver(EditDiscountSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateDiscount(discount.id)
const handleSubmit = form.handleSubmit(async (data) => {
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
? formatISODuration(
pick(data, ["years", "months", "days", "hours", "minutes"])
)
: null,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="discounts.codeHint"
components={[<br key="break" />]}
/>
</Text>
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="start_date_enabled"
render={() => (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label tooltip="todo">
{t("discounts.hasStartDate")}
</Form.Label>
</div>
<Form.Hint className="!mt-1">
{t("discounts.startDateHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="start_date"
render={({
field: { value, onChange, ref: _ref, ...field },
}) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="end_date_enabled"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label tooltip="todo">
{t("discounts.hasEndDate")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("discounts.endDateHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="end_date"
render={({
field: { value, onChange, ref: _ref, ...field },
}) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Control>
<DatePicker
showTimePicker
value={value ?? undefined}
onChange={(v) => {
onChange(v ?? null)
}}
{...field}
/**
* TODO: FIX bug in the picker when a placeholder is provided it resets selected value to undefined
*/
// placeholder="DD/MM/YYYY HH:MM"
/*
* Disable input here. If set on Field it wont properly set the value.
*/
disabled={!form.watch("end_date_enabled")}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="enable_usage_limit"
render={({ field }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label tooltip="todo">
{t("discounts.hasUsageLimit")}
</Form.Label>
<Form.Control>
<Form.Control>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</Form.Control>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("discounts.usageLimitHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="usage_limit"
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_usage_limit")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="enable_duration"
render={({ field }) => {
return (
<Form.Item>
<div className="flex items-center justify-between">
<Form.Label tooltip="todo">
{t("discounts.hasDurationLimit")}
</Form.Label>
<Form.Control>
<Form.Control>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</Form.Control>
</Form.Control>
</div>
<Form.Hint className="!mt-1">
{t("discounts.durationHint")}
</Form.Hint>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex items-center justify-between gap-3">
<Form.Field
control={form.control}
name="years"
render={({ field }) => {
return (
<Form.Item className="flex-1">
<Form.Label>{t("fields.years")}</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_duration")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="months"
render={({ field }) => {
return (
<Form.Item className="flex-1">
<Form.Label>{t("fields.months")}</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_duration")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="days"
render={({ field }) => {
return (
<Form.Item className="flex-1">
<Form.Label>{t("fields.days")}</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_duration")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<div className="flex items-center gap-3">
<Form.Field
control={form.control}
name="hours"
render={({ field }) => {
return (
<Form.Item className="flex-1">
<Form.Label>{t("fields.hours")}</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_duration")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="minutes"
render={({ field }) => {
return (
<Form.Item className="flex-1">
<Form.Label>{t("fields.minutes")}</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={0}
disabled={!form.watch("enable_duration")}
onChange={(e) => {
const value = e.target.value
if (value === "") {
field.onChange(null)
} else {
console.log(Number(value))
field.onChange(Number(value))
}
}}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-discount-configuration-form"

View File

@@ -0,0 +1,29 @@
import { Heading } from "@medusajs/ui"
import { useAdminDiscount } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditDiscountConfigurationForm } from "./components/edit-discount-form"
export const DiscountEditConfiguration = () => {
const { id } = useParams()
const { t } = useTranslation()
const { discount, isLoading, isError, error } = useAdminDiscount(id!)
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("discounts.editDiscountConfiguration")}</Heading>
</RouteDrawer.Header>
{!isLoading && discount && (
<EditDiscountConfigurationForm discount={discount} />
)}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export { DiscountEditConfiguration as Component } from "./discount-edit-configuration"

View File

@@ -0,0 +1,237 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Discount } from "@medusajs/medusa"
import {
CurrencyInput,
Button,
Input,
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 { Form } from "../../../../../components/common/form"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers"
import { getCurrencySymbol } from "../../../../../lib/currencies"
import { Combobox } from "../../../../../components/common/combobox"
type EditDiscountFormProps = {
discount: Discount
}
const EditDiscountSchema = zod.object({
code: zod.string().min(1),
description: zod.string(),
value: zod.number(),
regions: zod.array(zod.string()),
})
export const EditDiscountDetailsForm = ({
discount,
}: EditDiscountFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { regions } = useAdminRegions()
const isFixedDiscount = discount.rule.type === "fixed"
const isFreeShipping = discount.rule.type === "free_shipping"
const form = useForm<zod.infer<typeof EditDiscountSchema>>({
defaultValues: {
code: discount.code,
description: discount.rule.description || "",
regions: discount.regions.map((r) => r.id),
value: isFixedDiscount
? getPresentationalAmount(
discount.rule.value,
discount.regions[0].currency_code
)
: discount.rule.value,
},
resolver: zodResolver(EditDiscountSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateDiscount(discount.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
code: data.code,
regions: data.regions,
rule: {
id: discount.rule.id,
description: data.description,
value: isFixedDiscount
? getDbAmount(data.value, discount.regions[0].currency_code)
: (data.value as number),
},
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteDrawer.Body>
<div className="flex h-full flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
<Trans
t={t}
i18nKey="discounts.titleHint"
components={[<br key="break" />]}
/>
</Text>
</div>
<Form.Field
control={form.control}
name="regions"
render={({ field: { onChange, value, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("discounts.chooseValidRegions")}</Form.Label>
<Form.Control>
{isFixedDiscount ? (
<Select
value={value[0]}
onValueChange={(v) => onChange([v])}
{...field}
>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{(regions || []).map((r) => (
<Select.Item key={r.id} value={r.id}>
{r.name}
</Select.Item>
))}
</Select.Content>
</Select>
) : (
<Combobox
options={(regions || []).map((r) => ({
label: r.name,
value: r.id,
}))}
value={value}
onChange={onChange}
{...field}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{!isFreeShipping && (
<Form.Field
control={form.control}
name="value"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{isFixedDiscount
? t("fields.amount")
: t("fields.percentage")}
</Form.Label>
<Form.Control>
{isFixedDiscount ? (
<CurrencyInput
min={0}
onValueChange={onChange}
code={discount.regions[0].currency_code}
symbol={getCurrencySymbol(
discount.regions[0].currency_code
)}
{...field}
/>
) : (
<Input
onChange={onChange}
type="number"
min={0}
max={100}
{...field}
/>
)}
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
<Form.Field
control={form.control}
name="description"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>{t("fields.description")}</Form.Label>
<Form.Control>
<Textarea {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}

View File

@@ -0,0 +1 @@
export * from "./edit-discount-details-form"

View File

@@ -0,0 +1,29 @@
import { Heading } from "@medusajs/ui"
import { useAdminDiscount } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditDiscountDetailsForm } from "./components/edit-discount-form"
export const DiscountEditDetails = () => {
const { id } = useParams()
const { t } = useTranslation()
const { discount, isLoading, isError, error } = useAdminDiscount(id!)
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("discounts.editDiscountDetails")}</Heading>
</RouteDrawer.Header>
{!isLoading && discount && (
<EditDiscountDetailsForm discount={discount} />
)}
</RouteDrawer>
)
}

View File

@@ -0,0 +1 @@
export { DiscountEditDetails as Component } from "./discount-edit-details"

View File

@@ -86,7 +86,7 @@ const DiscountActions = ({ discount }: { discount: Discount }) => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("discounts.deleteWarning", {
title: discount.code,
code: discount.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),

View File

@@ -83,7 +83,7 @@ export const ProductOrganizationSection = ({
{t("fields.categories")}
</Text>
<div className="flex flex-wrap items-center gap-1">
{product.categories.length > 0
{product.categories?.length > 0
? product.categories.map((pcat) => (
<Badge
key={pcat.id}

View File

@@ -4,6 +4,7 @@ import {
AdminDiscountsDeleteRes,
AdminDiscountsListRes,
AdminDiscountsRes,
AdminGetDiscountParams,
AdminGetDiscountsDiscountConditionsConditionParams,
AdminGetDiscountsParams,
AdminPostDiscountsDiscountConditions,
@@ -23,12 +24,12 @@ import BaseResource from "../base"
/**
* This class is used to send requests to [Admin Discount API Routes](https://docs.medusajs.com/api/admin#discounts). All its method
* are available in the JS Client under the `medusa.admin.discounts` property.
*
*
* All methods in this class require {@link AdminAuthResource.createSession | user authentication}.
*
*
* Admins can create discounts with conditions and rules, providing them with advanced settings for variety of cases.
* The methods in this class can be used to manage discounts, their conditions, resources, and more.
*
*
* Related Guide: [How to manage discounts](https://docs.medusajs.com/modules/discounts/admin/manage-discounts).
*/
class AdminDiscountsResource extends BaseResource {
@@ -38,7 +39,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} regionId - The ID of the region to add.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the discount's details.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -62,7 +63,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminPostDiscountsReq} payload - The discount to create.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the discount's details.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* import { AllocationType, DiscountRuleType } from "@medusajs/medusa"
@@ -97,7 +98,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminPostDiscountsDiscountReq} payload - The attributes to update in the discount.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -124,7 +125,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminPostDiscountsDiscountDynamicCodesReq} payload - The dynamic code to create.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -151,7 +152,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} id - The discount's ID.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsDeleteRes>} Resolves to the delete operation details.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -175,7 +176,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} code - The code of the dynamic code to delete.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -197,9 +198,10 @@ class AdminDiscountsResource extends BaseResource {
/**
* Retrieve a discount.
* @param {string} id - The discount's ID.
* @param {AdminGetDiscountParams} query - Configurations to apply on the retrieved product category.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -211,9 +213,16 @@ class AdminDiscountsResource extends BaseResource {
*/
retrieve(
id: string,
query?: AdminGetDiscountParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminDiscountsRes> {
const path = `/admin/discounts/${id}`
let path = `/admin/discounts/${id}`
if (query) {
const queryString = qs.stringify(query)
path = `${path}?${queryString}`
}
return this.client.request("GET", path, undefined, {}, customHeaders)
}
@@ -222,7 +231,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} code - The code of the discount.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -245,10 +254,10 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminGetDiscountsParams} query - Filters and pagination configurations to apply on the retrieved discounts.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsListRes>} Resolves to the list of discounts with pagination fields.
*
*
* @example
* To list discounts:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -258,9 +267,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discounts.id);
* })
* ```
*
*
* To specify relations that should be retrieved within the discounts:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -272,9 +281,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discounts.id);
* })
* ```
*
*
* By default, only the first `20` records are retrieved. You can control pagination by specifying the `limit` and `offset` properties:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -309,7 +318,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} regionId - The ID of the region to remove.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -329,17 +338,17 @@ class AdminDiscountsResource extends BaseResource {
}
/**
* Create a discount condition. Only one of `products`, `product_types`, `product_collections`, `product_tags`, and `customer_groups` should be provided in the `payload` parameter,
* Create a discount condition. Only one of `products`, `product_types`, `product_collections`, `product_tags`, and `customer_groups` should be provided in the `payload` parameter,
* based on the type of discount condition. For example, if the discount condition's type is `products`, the `products` field should be provided in the `payload` parameter.
* @param {string} discountId - The discount's ID.
* @param {AdminPostDiscountsDiscountConditions} payload - The discount condition to create.
* @param {AdminPostDiscountsDiscountConditionsParams} query - Configurations to apply on the returned discount.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* To create a condition in a discount:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* import { DiscountConditionOperator } from "@medusajs/medusa"
@@ -353,9 +362,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discount.id);
* })
* ```
*
*
* To specify relations that should be retrieved as part of the response:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* import { DiscountConditionOperator } from "@medusajs/medusa"
@@ -397,10 +406,10 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminPostDiscountsDiscountConditionsConditionParams} query - Configurations to apply on the returned discount.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* To update a condition in a discount:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -414,9 +423,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discount.id);
* })
* ```
*
*
* To specify relations that should be retrieved as part of the response:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -456,7 +465,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {string} conditionId - The ID of the discount condition.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsDeleteRes>} Resolves to the deletion operation details.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -482,10 +491,10 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminGetDiscountsDiscountConditionsConditionParams} query - Configurations to apply on the retrieved discount condition.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountConditionsRes>} Resolves to the discount condition details.
*
*
* @example
* A simple example that retrieves a discount condition by its ID:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -495,9 +504,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discount_condition.id);
* })
* ```
*
*
* To specify relations that should be retrieved:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -535,10 +544,10 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminPostDiscountsDiscountConditionsConditionBatchParams} query - Configurations to apply on the retrieved discount.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* To add resources to a discount condition:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -550,9 +559,9 @@ class AdminDiscountsResource extends BaseResource {
* console.log(discount.id);
* })
* ```
*
*
* To specify relations to include in the returned discount:
*
*
* ```ts
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
@@ -591,7 +600,7 @@ class AdminDiscountsResource extends BaseResource {
* @param {AdminDeleteDiscountsDiscountConditionsConditionBatchReq} payload - The resources to remove.
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminDiscountsRes>} Resolves to the details of the discount.
*
*
* @example
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })

View File

@@ -28,19 +28,19 @@ export const adminDiscountKeys = {
type DiscountQueryKeys = typeof adminDiscountKeys
/**
* This hook retrieves a list of Discounts. The discounts can be filtered by fields such as `rule` or `is_dynamic`.
* This hook retrieves a list of Discounts. The discounts can be filtered by fields such as `rule` or `is_dynamic`.
* The discounts can also be paginated.
*
*
* @example
* To list discounts:
*
*
* ```tsx
* import React from "react"
* import { useAdminDiscounts } from "medusa-react"
*
*
* const Discounts = () => {
* const { discounts, isLoading } = useAdminDiscounts()
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -57,21 +57,21 @@ type DiscountQueryKeys = typeof adminDiscountKeys
* </div>
* )
* }
*
*
* export default Discounts
* ```
*
*
* To specify relations that should be retrieved within the discounts:
*
*
* ```tsx
* import React from "react"
* import { useAdminDiscounts } from "medusa-react"
*
*
* const Discounts = () => {
* const { discounts, isLoading } = useAdminDiscounts({
* expand: "rule"
* })
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -88,19 +88,19 @@ type DiscountQueryKeys = typeof adminDiscountKeys
* </div>
* )
* }
*
*
* export default Discounts
* ```
*
*
* By default, only the first `20` records are retrieved. You can control pagination by specifying the `limit` and `offset` properties:
*
*
* ```tsx
* import React from "react"
* import { useAdminDiscounts } from "medusa-react"
*
*
* const Discounts = () => {
* const {
* discounts,
* const {
* discounts,
* limit,
* offset,
* isLoading
@@ -109,7 +109,7 @@ type DiscountQueryKeys = typeof adminDiscountKeys
* limit: 10,
* offset: 0
* })
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -126,10 +126,10 @@ type DiscountQueryKeys = typeof adminDiscountKeys
* </div>
* )
* }
*
*
* export default Discounts
* ```
*
*
* @customNamespace Hooks.Admin.Discounts
* @category Queries
*/
@@ -155,20 +155,20 @@ export const useAdminDiscounts = (
/**
* This hook retrieves a discount.
*
*
* @example
* import React from "react"
* import { useAdminDiscount } from "medusa-react"
*
*
* type Props = {
* discountId: string
* }
*
*
* const Discount = ({ discountId }: Props) => {
* const { discount, isLoading } = useAdminDiscount(
* discountId
* )
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -176,9 +176,9 @@ export const useAdminDiscounts = (
* </div>
* )
* }
*
*
* export default Discount
*
*
* @customNamespace Hooks.Admin.Discounts
* @category Queries
*/
@@ -209,20 +209,20 @@ export const useAdminDiscount = (
/**
* This hook adds a batch of resources to a discount condition. The type of resource depends on the type of discount condition. For example, if the discount condition's type is `products`,
* the resources being added should be products.
*
*
* @example
* import React from "react"
* import { useAdminGetDiscountByCode } from "medusa-react"
*
*
* type Props = {
* discountCode: string
* }
*
*
* const Discount = ({ discountCode }: Props) => {
* const { discount, isLoading } = useAdminGetDiscountByCode(
* discountCode
* )
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -230,9 +230,9 @@ export const useAdminDiscount = (
* </div>
* )
* }
*
*
* export default Discount
*
*
* @customNamespace Hooks.Admin.Discounts
* @category Queries
*/
@@ -258,28 +258,28 @@ export const useAdminGetDiscountByCode = (
/**
* This hook retries a Discount Condition's details.
*
*
* @example
* import React from "react"
* import { useAdminGetDiscountCondition } from "medusa-react"
*
*
* type Props = {
* discountId: string
* discountConditionId: string
* }
*
*
* const DiscountCondition = ({
* discountId,
* discountConditionId
* }: Props) => {
* const {
* discount_condition,
* const {
* discount_condition,
* isLoading
* } = useAdminGetDiscountCondition(
* discountId,
* discountConditionId
* )
*
*
* return (
* <div>
* {isLoading && <span>Loading...</span>}
@@ -289,9 +289,9 @@ export const useAdminGetDiscountByCode = (
* </div>
* )
* }
*
*
* export default DiscountCondition
*
*
* @customNamespace Hooks.Admin.Discounts
* @category Queries
*/