feat(dashboard): Admin UI regions v2 (#6943)

This commit is contained in:
Frane Polić authored and GitHub committed 2024-04-06 17:41:54 +02:00
1 parent df0751f122
commit 58c68f6715
72 files changed
+456 -1668

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa, types): list payment providers endpoint
@@ -0,0 +1,33 @@
import { medusaIntegrationTestRunner } from "medusa-test-utils/dist"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
medusaIntegrationTestRunner({
env,
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Payment Providers", () => {
let appContainer
beforeAll(async () => {
appContainer = getContainer()
})
it("should list payment providers", async () => {
let response = await api.get(`/admin/payments/payment-providers`)
expect(response.status).toEqual(200)
expect(response.data.payment_providers).toEqual([
expect.objectContaining({
id: "pp_system_default_2",
}),
expect.objectContaining({
id: "pp_system_default",
}),
])
expect(response.data.count).toEqual(2)
})
})
},
})
@@ -817,7 +817,7 @@
"removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.", "removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.",
"removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.", "removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.",
"taxInclusiveHint": "When enabled prices in the region will be tax inclusive.", "taxInclusiveHint": "When enabled prices in the region will be tax inclusive.",
"providersHint": " Add which fulfillment and payment providers should be available in this region.", "providersHint": " Add which payment providers should be available in this region.",
"shippingOptions": "Shipping Options", "shippingOptions": "Shipping Options",
"deleteShippingOptionWarning": "You are about to delete the shipping option {{name}}. This action cannot be undone.", "deleteShippingOptionWarning": "You are about to delete the shipping option {{name}}. This action cannot be undone.",
"return": "Return", "return": "Return",
@@ -24,7 +24,7 @@ async function generateCountries() {
const dest = path.join(__dirname, "../src/lib/countries.ts") const dest = path.join(__dirname, "../src/lib/countries.ts")
const destDir = path.dirname(dest) const destDir = path.dirname(dest)
const fileContent = `/** This file is auto-generated. Do not modify it manually. */\nimport type { Country } from "@medusajs/medusa"\n\nexport const countries: Omit<Country, "region" | "region_id" | "id">[] = ${json}` const fileContent = `/** This file is auto-generated. Do not modify it manually. */\nimport type { RegionCountryDTO } from "@medusajs/types"\n\nexport const countries: Omit<RegionCountryDTO, "id">[] = ${json}`
if (!fs.existsSync(destDir)) { if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }) fs.mkdirSync(destDir, { recursive: true })
@@ -1,9 +1,12 @@
import { Country } from "@medusajs/medusa"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { RegionCountryDTO } from "@medusajs/types"
import { PlaceholderCell } from "../../common/placeholder-cell" import { PlaceholderCell } from "../../common/placeholder-cell"
import { ListSummary } from "../../../../common/list-summary"
import { countries as COUNTRIES } from "../../../../../lib/countries"
type CountriesCellProps = { type CountriesCellProps = {
countries?: Country[] | null countries?: RegionCountryDTO[] | null
} }
export const CountriesCell = ({ countries }: CountriesCellProps) => { export const CountriesCell = ({ countries }: CountriesCellProps) => {
@@ -13,26 +16,14 @@ export const CountriesCell = ({ countries }: CountriesCellProps) => {
return <PlaceholderCell /> return <PlaceholderCell />
} }
const displayValue = countries
.slice(0, 2)
.map((c) => c.display_name)
.join(", ")
const additionalCountries = countries
.slice(2)
.map((c) => c.display_name).length
const text = `${displayValue}${
additionalCountries > 0
? ` ${t("general.plusCountMore", {
count: additionalCountries,
})}`
: ""
}`
return ( return (
<div className="flex size-full items-center overflow-hidden"> <div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span> <ListSummary
list={countries.map(
(country) =>
COUNTRIES.find((c) => c.iso_2 === country.iso_2)!.display_name
)}
/>
</div> </div>
) )
} }
@@ -1,39 +1,26 @@
import { PaymentProvider } from "@medusajs/medusa"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { PaymentProviderDTO } from "@medusajs/types"
import { formatProvider } from "../../../../../lib/format-provider" import { formatProvider } from "../../../../../lib/format-provider"
import { PlaceholderCell } from "../../common/placeholder-cell" import { PlaceholderCell } from "../../common/placeholder-cell"
import { ListSummary } from "../../../../common/list-summary"
type PaymentProvidersCellProps = { type PaymentProvidersCellProps = {
paymentProviders?: PaymentProvider[] | null paymentProviders?: PaymentProviderDTO[] | null
} }
export const PaymentProvidersCell = ({ export const PaymentProvidersCell = ({
paymentProviders, paymentProviders,
}: PaymentProvidersCellProps) => { }: PaymentProvidersCellProps) => {
const { t } = useTranslation()
if (!paymentProviders || paymentProviders.length === 0) { if (!paymentProviders || paymentProviders.length === 0) {
return <PlaceholderCell /> return <PlaceholderCell />
} }
const displayValue = paymentProviders const displayValues = paymentProviders.map((p) => formatProvider(p.id))
.slice(0, 2)
.map((p) => formatProvider(p.id))
.join(", ")
const additionalProviders = paymentProviders.slice(2).length
const text = `${displayValue}${
additionalProviders > 0
? ` ${t("general.plusCountMore", {
count: additionalProviders,
})}`
: ""
}`
return ( return (
<div className="flex size-full items-center overflow-hidden"> <div className="flex size-full items-center overflow-hidden">
<span className="truncate">{text}</span> <ListSummary list={displayValues} />
</div> </div>
) )
} }
@@ -0,0 +1,24 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { PaymentProvidersListRes } from "../../types/api-responses"
import { client } from "../../lib/client"
export const usePaymentProviders = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<
PaymentProvidersListRes,
Error,
PaymentProvidersListRes,
QueryKey
>,
"queryKey" | "queryFn"
>
) => {
const { data, ...rest } = useQuery({
queryFn: async () => client.payments.listPaymentProviders(query),
queryKey: [],
...options,
})
return { ...data, ...rest }
}
@@ -20,6 +20,7 @@ const regionsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
export const useRegion = ( export const useRegion = (
id: string, id: string,
query?: Record<string, any>,
options?: Omit< options?: Omit<
UseQueryOptions<RegionRes, Error, RegionRes, QueryKey>, UseQueryOptions<RegionRes, Error, RegionRes, QueryKey>,
"queryFn" | "queryKey" "queryFn" | "queryKey"
@@ -27,7 +28,7 @@ export const useRegion = (
) => { ) => {
const { data, ...rest } = useQuery({ const { data, ...rest } = useQuery({
queryKey: regionsQueryKeys.detail(id), queryKey: regionsQueryKeys.detail(id),
queryFn: async () => client.regions.retrieve(id), queryFn: async () => client.regions.retrieve(id, query),
...options, ...options,
}) })
@@ -1,15 +1,11 @@
import { Region } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { RegionDTO } from "@medusajs/types"
import { import {
CountriesCell, CountriesCell,
CountriesHeader, CountriesHeader,
} from "../../../components/table/table-cells/region/countries-cell" } from "../../../components/table/table-cells/region/countries-cell"
import {
FulfillmentProvidersCell,
FulfillmentProvidersHeader,
} from "../../../components/table/table-cells/region/fulfillment-providers-cell"
import { import {
PaymentProvidersCell, PaymentProvidersCell,
PaymentProvidersHeader, PaymentProvidersHeader,
@@ -19,7 +15,7 @@ import {
RegionHeader, RegionHeader,
} from "../../../components/table/table-cells/region/region-cell" } from "../../../components/table/table-cells/region/region-cell"
const columnHelper = createColumnHelper<Region>() const columnHelper = createColumnHelper<RegionDTO>()
export const useRegionTableColumns = () => { export const useRegionTableColumns = () => {
return useMemo( return useMemo(
@@ -38,12 +34,6 @@ export const useRegionTableColumns = () => {
<PaymentProvidersCell paymentProviders={getValue()} /> <PaymentProvidersCell paymentProviders={getValue()} />
), ),
}), }),
columnHelper.accessor("fulfillment_providers", {
header: () => <FulfillmentProvidersHeader />,
cell: ({ getValue }) => (
<FulfillmentProvidersCell fulfillmentProviders={getValue()} />
),
}),
], ],
[] []
) )
@@ -1,2 +1,3 @@
export * from "./auth" export * from "./auth"
export * from "./store" export * from "./campaign"
export * from "./currencies"
@@ -1,23 +0,0 @@
import { RegionDTO } from "@medusajs/types"
import { adminRegionKeys, useAdminCustomQuery } from "medusa-react"
import { V2ListRes } from "./types/common"
export const useV2Regions = (query?: any, options?: any) => {
const { data, ...rest } = useAdminCustomQuery(
"/regions",
adminRegionKeys.list(query),
query,
options
)
const typedData: {
regions: RegionDTO[] | undefined
} & V2ListRes = {
regions: data?.regions,
count: data?.count,
offset: data?.offset,
limit: data?.limit,
}
return { ...typedData, ...rest }
}
@@ -1,32 +0,0 @@
import {
adminStoreKeys,
useAdminCustomPost,
useAdminCustomQuery,
} from "medusa-react"
import { Store } from "./types/store"
// TODO: Add types once we export V2 API types
export const useV2Store = (options?: any) => {
const { data, isLoading, isError, error, ...rest } = useAdminCustomQuery(
"/admin/stores",
adminStoreKeys.details(),
undefined,
options
)
const store = data?.stores[0] as Store | undefined
let hasError = isError
let err: Error | null = error
if (!isLoading && !isError && typeof store === "undefined") {
hasError = true
err = new Error("Store not found")
}
return { store, isLoading, isError: hasError, error: err, ...rest }
}
export const useV2UpdateStore = (id: string) => {
return useAdminCustomPost(`/admin/stores/${id}`, adminStoreKeys.detail(id))
}
@@ -1,5 +1,7 @@
import { CurrencyDTO, StoreDTO } from "@medusajs/types" import { CurrencyDTO, PaymentProviderDTO, StoreDTO } from "@medusajs/types"
export type Store = StoreDTO & { export type Store = StoreDTO & {
default_currency: CurrencyDTO | null default_currency: CurrencyDTO | null
currencies?: CurrencyDTO[]
payment_providers?: PaymentProviderDTO[]
} }
@@ -8,6 +8,7 @@ import { customers } from "./customers"
import { invites } from "./invites" import { invites } from "./invites"
import { productTypes } from "./product-types" import { productTypes } from "./product-types"
import { products } from "./products" import { products } from "./products"
import { payments } from "./payments"
import { promotions } from "./promotions" import { promotions } from "./promotions"
import { regions } from "./regions" import { regions } from "./regions"
import { salesChannels } from "./sales-channels" import { salesChannels } from "./sales-channels"
@@ -26,6 +27,7 @@ export const client = {
currencies: currencies, currencies: currencies,
collections: collections, collections: collections,
promotions: promotions, promotions: promotions,
payments: payments,
stores: stores, stores: stores,
salesChannels: salesChannels, salesChannels: salesChannels,
tags: tags, tags: tags,
@@ -0,0 +1,13 @@
import { getRequest } from "./common"
import { PaymentProvidersListRes } from "../../types/api-responses"
async function listPaymentProviders(query?: Record<string, any>) {
return getRequest<PaymentProvidersListRes, Record<string, any>>(
`/admin/payments/payment-providers`,
query
)
}
export const payments = {
listPaymentProviders,
}
@@ -1,7 +1,7 @@
/** This file is auto-generated. Do not modify it manually. */ /** This file is auto-generated. Do not modify it manually. */
import type { Country } from "@medusajs/medusa" import type { RegionCountryDTO } from "@medusajs/types"
export const countries: Omit<Country, "region" | "region_id" | "id">[] = [ export const countries: Omit<RegionCountryDTO, "id">[] = [
{ {
iso_2: "af", iso_2: "af",
iso_3: "afg", iso_3: "afg",
@@ -888,8 +888,8 @@ export const countries: Omit<Country, "region" | "region_id" | "id">[] = [
iso_2: "ly", iso_2: "ly",
iso_3: "lby", iso_3: "lby",
num_code: 434, num_code: 434,
name: "LIBYAN ARAB JAMAHIRIYA", name: "LIBYA",
display_name: "Libyan Arab Jamahiriya", display_name: "Libya",
}, },
{ {
iso_2: "li", iso_2: "li",
@@ -1,5 +1,5 @@
/** This file is auto-generated. Do not modify it manually. */ /** This file is auto-generated. Do not modify it manually. */
type CurrencyInfo = { export type CurrencyInfo = {
code: string code: string
name: string name: string
symbol_native: string symbol_native: string
@@ -1,12 +1,18 @@
/** /**
* Providers only have an ID to identify them. This function formats the ID * Providers only have an ID to identify them. This function formats the ID
* into a human-readable string. * into a human-readable string.
*
* Format example: pp_stripe-blik_dkk
*
* @param id - The ID of the provider * @param id - The ID of the provider
* @returns A formatted string * @returns A formatted string
*/ */
export const formatProvider = (id: string) => { export const formatProvider = (id: string) => {
return id const [_, name, type] = id.split("_")
.split("-") return (
.map((s) => s.charAt(0).toUpperCase() + s.slice(1)) name
.join(" ") .split("-")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(" ") + (type ? ` (${type})` : "")
)
} }
@@ -254,6 +254,43 @@ export const v2Routes: RouteObject[] = [
}, },
], ],
}, },
{
path: "regions",
element: <Outlet />,
handle: {
crumb: () => "Regions",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/regions/region-list"),
children: [
{
path: "create",
lazy: () => import("../../v2-routes/regions/region-create"),
},
],
},
{
path: ":id",
lazy: () => import("../../v2-routes/regions/region-detail"),
handle: {
crumb: (data: AdminRegionsRes) => data.region.name,
},
children: [
{
path: "edit",
lazy: () => import("../../v2-routes/regions/region-edit"),
},
{
path: "countries/add",
lazy: () =>
import("../../v2-routes/regions/region-add-countries"),
},
],
},
],
},
{ {
path: "store", path: "store",
lazy: () => import("../../v2-routes/store/store-detail"), lazy: () => import("../../v2-routes/store/store-detail"),
@@ -1,563 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
FulfillmentOption,
Region,
ShippingOptionRequirement,
ShippingProfile,
} from "@medusajs/medusa"
import {
Button,
CurrencyInput,
Heading,
Input,
RadioGroup,
Select,
Switch,
Text,
clx,
} from "@medusajs/ui"
import { useAdminCreateShippingOption } from "medusa-react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { IncludesTaxTooltip } from "../../../../../components/common/tax-badge/tax-badge"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { formatProvider } from "../../../../../lib/format-provider"
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
import { ShippingOptionPriceType } from "../../../shared/constants"
type CreateShippingOptionProps = {
region: Region
shippingProfiles: ShippingProfile[]
fulfillmentOptions: FulfillmentOption[]
}
enum ShippingOptionType {
OUTBOUND = "outbound",
RETURN = "return",
}
const CreateShippingOptionSchema = zod
.object({
name: zod.string().min(1),
type: zod.nativeEnum(ShippingOptionType),
admin_only: zod.boolean(),
provider_id: zod.string().min(1),
profile_id: zod.string().min(1),
includes_tax: zod.boolean(),
price_type: zod.nativeEnum(ShippingOptionPriceType),
amount: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return false
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Amount must be a positive number")
.optional(),
min_subtotal: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return true
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Min. subtotal must be a positive number")
.optional(),
max_subtotal: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return true
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Max. subtotal must be a positive number")
.optional(),
})
.superRefine((data, ctx) => {
if (data.price_type === ShippingOptionPriceType.FLAT_RATE) {
if (typeof data.amount === "string" && data.amount === "") {
return ctx.addIssue({
code: "custom",
message: "Amount is required",
path: ["amount"],
})
}
}
})
export const CreateShippingOptionForm = ({
region,
fulfillmentOptions,
shippingProfiles,
}: CreateShippingOptionProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof CreateShippingOptionSchema>>({
defaultValues: {
name: "",
type: ShippingOptionType.OUTBOUND,
admin_only: false,
provider_id: "",
profile_id: "",
price_type: ShippingOptionPriceType.FLAT_RATE,
includes_tax: false,
amount: "",
min_subtotal: "",
max_subtotal: "",
},
resolver: zodResolver(CreateShippingOptionSchema),
})
const watchedPriceType = useWatch({
control: form.control,
name: "price_type",
defaultValue: ShippingOptionPriceType.FLAT_RATE,
})
const isFlatRate = watchedPriceType === ShippingOptionPriceType.FLAT_RATE
const includesTax = useWatch({
control: form.control,
name: "includes_tax",
defaultValue: false,
})
const { mutateAsync, isLoading } = useAdminCreateShippingOption()
const getPricePayload = (amount?: string | number) => {
if (!amount) {
return undefined
}
const amountValue = typeof amount === "string" ? Number(amount) : amount
return getDbAmount(amountValue, region.currency_code)
}
const handleSubmit = form.handleSubmit(async (values) => {
const { type, amount, min_subtotal, max_subtotal, ...rest } = values
const amountPayload = getPricePayload(amount)
const minSubtotalPayload = getPricePayload(min_subtotal)
const maxSubtotalPayload = getPricePayload(max_subtotal)
const minSubtotalRequirement = minSubtotalPayload
? {
amount: minSubtotalPayload,
type: "min_subtotal",
}
: undefined
const maxSubtotalRequirement = maxSubtotalPayload
? {
amount: maxSubtotalPayload,
type: "max_subtotal",
}
: undefined
const requirements = [
minSubtotalRequirement,
maxSubtotalRequirement,
].filter(Boolean) as ShippingOptionRequirement[]
await mutateAsync(
{
region_id: region.id,
data: {},
is_return: type === ShippingOptionType.RETURN,
amount: isFlatRate ? amountPayload : undefined,
requirements: requirements.length ? requirements : undefined,
...rest,
},
{
onSuccess: () => {
handleSuccess()
},
onError: (error) => {
console.error(error)
},
}
)
})
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 size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="overflow-hidden">
<div
className={clx(
"flex h-full w-full flex-col items-center overflow-y-auto p-16"
)}
id="form-section"
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>
{t("regions.shippingOption.createShippingOption")}
</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.createShippingOptionHint")}
</Text>
</div>
<Form.Field
control={form.control}
name="type"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.type")}</Form.Label>
<Form.Control>
<RadioGroup
{...field}
onValueChange={field.onChange}
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<RadioGroup.ChoiceBox
value={ShippingOptionType.OUTBOUND}
label={t("regions.shippingOption.type.outbound")}
description={t(
"regions.shippingOption.type.outboundHint"
)}
/>
<RadioGroup.ChoiceBox
value={ShippingOptionType.RETURN}
label={t("regions.shippingOption.type.return")}
description={t(
"regions.shippingOption.type.returnHint"
)}
/>
</RadioGroup>
</Form.Control>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="admin_only"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-center justify-between">
<Form.Label>
{t("regions.shippingOption.availability.adminOnly")}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t(
"regions.shippingOption.availability.adminOnlyHint"
)}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>
{t("regions.shippingOption.taxInclusiveHint")}
</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="price_type"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("regions.shippingOption.priceType.label")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item
value={ShippingOptionPriceType.FLAT_RATE}
>
{t(
"regions.shippingOption.priceType.flatRate"
)}
</Select.Item>
<Select.Item
value={ShippingOptionPriceType.CALCULATED}
>
{t(
"regions.shippingOption.priceType.calculated"
)}
</Select.Item>
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isFlatRate && (
<Form.Field
control={form.control}
name="amount"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
>
{t("fields.price")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="profile_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.shippingProfile")}</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{shippingProfiles.map((profile) => (
<Select.Item
key={profile.id}
value={profile.id}
>
{profile.name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="provider_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProvider")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{fulfillmentOptions.map((option) => (
<Select.Item
key={option.provider_id}
value={option.provider_id}
>
{formatProvider(option.provider_id)}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("regions.shippingOption.requirements.label")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.requirements.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="min_subtotal"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
optional
>
{t("fields.minSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="max_subtotal"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={
<IncludesTaxTooltip includesTax={includesTax} />
}
optional
>
{t("fields.maxSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
@@ -1 +0,0 @@
export * from "./create-shipping-option-form"
@@ -1 +0,0 @@
export { RegionCreateShippingOption as Component } from "./region-create-shipping-option"
@@ -1,59 +0,0 @@
import {
useAdminRegion,
useAdminRegionFulfillmentOptions,
useAdminShippingProfiles,
} from "medusa-react"
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateShippingOptionForm } from "./components/create-shipping-option-form"
export const RegionCreateShippingOption = () => {
const { id } = useParams()
const {
region,
isLoading: isLoadingRegion,
isError: isRegionError,
error: regionError,
} = useAdminRegion(id!)
const {
shipping_profiles,
isLoading: isLoadingProfiles,
isError: isProfilesError,
error: profileError,
} = useAdminShippingProfiles()
const {
fulfillment_options,
isLoading: isLoadingOptions,
isError: isOptionsError,
error: optionsError,
} = useAdminRegionFulfillmentOptions(id!)
const isLoading = isLoadingProfiles || isLoadingOptions || isLoadingRegion
if (isRegionError) {
throw regionError
}
if (isProfilesError) {
throw profileError
}
if (isOptionsError) {
throw optionsError
}
return (
<RouteFocusModal>
{!isLoading && region && shipping_profiles && fulfillment_options && (
<CreateShippingOptionForm
region={region}
fulfillmentOptions={fulfillment_options}
shippingProfiles={shipping_profiles}
/>
)}
</RouteFocusModal>
)
}
@@ -1,27 +0,0 @@
import { useAdminStore } from "medusa-react"
import { RouteFocusModal } from "../../../components/route-modal/route-focus-modal"
import { CreateRegionForm } from "./components/create-region-form"
export const RegionCreate = () => {
const { store, isLoading, isError, error } = useAdminStore()
const currencies = store?.currencies ?? []
const paymentProviders = store?.payment_providers ?? []
const fulfillmentProviders = store?.fulfillment_providers ?? []
if (isError) {
throw error
}
return (
<RouteFocusModal>
{!isLoading && store && (
<CreateRegionForm
currencies={currencies}
fulfillmentProviders={fulfillmentProviders}
paymentProviders={paymentProviders}
/>
)}
</RouteFocusModal>
)
}
@@ -1 +0,0 @@
export * from "./region-shipping-option-section"
@@ -1,173 +0,0 @@
import { Region } from "@medusajs/medusa"
import { Container, Heading, usePrompt } from "@medusajs/ui"
import {
useAdminDeleteShippingOption,
useAdminShippingOptions,
} from "medusa-react"
import { useTranslation } from "react-i18next"
import { PencilSquare, PlusMini, Trash } from "@medusajs/icons"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useShippingOptionTableColumns } from "../../../../../hooks/table/columns/use-shipping-option-table-columns"
import { useShippingOptionTableFilters } from "../../../../../hooks/table/filters/use-shipping-option-table-filters"
import { useShippingOptionTableQuery } from "../../../../../hooks/table/query/use-shipping-option-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
type RegionShippingOptionSectionProps = {
region: Region
}
const PAGE_SIZE = 10
export const RegionShippingOptionSection = ({
region,
}: RegionShippingOptionSectionProps) => {
const { searchParams, raw } = useShippingOptionTableQuery({
pageSize: PAGE_SIZE,
regionId: region.id,
})
const { shipping_options, count, isError, error, isLoading } =
useAdminShippingOptions(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const filters = useShippingOptionTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (shipping_options ?? []) as unknown as PricedShippingOption[],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id!,
pageSize: PAGE_SIZE,
})
const { t } = useTranslation()
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("regions.shippingOptions")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.create"),
icon: <PlusMini />,
to: "shipping-options/create",
},
],
},
]}
/>
</div>
<DataTable
table={table}
columns={columns}
count={count}
filters={filters}
orderBy={[
"name",
"price_type",
"price_incl_tax",
"is_return",
"admin_only",
"created_at",
"updated_at",
]}
isLoading={isLoading}
pageSize={PAGE_SIZE}
pagination
search
queryObject={raw}
/>
</Container>
)
}
const ShippingOptionActions = ({
shippingOption,
}: {
shippingOption: PricedShippingOption
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteShippingOption(shippingOption.id!)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("regions.deleteShippingOptionWarning", {
name: shippingOption.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `shipping-options/${shippingOption.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<PricedShippingOption>()
const useColumns = () => {
const base = useShippingOptionTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <ShippingOptionActions shippingOption={row.original} />
},
}),
],
[base]
)
}
@@ -1,428 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Region,
ShippingOption,
ShippingOptionRequirement,
} from "@medusajs/medusa"
import {
Button,
CurrencyInput,
Input,
Select,
Switch,
Text,
} from "@medusajs/ui"
import { useAdminUpdateShippingOption } from "medusa-react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
import { IncludesTaxTooltip } from "../../../../../components/common/tax-badge/tax-badge"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import {
getDbAmount,
getPresentationalAmount,
} from "../../../../../lib/money-amount-helpers"
import { ShippingOptionPriceType } from "../../../shared/constants"
type EditShippingOptionFormProps = {
region: Region
shippingOption: ShippingOption
}
const EditShippingOptionSchema = zod
.object({
name: zod.string().min(1),
admin_only: zod.boolean(),
price_type: zod.nativeEnum(ShippingOptionPriceType),
includes_tax: zod.boolean(),
amount: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return false
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Amount must be a positive number")
.optional(),
min_subtotal: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return true
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Min. subtotal must be a positive number")
.optional(),
max_subtotal: zod
.union([zod.string(), zod.number()])
.refine((value) => {
if (value === "") {
return true
}
const num = Number(value)
if (isNaN(num)) {
return false
}
return num >= 0
}, "Max. subtotal must be a positive number")
.optional(),
})
.superRefine((data, ctx) => {
if (data.price_type === ShippingOptionPriceType.FLAT_RATE) {
if (typeof data.amount === "string" && data.amount === "") {
return ctx.addIssue({
code: "custom",
message: "Amount is required",
path: ["amount"],
})
}
}
})
export const EditShippingOptionForm = ({
region,
shippingOption,
}: EditShippingOptionFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const defaultAmount = shippingOption.amount
? getPresentationalAmount(shippingOption.amount, region.currency_code)
: ""
const defaultMinSubtotal = shippingOption.requirements.find(
(r) => r.type === "min_subtotal"
)
const defaultMinSubtotalAmount = defaultMinSubtotal
? getPresentationalAmount(defaultMinSubtotal.amount, region.currency_code)
: ""
const defaultMaxSubtotal = shippingOption.requirements.find(
(r) => r.type === "max_subtotal"
)
const defaultMaxSubtotalAmount = defaultMaxSubtotal
? getPresentationalAmount(defaultMaxSubtotal.amount, region.currency_code)
: ""
const form = useForm<zod.infer<typeof EditShippingOptionSchema>>({
defaultValues: {
admin_only: shippingOption.admin_only,
name: shippingOption.name,
amount: defaultAmount,
max_subtotal: defaultMaxSubtotalAmount,
min_subtotal: defaultMinSubtotalAmount,
price_type: shippingOption.price_type,
includes_tax: shippingOption.includes_tax,
},
resolver: zodResolver(EditShippingOptionSchema),
})
const watchedPriceType = useWatch({
control: form.control,
name: "price_type",
defaultValue: ShippingOptionPriceType.FLAT_RATE,
})
const isFlatRate = watchedPriceType === ShippingOptionPriceType.FLAT_RATE
const includesTax = useWatch({
control: form.control,
name: "includes_tax",
})
const { mutateAsync, isLoading } = useAdminUpdateShippingOption(
shippingOption.id
)
const getPricePayload = (amount?: string | number) => {
if (!amount) {
return undefined
}
const amountValue = typeof amount === "string" ? Number(amount) : amount
return getDbAmount(amountValue, region.currency_code)
}
const handleSubmit = form.handleSubmit(async (values) => {
const { amount, min_subtotal, max_subtotal, ...rest } = values
const amountPayload = getPricePayload(amount)
const minSubtotalPayload = getPricePayload(min_subtotal)
const maxSubtotalPayload = getPricePayload(max_subtotal)
const minSubtotalRequirement = minSubtotalPayload
? {
amount: minSubtotalPayload,
type: "min_subtotal",
}
: undefined
const maxSubtotalRequirement = maxSubtotalPayload
? {
amount: maxSubtotalPayload,
type: "max_subtotal",
}
: undefined
const requirements = [
minSubtotalRequirement,
maxSubtotalRequirement,
].filter(Boolean) as ShippingOptionRequirement[]
await mutateAsync(
{
amount: amountPayload,
requirements,
...rest,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-y-auto">
<Form.Field
control={form.control}
name="admin_only"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-center justify-between">
<Form.Label>
{t("regions.shippingOption.availability.adminOnly")}
</Form.Label>
<Form.Control>
<Switch
checked={value}
onCheckedChange={onChange}
{...field}
/>
</Form.Control>
</div>
<Form.Hint>
{t("regions.shippingOption.availability.adminOnlyHint")}
</Form.Hint>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>{t("fields.taxInclusivePricing")}</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.Hint>{t("regions.taxInclusiveHint")}</Form.Hint>
<Form.ErrorMessage />
</div>
</Form.Item>
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="price_type"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("regions.shippingOption.priceType.label")}
</Form.Label>
<Form.Control>
<Select onValueChange={onChange} {...field}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item
value={ShippingOptionPriceType.FLAT_RATE}
>
{t("regions.shippingOption.priceType.flatRate")}
</Select.Item>
<Select.Item
value={ShippingOptionPriceType.CALCULATED}
>
{t("regions.shippingOption.priceType.calculated")}
</Select.Item>
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{isFlatRate && (
<Form.Field
control={form.control}
name="amount"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
>
{t("fields.price")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>
<Text size="small" leading="compact" weight="plus">
{t("regions.shippingOption.requirements.label")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("regions.shippingOption.requirements.hint")}
</Text>
</div>
<div className="grid grid-cols-1 gap-4">
<Form.Field
control={form.control}
name="min_subtotal"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
optional
>
{t("fields.minSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="max_subtotal"
shouldUnregister
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label
icon={<IncludesTaxTooltip includesTax={includesTax} />}
optional
>
{t("fields.maxSubtotal")}
</Form.Label>
<Form.Control>
<CurrencyInput
code={region.currency_code}
symbol={region.currency.symbol_native}
onValueChange={onChange}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -1 +0,0 @@
export * from "./edit-shipping-option-form"
@@ -1 +0,0 @@
export { RegionEditShippingOption as Component } from "./region-edit-shipping-option"
@@ -1,49 +0,0 @@
import { Heading } from "@medusajs/ui"
import { useAdminRegion, useAdminShippingOption } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditShippingOptionForm } from "./components/edit-shipping-option-form"
export const RegionEditShippingOption = () => {
const { id, so_id } = useParams()
const { t } = useTranslation()
const {
region,
isLoading: isLoadingRegion,
isError: isRegionError,
error: regionError,
} = useAdminRegion(id!)
const {
shipping_option,
isLoading: isLoadingOption,
isError: isOptionError,
error: optionError,
} = useAdminShippingOption(so_id!)
const isLoading = isLoadingRegion || isLoadingOption
if (isRegionError) {
throw regionError
}
if (isOptionError) {
throw optionError
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("regions.shippingOption.editShippingOption")}</Heading>
</RouteDrawer.Header>
{!isLoading && region && shipping_option && (
<EditShippingOptionForm
region={region}
shippingOption={shipping_option}
/>
)}
</RouteDrawer>
)
}
@@ -8,6 +8,7 @@ import {
CurrencyDTO, CurrencyDTO,
CustomerDTO, CustomerDTO,
InviteDTO, InviteDTO,
PaymentProviderDTO,
ProductCategoryDTO, ProductCategoryDTO,
ProductCollectionDTO, ProductCollectionDTO,
ProductDTO, ProductDTO,
@@ -122,6 +123,12 @@ export type TagsListRes = { tags: ProductTagDTO[] } & ListRes
export type ProductTypeRes = { product_type: ProductTypeDTO } export type ProductTypeRes = { product_type: ProductTypeDTO }
export type ProductTypeListRes = { product_types: ProductTypeDTO[] } & ListRes export type ProductTypeListRes = { product_types: ProductTypeDTO[] } & ListRes
// Payments
export type PaymentProvidersListRes = {
payment_providers: PaymentProviderDTO[]
}
// Stock Locations // Stock Locations
export type ExtendedStockLocationDTO = StockLocationDTO & { export type ExtendedStockLocationDTO = StockLocationDTO & {
address: StockLocationAddressDTO | null address: StockLocationAddressDTO | null
@@ -1,5 +1,5 @@
import { Country } from "@medusajs/medusa"
import { json } from "react-router-dom" import { json } from "react-router-dom"
import { RegionCountryDTO } from "@medusajs/types"
const acceptedOrderKeys = ["name", "code"] const acceptedOrderKeys = ["name", "code"]
@@ -14,7 +14,7 @@ export const useCountries = ({
limit, limit,
offset = 0, offset = 0,
}: { }: {
countries: Country[] countries: RegionCountryDTO[]
limit: number limit: number
offset?: number offset?: number
order?: string order?: string
@@ -1,9 +1,10 @@
import { Country } from "@medusajs/medusa" import { RegionCountryDTO } from "@medusajs/types"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
const columnHelper = createColumnHelper<Country>() const columnHelper = createColumnHelper<RegionCountryDTO>()
export const useCountryTableColumns = () => { export const useCountryTableColumns = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1,5 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Country, Region } from "@medusajs/medusa"
import { import {
ColumnDef, ColumnDef,
RowSelectionState, RowSelectionState,
@@ -11,7 +10,7 @@ import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { Button, Checkbox } from "@medusajs/ui" import { Button, Checkbox } from "@medusajs/ui"
import { useAdminUpdateRegion } from "medusa-react" import { RegionCountryDTO, RegionDTO } from "@medusajs/types"
import { import {
RouteFocusModal, RouteFocusModal,
useRouteModal, useRouteModal,
@@ -19,12 +18,13 @@ import {
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { countries as staticCountries } from "../../../../../lib/countries" import { countries as staticCountries } from "../../../../../lib/countries"
import { useCountries } from "../../../shared/hooks/use-countries" import { useCountries } from "../../../common/hooks/use-countries"
import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" import { useCountryTableColumns } from "../../../common/hooks/use-country-table-columns"
import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" import { useCountryTableQuery } from "../../../common/hooks/use-country-table-query"
import { useUpdateRegion } from "../../../../../hooks/api/regions.tsx"
type AddCountriesFormProps = { type AddCountriesFormProps = {
region: Region region: RegionDTO
} }
const AddCountriesSchema = zod.object({ const AddCountriesSchema = zod.object({
@@ -66,12 +66,12 @@ export const AddCountriesForm = ({ region }: AddCountriesFormProps) => {
countries: staticCountries.map((c, i) => ({ countries: staticCountries.map((c, i) => ({
display_name: c.display_name, display_name: c.display_name,
name: c.name, name: c.name,
id: i, id: i as any,
iso_2: c.iso_2, iso_2: c.iso_2,
iso_3: c.iso_3, iso_3: c.iso_3,
num_code: c.num_code, num_code: c.num_code,
region_id: null, region_id: null,
region: {} as Region, region: {} as RegionDTO,
})), })),
...searchParams, ...searchParams,
}) })
@@ -98,7 +98,7 @@ export const AddCountriesForm = ({ region }: AddCountriesFormProps) => {
prefix: PREFIX, prefix: PREFIX,
}) })
const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) const { mutateAsync, isLoading } = useUpdateRegion(region.id)
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
const payload = [ const payload = [
@@ -155,7 +155,7 @@ export const AddCountriesForm = ({ region }: AddCountriesFormProps) => {
) )
} }
const columnHelper = createColumnHelper<Country>() const columnHelper = createColumnHelper<RegionCountryDTO>()
const useColumns = () => { const useColumns = () => {
const base = useCountryTableColumns() const base = useCountryTableColumns()
@@ -196,5 +196,5 @@ const useColumns = () => {
...base, ...base,
], ],
[base] [base]
) as ColumnDef<Country>[] ) as ColumnDef<RegionCountryDTO>[]
} }
@@ -1,12 +1,14 @@
import { useAdminRegion } from "medusa-react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { AddCountriesForm } from "./components/add-countries-form" import { AddCountriesForm } from "./components/add-countries-form"
import { useRegion } from "../../../hooks/api/regions"
export const RegionAddCountries = () => { export const RegionAddCountries = () => {
const { id } = useParams() const { id } = useParams()
const { region, isLoading, isError, error } = useAdminRegion(id!) const { region, isLoading, isError, error } = useRegion(id!, {
fields: "*payment_providers",
})
if (isError) { if (isError) {
throw error throw error
@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { XMarkMini } from "@medusajs/icons" import { XMarkMini } from "@medusajs/icons"
import {
Country,
Currency,
FulfillmentProvider,
PaymentProvider,
} from "@medusajs/medusa"
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -17,29 +11,33 @@ import {
clx, clx,
} from "@medusajs/ui" } from "@medusajs/ui"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useAdminCreateRegion } from "medusa-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useForm, useWatch } from "react-hook-form" import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { RegionCountryDTO, PaymentProviderDTO } from "@medusajs/types"
import { Combobox } from "../../../../../components/common/combobox" import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
import { SplitView } from "../../../../../components/layout/split-view" import { SplitView } from "../../../../../components/layout/split-view"
import { useRouteModal } from "../../../../../components/route-modal" import {
import { RouteFocusModal } from "../../../../../components/route-modal/route-focus-modal" useRouteModal,
RouteFocusModal,
} from "../../../../../components/route-modal"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { countries as staticCountries } from "../../../../../lib/countries" import { countries as staticCountries } from "../../../../../lib/countries"
import { formatProvider } from "../../../../../lib/format-provider" import { formatProvider } from "../../../../../lib/format-provider"
import { useCountries } from "../../../shared/hooks/use-countries" import { useCountries } from "../../../common/hooks/use-countries"
import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" import { useCountryTableColumns } from "../../../common/hooks/use-country-table-columns"
import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" import { useCountryTableQuery } from "../../../common/hooks/use-country-table-query"
import { CurrencyInfo } from "../../../../../lib/currencies"
import { useCreateRegion } from "../../../../../hooks/api/regions"
type CreateRegionFormProps = { type CreateRegionFormProps = {
currencies: Currency[] currencies: CurrencyInfo[]
paymentProviders: PaymentProvider[] paymentProviders: PaymentProviderDTO[]
fulfillmentProviders: FulfillmentProvider[]
} }
const CreateRegionSchema = zod.object({ const CreateRegionSchema = zod.object({
@@ -47,22 +45,7 @@ const CreateRegionSchema = zod.object({
currency_code: zod.string().min(2, "Select a currency"), currency_code: zod.string().min(2, "Select a currency"),
includes_tax: zod.boolean(), includes_tax: zod.boolean(),
countries: zod.array(zod.object({ code: zod.string(), name: zod.string() })), countries: zod.array(zod.object({ code: zod.string(), name: zod.string() })),
fulfillment_providers: zod.array(zod.string()).min(1),
payment_providers: zod.array(zod.string()).min(1), payment_providers: zod.array(zod.string()).min(1),
tax_rate: zod.union([zod.string(), zod.number()]).refine((value) => {
if (value === "") {
return false
}
const num = Number(value)
if (num >= 0 && num <= 100) {
return true
}
return false
}, "Tax rate must be a number between 0 and 100"),
tax_code: zod.string().optional(),
}) })
const PREFIX = "cr" const PREFIX = "cr"
@@ -71,7 +54,6 @@ const PAGE_SIZE = 50
export const CreateRegionForm = ({ export const CreateRegionForm = ({
currencies, currencies,
paymentProviders, paymentProviders,
fulfillmentProviders,
}: CreateRegionFormProps) => { }: CreateRegionFormProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -83,10 +65,7 @@ export const CreateRegionForm = ({
currency_code: "", currency_code: "",
includes_tax: false, includes_tax: false,
countries: [], countries: [],
fulfillment_providers: [],
payment_providers: [], payment_providers: [],
tax_code: "",
tax_rate: "",
}, },
resolver: zodResolver(CreateRegionSchema), resolver: zodResolver(CreateRegionSchema),
}) })
@@ -99,7 +78,7 @@ export const CreateRegionForm = ({
const { t } = useTranslation() const { t } = useTranslation()
const { mutateAsync, isLoading } = useAdminCreateRegion() const { mutateAsync, isLoading } = useCreateRegion()
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync( await mutateAsync(
@@ -107,14 +86,8 @@ export const CreateRegionForm = ({
name: values.name, name: values.name,
countries: values.countries.map((c) => c.code), countries: values.countries.map((c) => c.code),
currency_code: values.currency_code, currency_code: values.currency_code,
fulfillment_providers: values.fulfillment_providers,
payment_providers: values.payment_providers, payment_providers: values.payment_providers,
tax_rate: automatic_taxes: values.includes_tax,
typeof values.tax_rate === "string"
? Number(values.tax_rate)
: values.tax_rate,
tax_code: values.tax_code,
includes_tax: values.includes_tax,
}, },
{ {
onSuccess: ({ region }) => { onSuccess: ({ region }) => {
@@ -290,40 +263,6 @@ export const CreateRegionForm = ({
}} }}
/> />
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Form.Field
control={form.control}
name="tax_rate"
render={({ field: { value, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.taxRate")}</Form.Label>
<Form.Control>
<Input type="number" value={value} {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label optional>
{t("fields.taxCode")}
</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div> </div>
<Form.Field <Form.Field
control={form.control} control={form.control}
@@ -383,9 +322,9 @@ export const CreateRegionForm = ({
</div> </div>
)} )}
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<SplitView.Open type="button"> <Button onClick={() => setOpen(true)} type="button">
{t("regions.addCountries")} {t("regions.addCountries")}
</SplitView.Open> </Button>
</div> </div>
</div> </div>
<div className="bg-ui-border-base h-px w-full" /> <div className="bg-ui-border-base h-px w-full" />
@@ -422,29 +361,6 @@ export const CreateRegionForm = ({
) )
}} }}
/> />
<Form.Field
control={form.control}
name="fulfillment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProviders")}
</Form.Label>
<Form.Control>
<Combobox
options={fulfillmentProviders.map((fp) => ({
label: formatProvider(fp.id),
value: fp.id,
}))}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -481,7 +397,7 @@ export const CreateRegionForm = ({
) )
} }
const columnHelper = createColumnHelper<Country>() const columnHelper = createColumnHelper<RegionCountryDTO>()
const useColumns = () => { const useColumns = () => {
const base = useCountryTableColumns() const base = useCountryTableColumns()
@@ -0,0 +1,29 @@
import { RouteFocusModal } from "../../../components/route-modal/route-focus-modal"
import { CreateRegionForm } from "./components/create-region-form"
import { currencies } from "../../../lib/currencies"
import { usePaymentProviders } from "../../../hooks/api/payments"
import { useStore } from "../../../hooks/api/store"
export const RegionCreate = () => {
const { store, isLoading, isError, error } = useStore()
const storeCurrencies = (store?.supported_currency_codes ?? []).map(
(code) => currencies[code.toUpperCase()]
)
const { payment_providers: paymentProviders = [] } = usePaymentProviders()
if (isError) {
throw error
}
return (
<RouteFocusModal>
{!isLoading && store && (
<CreateRegionForm
currencies={storeCurrencies}
paymentProviders={paymentProviders}
/>
)}
</RouteFocusModal>
)
}
@@ -1,32 +1,23 @@
import { PlusMini, Trash } from "@medusajs/icons" import { PlusMini, Trash } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui" import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
import { RegionCountryDTO, RegionDTO } from "@medusajs/types"
import { import {
ColumnDef, ColumnDef,
RowSelectionState, RowSelectionState,
createColumnHelper, createColumnHelper,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { useAdminUpdateRegion } from "medusa-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useCountries } from "../../../shared/hooks/use-countries" import { useCountries } from "../../../common/hooks/use-countries"
import { useCountryTableColumns } from "../../../shared/hooks/use-country-table-columns" import { useCountryTableColumns } from "../../../common/hooks/use-country-table-columns"
import { useCountryTableQuery } from "../../../shared/hooks/use-country-table-query" import { useCountryTableQuery } from "../../../common/hooks/use-country-table-query"
import { useUpdateRegion } from "../../../../../hooks/api/regions.tsx"
type RegionCountrySectionProps = { type RegionCountrySectionProps = {
region: Region region: RegionDTO
}
type Country = {
id: number
iso_2: string
iso_3: string
num_code: number
name: string
display_name: string
} }
const PREFIX = "c" const PREFIX = "c"
@@ -67,7 +58,7 @@ export const RegionCountrySection = ({ region }: RegionCountrySectionProps) => {
}, },
}) })
const { mutateAsync } = useAdminUpdateRegion(region.id) const { mutateAsync } = useUpdateRegion(region.id)
const handleRemoveCountries = async () => { const handleRemoveCountries = async () => {
const ids = Object.keys(rowSelection).filter((k) => rowSelection[k]) const ids = Object.keys(rowSelection).filter((k) => rowSelection[k])
@@ -140,12 +131,12 @@ const CountryActions = ({
country, country,
region, region,
}: { }: {
country: Country country: RegionCountryDTO
region: Region region: RegionDTO
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminUpdateRegion(region.id) const { mutateAsync } = useUpdateRegion(region.id)
const payload = region.countries const payload = region.countries
?.filter((c) => c.iso_2 !== country.iso_2) ?.filter((c) => c.iso_2 !== country.iso_2)
@@ -189,7 +180,7 @@ const CountryActions = ({
) )
} }
const columnHelper = createColumnHelper<Country>() const columnHelper = createColumnHelper<RegionCountryDTO>()
const useColumns = () => { const useColumns = () => {
const base = useCountryTableColumns() const base = useCountryTableColumns()
@@ -228,12 +219,12 @@ const useColumns = () => {
columnHelper.display({ columnHelper.display({
id: "actions", id: "actions",
cell: ({ row, table }) => { cell: ({ row, table }) => {
const { region } = table.options.meta as { region: Region } const { region } = table.options.meta as { region: RegionDTO }
return <CountryActions country={row.original} region={region} /> return <CountryActions country={row.original} region={region} />
}, },
}), }),
], ],
[base] [base]
) as ColumnDef<Country>[] ) as ColumnDef<RegionCountryDTO>[]
} }
@@ -1,13 +1,17 @@
import { BuildingTax, PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { Region } from "@medusajs/medusa" import { RegionDTO } from "@medusajs/types"
import { Badge, Container, Heading, Text, usePrompt } from "@medusajs/ui" import { Badge, Container, Heading, Text, usePrompt } from "@medusajs/ui"
import { useAdminDeleteRegion } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { formatProvider } from "../../../../../lib/format-provider" import { formatProvider } from "../../../../../lib/format-provider"
import { currencies } from "../../../../../lib/currencies"
import { useDeleteRegion } from "../../../../../hooks/api/regions.tsx"
import { ListSummary } from "../../../../../components/common/list-summary"
import { useNavigate } from "react-router-dom"
type RegionGeneralSectionProps = { type RegionGeneralSectionProps = {
region: Region region: RegionDTO
} }
export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => { export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
@@ -28,7 +32,7 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
{region.currency_code} {region.currency_code}
</Badge> </Badge>
<Text size="small" leading="compact"> <Text size="small" leading="compact">
{region.currency?.name} {currencies[region.currency_code.toUpperCase()].name}
</Text> </Text>
</div> </div>
</div> </div>
@@ -36,33 +40,24 @@ export const RegionGeneralSection = ({ region }: RegionGeneralSectionProps) => {
<Text size="small" leading="compact" weight="plus"> <Text size="small" leading="compact" weight="plus">
{t("fields.paymentProviders")} {t("fields.paymentProviders")}
</Text> </Text>
<Text size="small" leading="compact"> <div className="inline-flex">
{region.payment_providers.length > 0 {region.payment_providers?.length > 0 ? (
? region.payment_providers <ListSummary
.map((p) => formatProvider(p.id)) list={region.payment_providers.map((p) => formatProvider(p.id))}
.join(", ") />
: "-"} ) : (
</Text> "-"
</div> )}
<div className="grid grid-cols-2 items-center px-6 py-4"> </div>
<Text size="small" leading="compact" weight="plus">
{t("fields.fulfillmentProviders")}
</Text>
<Text size="small" leading="compact">
{region.fulfillment_providers.length > 0
? region.fulfillment_providers
.map((p) => formatProvider(p.id))
.join(", ")
: "-"}
</Text>
</div> </div>
</Container> </Container>
) )
} }
const RegionActions = ({ region }: { region: Region }) => { const RegionActions = ({ region }: { region: RegionDTO }) => {
const navigate = useNavigate()
const { t } = useTranslation() const { t } = useTranslation()
const { mutateAsync } = useAdminDeleteRegion(region.id) const { mutateAsync } = useDeleteRegion(region.id)
const prompt = usePrompt() const prompt = usePrompt()
const handleDelete = async () => { const handleDelete = async () => {
@@ -82,6 +77,7 @@ const RegionActions = ({ region }: { region: Region }) => {
} }
await mutateAsync(undefined) await mutateAsync(undefined)
navigate("/settings/regions", { replace: true })
} }
return ( return (
@@ -94,11 +90,6 @@ const RegionActions = ({ region }: { region: Region }) => {
label: t("actions.edit"), label: t("actions.edit"),
to: `/settings/regions/${region.id}/edit`, to: `/settings/regions/${region.id}/edit`,
}, },
{
icon: <BuildingTax />,
label: "Tax settings",
to: `/settings/taxes/${region.id}`,
},
], ],
}, },
{ {
@@ -7,7 +7,8 @@ import { medusa, queryClient } from "../../../lib/medusa"
const regionQuery = (id: string) => ({ const regionQuery = (id: string) => ({
queryKey: adminRegionKeys.detail(id), queryKey: adminRegionKeys.detail(id),
queryFn: async () => medusa.admin.regions.retrieve(id), queryFn: async () =>
medusa.admin.regions.retrieve(id, { fields: "*payment_providers" }),
}) })
export const regionLoader = async ({ params }: LoaderFunctionArgs) => { export const regionLoader = async ({ params }: LoaderFunctionArgs) => {
@@ -1,11 +1,9 @@
import { useAdminRegion } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom" import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { RegionCountrySection } from "./components/region-country-section" import { RegionCountrySection } from "./components/region-country-section"
import { RegionGeneralSection } from "./components/region-general-section" import { RegionGeneralSection } from "./components/region-general-section"
import { RegionShippingOptionSection } from "./components/region-shipping-option-section"
import { regionLoader } from "./loader" import { regionLoader } from "./loader"
import { useRegion } from "../../../hooks/api/regions"
export const RegionDetail = () => { export const RegionDetail = () => {
const initialData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
@@ -13,9 +11,13 @@ export const RegionDetail = () => {
> >
const { id } = useParams() const { id } = useParams()
const { region, isLoading, isError, error } = useAdminRegion(id!, { const { region, isLoading, isError, error } = useRegion(
initialData, id!,
}) { fields: "*payment_providers" },
{
initialData,
}
)
// TODO: Move to loading.tsx and set as Suspense fallback for the route // TODO: Move to loading.tsx and set as Suspense fallback for the route
if (isLoading || !region) { if (isLoading || !region) {
@@ -30,8 +32,6 @@ export const RegionDetail = () => {
<div className="flex flex-col gap-y-2"> <div className="flex flex-col gap-y-2">
<RegionGeneralSection region={region} /> <RegionGeneralSection region={region} />
<RegionCountrySection region={region} /> <RegionCountrySection region={region} />
<RegionShippingOptionSection region={region} />
<JsonViewSection data={region} />
<Outlet /> <Outlet />
</div> </div>
) )
@@ -1,14 +1,8 @@
import {
Currency,
FulfillmentProvider,
PaymentProvider,
Region,
} from "@medusajs/medusa"
import { Button, Input, Select, Text } from "@medusajs/ui" import { Button, Input, Select, Text } from "@medusajs/ui"
import { useAdminUpdateRegion } from "medusa-react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import * as zod from "zod" import * as zod from "zod"
import { PaymentProviderDTO, RegionDTO } from "@medusajs/types"
import { Combobox } from "../../../../../components/common/combobox" import { Combobox } from "../../../../../components/common/combobox"
import { Form } from "../../../../../components/common/form" import { Form } from "../../../../../components/common/form"
@@ -17,26 +11,25 @@ import {
useRouteModal, useRouteModal,
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { formatProvider } from "../../../../../lib/format-provider" import { formatProvider } from "../../../../../lib/format-provider"
import { CurrencyInfo } from "../../../../../lib/currencies"
import { useUpdateRegion } from "../../../../../hooks/api/regions.tsx"
type EditRegionFormProps = { type EditRegionFormProps = {
region: Region region: RegionDTO
currencies: Currency[] currencies: CurrencyInfo[]
paymentProviders: PaymentProvider[] paymentProviders: PaymentProviderDTO[]
fulfillmentProviders: FulfillmentProvider[]
} }
const EditRegionSchema = zod.object({ const EditRegionSchema = zod.object({
name: zod.string().min(1), name: zod.string().min(1),
currency_code: zod.string(), currency_code: zod.string(),
payment_providers: zod.array(zod.string()), payment_providers: zod.array(zod.string()),
fulfillment_providers: zod.array(zod.string()),
}) })
export const EditRegionForm = ({ export const EditRegionForm = ({
region, region,
currencies, currencies,
paymentProviders, paymentProviders,
fulfillmentProviders,
}: EditRegionFormProps) => { }: EditRegionFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
@@ -44,20 +37,18 @@ export const EditRegionForm = ({
const form = useForm<zod.infer<typeof EditRegionSchema>>({ const form = useForm<zod.infer<typeof EditRegionSchema>>({
defaultValues: { defaultValues: {
name: region.name, name: region.name,
currency_code: region.currency_code, currency_code: region.currency_code.toUpperCase(),
fulfillment_providers: region.fulfillment_providers.map((fp) => fp.id), payment_providers: region.payment_providers?.map((pp) => pp.id) || [],
payment_providers: region.payment_providers.map((pp) => pp.id),
}, },
}) })
const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id) const { mutateAsync, isLoading } = useUpdateRegion(region.id)
const handleSubmit = form.handleSubmit(async (values) => { const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync( await mutateAsync(
{ {
name: values.name, name: values.name,
currency_code: values.currency_code, currency_code: values.currency_code.toLowerCase(),
fulfillment_providers: values.fulfillment_providers,
payment_providers: values.payment_providers, payment_providers: values.payment_providers,
}, },
{ {
@@ -146,29 +137,6 @@ export const EditRegionForm = ({
) )
}} }}
/> />
<Form.Field
control={form.control}
name="fulfillment_providers"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("fields.fulfillmentProviders")}
</Form.Label>
<Form.Control>
<Combobox
options={fulfillmentProviders.map((fp) => ({
label: formatProvider(fp.id),
value: fp.id,
}))}
{...field}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div> </div>
</div> </div>
</RouteDrawer.Body> </RouteDrawer.Body>
@@ -1,10 +1,13 @@
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useAdminRegion, useAdminStore } from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal" import { RouteDrawer } from "../../../components/route-modal"
import { EditRegionForm } from "./components/edit-region-form" import { EditRegionForm } from "./components/edit-region-form"
import { currencies } from "../../../lib/currencies"
import { useRegion } from "../../../hooks/api/regions"
import { usePaymentProviders } from "../../../hooks/api/payments"
import { useStore } from "../../../hooks/api/store"
export const RegionEdit = () => { export const RegionEdit = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -15,20 +18,23 @@ export const RegionEdit = () => {
isLoading: isRegionLoading, isLoading: isRegionLoading,
isError: isRegionError, isError: isRegionError,
error: regionError, error: regionError,
} = useAdminRegion(id!) } = useRegion(id!, { fields: "*payment_providers" })
const { const {
store, store,
isLoading: isStoreLoading, isLoading: isStoreLoading,
isError: isStoreError, isError: isStoreError,
error: storeError, error: storeError,
} = useAdminStore() } = useStore()
const isLoading = isRegionLoading || isStoreLoading const isLoading = isRegionLoading || isStoreLoading
const currencies = store?.currencies || [] const storeCurrencies = (store?.supported_currency_codes ?? []).map(
const paymentProviders = store?.payment_providers || [] (code) => currencies[code.toUpperCase()]
const fulfillmentProviders = store?.fulfillment_providers || [] )
const { payment_providers: paymentProviders = [] } = usePaymentProviders({
limit: 999,
})
if (isRegionError) { if (isRegionError) {
throw regionError throw regionError
@@ -46,9 +52,8 @@ export const RegionEdit = () => {
{!isLoading && region && ( {!isLoading && region && (
<EditRegionForm <EditRegionForm
region={region} region={region}
currencies={currencies} currencies={storeCurrencies}
paymentProviders={paymentProviders} paymentProviders={paymentProviders}
fulfillmentProviders={fulfillmentProviders}
/> />
)} )}
</RouteDrawer> </RouteDrawer>
@@ -1,11 +1,10 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui" import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteRegion, useAdminRegions } from "medusa-react"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { RegionDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
@@ -13,6 +12,7 @@ import { useRegionTableColumns } from "../../../../../hooks/table/columns/use-re
import { useRegionTableFilters } from "../../../../../hooks/table/filters/use-region-table-filters" import { useRegionTableFilters } from "../../../../../hooks/table/filters/use-region-table-filters"
import { useRegionTableQuery } from "../../../../../hooks/table/query/use-region-table-query" import { useRegionTableQuery } from "../../../../../hooks/table/query/use-region-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useDeleteRegion, useRegions } from "../../../../../hooks/api/regions"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -20,9 +20,10 @@ export const RegionListTable = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { searchParams, raw } = useRegionTableQuery({ pageSize: PAGE_SIZE }) const { searchParams, raw } = useRegionTableQuery({ pageSize: PAGE_SIZE })
const { regions, count, isLoading, isError, error } = useAdminRegions( const { regions, count, isLoading, isError, error } = useRegions(
{ {
...searchParams, ...searchParams,
fields: "*payment_providers",
}, },
{ {
keepPreviousData: true, keepPreviousData: true,
@@ -33,7 +34,7 @@ export const RegionListTable = () => {
const columns = useColumns() const columns = useColumns()
const { table } = useDataTable({ const { table } = useDataTable({
data: (regions ?? []) as Region[], data: (regions ?? []) as RegionDTO[],
columns, columns,
count, count,
enablePagination: true, enablePagination: true,
@@ -72,11 +73,11 @@ export const RegionListTable = () => {
) )
} }
const RegionActions = ({ region }: { region: Region }) => { const RegionActions = ({ region }: { region: RegionDTO }) => {
const { t } = useTranslation() const { t } = useTranslation()
const prompt = usePrompt() const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteRegion(region.id) const { mutateAsync } = useDeleteRegion(region.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
@@ -123,7 +124,7 @@ const RegionActions = ({ region }: { region: Region }) => {
) )
} }
const columnHelper = createColumnHelper<Region>() const columnHelper = createColumnHelper<RegionDTO>()
const useColumns = () => { const useColumns = () => {
const base = useRegionTableColumns() const base = useRegionTableColumns()
@@ -19,9 +19,9 @@ import {
} from "../../../../../components/route-modal" } from "../../../../../components/route-modal"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useV2UpdateStore } from "../../../../../lib/api-v2"
import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns" import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns"
import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query" import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query"
import { useUpdateStore } from "../../../../../hooks/api/store"
type AddCurrenciesFormProps = { type AddCurrenciesFormProps = {
store: StoreDTO store: StoreDTO
@@ -95,7 +95,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
}, },
}) })
const { mutateAsync, isLoading: isMutating } = useV2UpdateStore(store.id) const { mutateAsync, isLoading: isMutating } = useUpdateStore(store.id)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
const currencies = Array.from( const currencies = Array.from(
@@ -1,9 +1,9 @@
import { RouteFocusModal } from "../../../components/route-modal" import { RouteFocusModal } from "../../../components/route-modal"
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form" import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
import { useV2Store } from "../../../lib/api-v2" import { useStore } from "../../../hooks/api/store"
export const StoreAddCurrencies = () => { export const StoreAddCurrencies = () => {
const { store, isLoading, isError, error } = useV2Store({}) const { store, isLoading, isError, error } = useStore({})
if (isError) { if (isError) {
throw error throw error
@@ -9,6 +9,7 @@ import {
AdminPostRegionsRegionFulfillmentProvidersReq, AdminPostRegionsRegionFulfillmentProvidersReq,
AdminPostRegionsRegionPaymentProvidersReq, AdminPostRegionsRegionPaymentProvidersReq,
AdminGetRegionsRegionFulfillmentOptionsRes, AdminGetRegionsRegionFulfillmentOptionsRes,
AdminGetRegionsRegionParams,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import qs from "qs" import qs from "qs"
import { ResponsePromise } from "../../typings" import { ResponsePromise } from "../../typings"
@@ -115,6 +116,7 @@ class AdminRegionsResource extends BaseResource {
/** /**
* Retrieve a region's details. * Retrieve a region's details.
* @param {string} id - The region's ID. * @param {string} id - The region's ID.
* @param query - Query params
* @param {Record<string, any>} customHeaders - Custom headers to attach to the request. * @param {Record<string, any>} customHeaders - Custom headers to attach to the request.
* @returns {ResponsePromise<AdminRegionsRes>} Resolves to the region's details. * @returns {ResponsePromise<AdminRegionsRes>} Resolves to the region's details.
* *
@@ -129,9 +131,16 @@ class AdminRegionsResource extends BaseResource {
*/ */
retrieve( retrieve(
id: string, id: string,
query?: AdminGetRegionsRegionParams,
customHeaders: Record<string, any> = {} customHeaders: Record<string, any> = {}
): ResponsePromise<AdminRegionsRes> { ): ResponsePromise<AdminRegionsRes> {
const path = `/admin/regions/${id}` let path = `/admin/regions/${id}`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/regions/${id}?${queryString}`
}
return this.client.request("GET", path, undefined, {}, customHeaders) return this.client.request("GET", path, undefined, {}, customHeaders)
} }
@@ -4,6 +4,7 @@ import { authenticate } from "../../../utils/authenticate-middleware"
import * as queryConfig from "./query-config" import * as queryConfig from "./query-config"
import { import {
AdminGetPaymentsParams, AdminGetPaymentsParams,
AdminGetPaymentsPaymentProvidersParams,
AdminPostPaymentsCapturesReq, AdminPostPaymentsCapturesReq,
AdminPostPaymentsRefundsReq, AdminPostPaymentsRefundsReq,
} from "./validators" } from "./validators"
@@ -24,6 +25,16 @@ export const adminPaymentRoutesMiddlewares: MiddlewareRoute[] = [
), ),
], ],
}, },
{
method: ["GET"],
matcher: "/admin/payments/payment-providers",
middlewares: [
transformQuery(
AdminGetPaymentsPaymentProvidersParams,
queryConfig.listTransformPaymentProvidersQueryConfig
),
],
},
{ {
method: ["GET"], method: ["GET"],
matcher: "/admin/payments/:id", matcher: "/admin/payments/:id",
@@ -0,0 +1,30 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { AdminGetPaymentsPaymentProvidersParams } from "../validators"
export const GET = async (
req: AuthenticatedMedusaRequest<AdminGetPaymentsPaymentProvidersParams>,
res: MedusaResponse
) => {
const paymentModule = req.scope.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
const [payment_providers, count] =
await paymentModule.listAndCountPaymentProviders(req.filterableFields, {
skip: req.listConfig.skip,
take: req.listConfig.take,
})
res.status(200).json({
count,
payment_providers,
offset: req.listConfig.skip,
limit: req.listConfig.take,
})
}
@@ -28,3 +28,12 @@ export const retrieveTransformQueryConfig = {
allowedRelations: allowedAdminPaymentRelations, allowedRelations: allowedAdminPaymentRelations,
isList: false, isList: false,
} }
export const defaultAdminPaymentPaymentProviderFields = ["id", "is_enabled"]
export const listTransformPaymentProvidersQueryConfig = {
defaultFields: defaultAdminPaymentPaymentProviderFields,
defaultRelations: [],
allowedRelations: [],
isList: true,
}
@@ -1,5 +1,5 @@
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { IsInt, IsOptional, ValidateNested } from "class-validator" import { IsBoolean, IsInt, IsOptional, ValidateNested } from "class-validator"
import { import {
DateComparisonOperator, DateComparisonOperator,
FindParams, FindParams,
@@ -56,3 +56,24 @@ export class AdminPostPaymentsRefundsReq {
@IsOptional() @IsOptional()
amount?: number amount?: number
} }
export class AdminGetPaymentsPaymentProvidersParams extends extendedFindParamsMixin(
{
limit: 20,
offset: 0,
}
) {
/**
* IDs to filter users by.
*/
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
/**
* Filter providers by `enabled` flag
*/
@IsBoolean()
@IsOptional()
is_enabled?: boolean
}
@@ -2,6 +2,7 @@ import { OperatorMap } from "@medusajs/types"
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { import {
IsArray, IsArray,
IsBoolean,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
@@ -87,6 +88,10 @@ export class AdminPostRegionsReq {
@IsOptional() @IsOptional()
countries?: string[] countries?: string[]
@IsBoolean()
@IsOptional()
automatic_taxes?: boolean
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -110,6 +115,10 @@ export class AdminPostRegionsRegionReq {
@IsOptional() @IsOptional()
countries?: string[] countries?: string[]
@IsBoolean()
@IsOptional()
automatic_taxes?: boolean
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -231,3 +231,4 @@ export * from "./add-payment-provider"
export * from "./create-region" export * from "./create-region"
export * from "./list-regions" export * from "./list-regions"
export * from "./update-region" export * from "./update-region"
export * from "./get-region"
@@ -733,4 +733,24 @@ export default class PaymentModuleService<
} }
) )
} }
@InjectManager("baseRepository_")
async listAndCountPaymentProviders(
filters: FilterablePaymentProviderProps = {},
config: FindConfig<PaymentProviderDTO> = {},
@MedusaContext() sharedContext?: Context
): Promise<[PaymentProviderDTO[], number]> {
const [providers, count] = await this.paymentProviderService_.listAndCount(
filters,
config,
sharedContext
)
return [
await this.baseRepository_.serialize<PaymentProviderDTO[]>(providers, {
populate: true,
}),
count,
]
}
} }
@@ -71,6 +71,23 @@ export default class PaymentProviderService {
) )
} }
@InjectManager("paymentProviderRepository_")
async listAndCount(
filters: FilterablePaymentProviderProps,
config: FindConfig<PaymentProviderDTO>,
@MedusaContext() sharedContext?: Context
): Promise<[PaymentProvider[], number]> {
const queryOptions = ModulesSdkUtils.buildQuery<PaymentProvider>(
filters,
config
)
return await this.paymentProviderRepository_.findAndCount(
queryOptions,
sharedContext
)
}
retrieveProvider(providerId: string): IPaymentProvider { retrieveProvider(providerId: string): IPaymentProvider {
try { try {
return this.container_[providerId] as IPaymentProvider return this.container_[providerId] as IPaymentProvider
+6
View File
@@ -701,6 +701,12 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context sharedContext?: Context
): Promise<PaymentProviderDTO[]> ): Promise<PaymentProviderDTO[]>
listAndCountPaymentProviders(
filters?: FilterablePaymentProviderProps,
config?: FindConfig<PaymentProviderDTO>,
sharedContext?: Context
): Promise<[PaymentProviderDTO[], number]>
/** /**
* This method retrieves a paginated list of captures based on optional filters and configuration. * This method retrieves a paginated list of captures based on optional filters and configuration.
* *
+5
View File
@@ -1,4 +1,5 @@
import { BaseFilterable, OperatorMap } from "../dal" import { BaseFilterable, OperatorMap } from "../dal"
import { PaymentProviderDTO } from "../payment"
/** /**
* The region details. * The region details.
@@ -28,6 +29,10 @@ export interface RegionDTO {
* The countries of the region. * The countries of the region.
*/ */
countries: RegionCountryDTO[] countries: RegionCountryDTO[]
/**
* Payment providers available in the region
*/
payment_providers: PaymentProviderDTO[]
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
+4
View File
@@ -18,6 +18,10 @@ export interface CreateRegionDTO {
* The region's countries. * The region's countries.
*/ */
countries?: string[] countries?: string[]
/**
* The region's payment providers.
*/
payment_providers?: string[]
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */