fix(dashboard): location UI fixes (#7288)
* fix: a few UI fixes * fix: domain name * feat: edit service zone areas * feat: edit shipping option prices * fix: sorting
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"select": "Select",
|
||||
"saveAsDraft": "Save as draft",
|
||||
"publish": "Publish",
|
||||
"create": "Create",
|
||||
@@ -653,8 +654,8 @@
|
||||
}
|
||||
},
|
||||
"shipping": {
|
||||
"title": "Location & Shipping",
|
||||
"domain": "Location & Shipping",
|
||||
"title": "Locations & Shipping",
|
||||
"domain": "Locations & Shipping",
|
||||
"description": "Choose where you ship and how much you charge for shipping at checkout. Define shipping options specific for your locations.",
|
||||
"createLocation": "Create location",
|
||||
"createLocationDetailsHint": "Specify the details of the location.",
|
||||
@@ -714,10 +715,12 @@
|
||||
"edit": {
|
||||
"title": "Edit Service Zone"
|
||||
},
|
||||
"editAreasTitle": "Manage {{zone}} areas",
|
||||
"deleteWarning": "Are you sure you want to delete \"{{name}}\". This will also delete all assocciated shipping options.",
|
||||
"toast": {
|
||||
"delete": "Zone \"{{name}}\" deleted successfully."
|
||||
},
|
||||
"manageAreas": "Manage areas",
|
||||
"editPrices": "Edit prices",
|
||||
"editOption": "Edit option",
|
||||
"optionsLength_one": "shipping option",
|
||||
@@ -745,8 +748,8 @@
|
||||
"allocation": "Shipping amount",
|
||||
"fixed": "Fixed",
|
||||
"fixedDescription": "Shipping option's price is always the same amount.",
|
||||
"enable": "Enable in store",
|
||||
"enableDescription": "Enable or disable the shipping option visiblity in store",
|
||||
"enable": "Show publicly",
|
||||
"enableDescription": "When disabled, the shipping option can only be applied by admins.",
|
||||
"calculated": "Calculated",
|
||||
"calculatedDescription": "Shipping option's price is calculated by the fulfillment provider.",
|
||||
"profile": "Shipping profile"
|
||||
|
||||
@@ -41,7 +41,7 @@ export const useStockLocation = (
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryFn: () => client.stockLocations.retrieve(id, query),
|
||||
queryKey: stockLocationsQueryKeys.detail(id, query),
|
||||
queryKey: stockLocationsQueryKeys.details(),
|
||||
...options,
|
||||
})
|
||||
|
||||
|
||||
@@ -744,6 +744,13 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../v2-routes/shipping/service-zone-edit"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "edit-areas",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../v2-routes/shipping/service-zone-areas-edit"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "shipping-option",
|
||||
children: [
|
||||
@@ -764,6 +771,13 @@ export const RouteMap: RouteObject[] = [
|
||||
"../../v2-routes/shipping/shipping-option-edit"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "edit-pricing",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../v2-routes/shipping/shipping-options-edit-pricing"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -156,7 +156,7 @@ function ShippingOption({
|
||||
{
|
||||
label: t("shipping.serviceZone.editPrices"),
|
||||
icon: <CurrencyDollar />,
|
||||
disabled: true,
|
||||
to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${option.service_zone_id}/shipping-option/${option.id}/edit-pricing`,
|
||||
},
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
@@ -199,7 +199,7 @@ function ServiceZoneOptions({
|
||||
{t("shipping.serviceZone.shippingOptions")}
|
||||
</span>
|
||||
<Button
|
||||
className="text-ui-fg-interactive txt-small px-0 font-medium hover:bg-transparent"
|
||||
className="text-ui-fg-interactive txt-small px-0 font-medium hover:bg-transparent active:bg-transparent"
|
||||
variant="transparent"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
@@ -231,7 +231,7 @@ function ServiceZoneOptions({
|
||||
{t("shipping.serviceZone.returnOptions")}
|
||||
</span>
|
||||
<Button
|
||||
className="text-ui-fg-interactive txt-small px-0 font-medium hover:bg-transparent"
|
||||
className="text-ui-fg-interactive txt-small px-0 font-medium hover:bg-transparent active:bg-transparent"
|
||||
variant="transparent"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
@@ -313,6 +313,7 @@ function ServiceZone({ zone, locationId, fulfillmentSetId }: ServiceZoneProps) {
|
||||
.filter((g) => g.type === "country")
|
||||
.map((g) => g.country_code)
|
||||
.map((code) => staticCountries.find((c) => c.iso_2 === code))
|
||||
.sort((c1, c2) => c1.name.localeCompare(c2.name))
|
||||
}, zone.geo_zones)
|
||||
|
||||
const [shippingOptionsCount, returnOptionsCount] = useMemo(() => {
|
||||
@@ -385,16 +386,16 @@ function ServiceZone({ zone, locationId, fulfillmentSetId }: ServiceZoneProps) {
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
// {
|
||||
// label: t("shipping.serviceZone.addOption"),
|
||||
// icon: <Plus />,
|
||||
// to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/shipping-option/create`,
|
||||
// },
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
icon: <PencilSquare />,
|
||||
to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/edit`,
|
||||
},
|
||||
{
|
||||
label: t("shipping.serviceZone.manageAreas"),
|
||||
icon: <Map />,
|
||||
to: `/settings/shipping/${locationId}/fulfillment-set/${fulfillmentSetId}/service-zone/${zone.id}/edit-areas`,
|
||||
},
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
icon: <Trash />,
|
||||
|
||||
@@ -181,7 +181,7 @@ function Location(props: LocationProps) {
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
className="text-ui-fg-interactive rounded-none pl-5 hover:bg-transparent"
|
||||
className="text-ui-fg-interactive rounded-none pl-5 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => navigate(`/settings/shipping/${location.id}`)}
|
||||
variant="transparent"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
ColumnDef,
|
||||
createColumnHelper,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
import * as zod from "zod"
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Heading,
|
||||
IconButton,
|
||||
Text,
|
||||
toast,
|
||||
} from "@medusajs/ui"
|
||||
import { RegionCountryDTO, RegionDTO, ServiceZoneDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import {
|
||||
useCreateServiceZone,
|
||||
useUpdateServiceZone,
|
||||
} from "../../../../../hooks/api/stock-locations"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useCountryTableQuery } from "../../../../regions/common/hooks/use-country-table-query"
|
||||
import { useCountries } from "../../../../regions/common/hooks/use-countries"
|
||||
import { countries as staticCountries } from "../../../../../lib/countries"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCountryTableColumns } from "../../../../regions/common/hooks/use-country-table-columns"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
|
||||
const PREFIX = "ac"
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const ConditionsFooter = ({ onSave }: { onSave: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<SplitView.Close type="button" asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</SplitView.Close>
|
||||
<Button size="small" type="button" onClick={onSave}>
|
||||
{t("actions.select")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditeServiceZoneSchema = zod.object({
|
||||
countries: zod.array(zod.string().length(2)).min(1),
|
||||
})
|
||||
|
||||
type EditServiceZoneAreasFormProps = {
|
||||
fulfillmentSetId: string
|
||||
locationId: string
|
||||
zone: ServiceZoneDTO
|
||||
}
|
||||
|
||||
export function EditServiceZoneAreasForm({
|
||||
fulfillmentSetId,
|
||||
locationId,
|
||||
zone,
|
||||
}: EditServiceZoneAreasFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
zone.geo_zones
|
||||
.map((z) => z.country_code)
|
||||
.reduce((acc, v) => {
|
||||
acc[v] = true
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
const form = useForm<zod.infer<typeof EditeServiceZoneSchema>>({
|
||||
defaultValues: {
|
||||
countries: zone.geo_zones.map((z) => z.country_code),
|
||||
},
|
||||
resolver: zodResolver(EditeServiceZoneSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync: editServiceZone, isPending: isLoading } =
|
||||
useUpdateServiceZone(fulfillmentSetId, zone.id, locationId)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await editServiceZone({
|
||||
geo_zones: data.countries.map((iso2) => ({
|
||||
country_code: iso2,
|
||||
type: "country",
|
||||
})),
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
}
|
||||
|
||||
handleSuccess()
|
||||
})
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
const { searchParams, raw } = useCountryTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { countries, count } = useCountries({
|
||||
countries: staticCountries.map((c, i) => ({
|
||||
display_name: c.display_name,
|
||||
name: c.name,
|
||||
id: i as any,
|
||||
iso_2: c.iso_2,
|
||||
iso_3: c.iso_3,
|
||||
num_code: c.num_code,
|
||||
region_id: null,
|
||||
region: {} as RegionDTO,
|
||||
})),
|
||||
...searchParams,
|
||||
})
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: countries || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.iso_2,
|
||||
pageSize: PAGE_SIZE,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater: setRowSelection,
|
||||
},
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
const countriesWatch = form.watch("countries")
|
||||
|
||||
const onCountriesSave = () => {
|
||||
form.setValue("countries", Object.keys(rowSelection))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const removeCountry = (iso2: string) => {
|
||||
const state = { ...rowSelection }
|
||||
delete state[iso2]
|
||||
setRowSelection(state)
|
||||
|
||||
form.setValue(
|
||||
"countries",
|
||||
countriesWatch.filter((c) => c !== iso2)
|
||||
)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
setRowSelection({})
|
||||
form.setValue("countries", [])
|
||||
}
|
||||
|
||||
const selectedCountries = useMemo(() => {
|
||||
return staticCountries.filter((c) => c.iso_2 in rowSelection)
|
||||
}, [countriesWatch])
|
||||
|
||||
useEffect(() => {
|
||||
// set selected rows from form state on open
|
||||
if (open) {
|
||||
setRowSelection(
|
||||
countriesWatch.reduce((acc, c) => {
|
||||
acc[c] = true
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const showAreasError =
|
||||
form.formState.errors["countries"]?.type === "too_small"
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button type="submit" size="small" isLoading={isLoading}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
|
||||
<RouteFocusModal.Body className="m-auto flex h-full w-full flex-col items-center divide-y overflow-hidden">
|
||||
<SplitView open={open} onOpenChange={handleOpenChange}>
|
||||
<SplitView.Content className="mx-auto max-w-[720px]">
|
||||
<div className="container w-fit px-1 py-8">
|
||||
<Heading className="mt-8 text-2xl">
|
||||
{t("shipping.serviceZone.editAreasTitle", {
|
||||
zone: zone.name,
|
||||
})}
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
<div className="container flex items-center justify-between py-8 pr-1">
|
||||
<div>
|
||||
<Text weight="plus">
|
||||
{t("shipping.serviceZone.areas.title")}
|
||||
</Text>
|
||||
<Text className="text-ui-fg-subtle mt-2">
|
||||
{t("shipping.serviceZone.areas.description")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
>
|
||||
{t("shipping.serviceZone.areas.manage")}
|
||||
</Button>
|
||||
</div>
|
||||
{!!selectedCountries.length && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{selectedCountries.map((c) => (
|
||||
<Badge
|
||||
key={c.iso_2}
|
||||
className="text-ui-fg-subtle txt-small flex items-center gap-1 divide-x pr-0"
|
||||
>
|
||||
{c.display_name}
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={() => removeCountry(c.iso_2)}
|
||||
className="text-ui-fg-subtle p-0 px-1 pt-[1px]"
|
||||
variant="transparent"
|
||||
>
|
||||
<XMarkMini />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
variant="transparent"
|
||||
className="txt-small text-ui-fg-muted font-medium"
|
||||
>
|
||||
{t("actions.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAreasError && (
|
||||
<Alert dismissible variant="error">
|
||||
{t("shipping.serviceZone.areas.error")}
|
||||
</Alert>
|
||||
)}
|
||||
</SplitView.Content>
|
||||
<SplitView.Drawer>
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
search
|
||||
pagination
|
||||
layout="fill"
|
||||
orderBy={["name", "code"]}
|
||||
queryObject={raw}
|
||||
prefix={PREFIX}
|
||||
/>
|
||||
<ConditionsFooter onSave={onCountriesSave} />
|
||||
</div>
|
||||
</SplitView.Drawer>
|
||||
</SplitView>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<RegionCountryDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useCountryTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isPreselected = !row.getCanSelect()
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected() || isPreselected}
|
||||
disabled={isPreselected}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
) as ColumnDef<RegionCountryDTO>[]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-service-zone-areas-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { ServiceZoneAreasEdit as Component } from "./service-zone-areas-edit"
|
||||
@@ -0,0 +1,45 @@
|
||||
import { json, useParams } from "react-router-dom"
|
||||
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { EditServiceZoneAreasForm } from "./components/edit-region-areas-form"
|
||||
import { useStockLocation } from "../../../hooks/api/stock-locations"
|
||||
|
||||
export const ServiceZoneAreasEdit = () => {
|
||||
const { location_id, fset_id, zone_id } = useParams()
|
||||
|
||||
const { stock_location, isPending, isError, error } = useStockLocation(
|
||||
location_id!,
|
||||
{
|
||||
// NOTE: use same query for all details page subroutes & fetches
|
||||
fields:
|
||||
"name,*sales_channels,address.city,address.country_code,fulfillment_sets.type,fulfillment_sets.name,*fulfillment_sets.service_zones.geo_zones,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options,*fulfillment_sets.service_zones.shipping_options.rules,*fulfillment_sets.service_zones.shipping_options.shipping_profile",
|
||||
}
|
||||
)
|
||||
|
||||
const zone = stock_location?.fulfillment_sets
|
||||
.find((f) => f.id === fset_id)
|
||||
?.service_zones.find((z) => z.id === zone_id)
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!isPending && !zone) {
|
||||
throw json(
|
||||
{ message: `Service zone with ID ${zone_id} was not found` },
|
||||
404
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{!isPending && zone && (
|
||||
<EditServiceZoneAreasForm
|
||||
zone={zone}
|
||||
fulfillmentSetId={fset_id}
|
||||
locationId={location_id}
|
||||
/>
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
Text,
|
||||
toast,
|
||||
} from "@medusajs/ui"
|
||||
import { FulfillmentSetDTO, RegionCountryDTO, RegionDTO } from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Map, XMark, XMarkMini } from "@medusajs/icons"
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
@@ -87,13 +88,20 @@ export function CreateServiceZoneForm({
|
||||
useCreateServiceZone(locationId, fulfillmentSet.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await createServiceZone({
|
||||
name: data.name,
|
||||
geo_zones: data.countries.map((iso2) => ({
|
||||
country_code: iso2,
|
||||
type: "country",
|
||||
})),
|
||||
})
|
||||
try {
|
||||
await createServiceZone({
|
||||
name: data.name,
|
||||
geo_zones: data.countries.map((iso2) => ({
|
||||
country_code: iso2,
|
||||
type: "country",
|
||||
})),
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
}
|
||||
|
||||
handleSuccess()
|
||||
})
|
||||
@@ -201,7 +209,7 @@ export function CreateServiceZoneForm({
|
||||
<RouteFocusModal.Body className="m-auto flex h-full w-full flex-col items-center divide-y overflow-hidden">
|
||||
<SplitView open={open} onOpenChange={handleOpenChange}>
|
||||
<SplitView.Content className="mx-auto max-w-[720px]">
|
||||
<div className="container w-fit px-1 py-8">
|
||||
<div className="container w-fit px-1 py-8">
|
||||
<Heading className="mb-12 mt-8 text-2xl">
|
||||
{t("shipping.fulfillmentSet.create.title", {
|
||||
fulfillmentSet: fulfillmentSet.name,
|
||||
|
||||
@@ -6,7 +6,9 @@ import { StockLocationRes } from "../../../types/api-responses"
|
||||
import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations"
|
||||
|
||||
const fulfillmentSetCreateQuery = (id: string) => ({
|
||||
queryKey: stockLocationsQueryKeys.detail(id),
|
||||
queryKey: stockLocationsQueryKeys.detail(id, {
|
||||
fields: "*fulfillment_sets",
|
||||
}),
|
||||
queryFn: async () =>
|
||||
client.stockLocations.retrieve(id, {
|
||||
fields: "*fulfillment_sets",
|
||||
|
||||
@@ -45,7 +45,7 @@ type StepStatus = {
|
||||
[key in Tab]: ProgressStatus
|
||||
}
|
||||
|
||||
const CreateServiceZoneSchema = zod.object({
|
||||
const CreateShippingOptionSchema = zod.object({
|
||||
name: zod.string().min(1),
|
||||
price_type: zod.nativeEnum(ShippingAllocation),
|
||||
enabled_in_store: zod.boolean().optional(),
|
||||
@@ -55,7 +55,7 @@ const CreateServiceZoneSchema = zod.object({
|
||||
currency_prices: zod.record(zod.string(), zod.string().optional()),
|
||||
})
|
||||
|
||||
type CreateServiceZoneFormProps = {
|
||||
type CreateShippingOptionFormProps = {
|
||||
zone: ServiceZoneDTO
|
||||
isReturn?: boolean
|
||||
}
|
||||
@@ -63,7 +63,7 @@ type CreateServiceZoneFormProps = {
|
||||
export function CreateShippingOptionsForm({
|
||||
zone,
|
||||
isReturn,
|
||||
}: CreateServiceZoneFormProps) {
|
||||
}: CreateShippingOptionFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const [tab, setTab] = React.useState<Tab>(Tab.DETAILS)
|
||||
@@ -77,7 +77,7 @@ export function CreateShippingOptionsForm({
|
||||
fields: "id,currency_code",
|
||||
})
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateServiceZoneSchema>>({
|
||||
const form = useForm<zod.infer<typeof CreateShippingOptionSchema>>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
price_type: ShippingAllocation.FlatRate,
|
||||
@@ -87,7 +87,7 @@ export function CreateShippingOptionsForm({
|
||||
region_prices: {},
|
||||
currency_prices: {},
|
||||
},
|
||||
resolver: zodResolver(CreateServiceZoneSchema),
|
||||
resolver: zodResolver(CreateShippingOptionSchema),
|
||||
})
|
||||
|
||||
const isCalculatedPriceType =
|
||||
|
||||
@@ -6,7 +6,10 @@ import { StockLocationRes } from "../../../types/api-responses"
|
||||
import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations"
|
||||
|
||||
const fulfillmentSetCreateQuery = (id: string) => ({
|
||||
queryKey: stockLocationsQueryKeys.list(), // Use the list cache key for now
|
||||
queryKey: stockLocationsQueryKeys.detail(id, {
|
||||
fields:
|
||||
"*fulfillment_sets,*fulfillment_sets.service_zones,*fulfillment_sets.service_zones.shipping_options",
|
||||
}),
|
||||
queryFn: async () =>
|
||||
client.stockLocations.retrieve(id, {
|
||||
fields:
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Button, toast } from "@medusajs/ui"
|
||||
import {
|
||||
CurrencyDTO,
|
||||
PriceDTO,
|
||||
ProductVariantDTO,
|
||||
RegionDTO,
|
||||
ShippingOptionDTO,
|
||||
} from "@medusajs/types"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import {
|
||||
getDbAmount,
|
||||
getPresentationalAmount,
|
||||
} from "../../../../../lib/money-amount-helpers"
|
||||
import { useRegions } from "../../../../../hooks/api/regions"
|
||||
import { useStore } from "../../../../../hooks/api/store.tsx"
|
||||
import { useCurrencies } from "../../../../../hooks/api/currencies"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { ExtendedProductDTO } from "../../../../../types/api-responses"
|
||||
import { CurrencyCell } from "../../../../../components/grid/grid-cells/common/currency-cell"
|
||||
import { DataGridMeta } from "../../../../../components/grid/types"
|
||||
import { DataGrid } from "../../../../../components/grid/data-grid"
|
||||
import { useUpdateShippingOptions } from "../../../../../hooks/api/shipping-options.ts"
|
||||
|
||||
const getInitialCurrencyPrices = (prices: PriceDTO[]) => {
|
||||
const ret: Record<string, number> = {}
|
||||
prices.forEach((p) => {
|
||||
if (p.price_rules!.length) {
|
||||
// this is a region price
|
||||
return
|
||||
}
|
||||
ret[p.currency_code!] = getPresentationalAmount(
|
||||
p.amount as number,
|
||||
p.currency_code!
|
||||
)
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
const getInitialRegionPrices = (prices: PriceDTO[]) => {
|
||||
const ret: Record<string, number> = {}
|
||||
prices.forEach((p) => {
|
||||
if (p.price_rules!.length) {
|
||||
const regionId = p.price_rules![0].value
|
||||
ret[regionId] = getPresentationalAmount(
|
||||
p.amount as number,
|
||||
p.currency_code!
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
const EditShippingOptionPricingSchema = zod.object({
|
||||
region_prices: zod.record(
|
||||
zod.string(),
|
||||
zod.string().or(zod.number()).optional()
|
||||
),
|
||||
currency_prices: zod.record(
|
||||
zod.string(),
|
||||
zod.string().or(zod.number()).optional()
|
||||
),
|
||||
})
|
||||
|
||||
enum ColumnType {
|
||||
REGION = "region",
|
||||
CURRENCY = "currency",
|
||||
}
|
||||
|
||||
type EnabledColumnRecord = Record<string, ColumnType>
|
||||
|
||||
type EditShippingOptionPricingFormProps = {
|
||||
shippingOption: ShippingOptionDTO & { prices: PriceDTO[] }
|
||||
}
|
||||
|
||||
export function EditShippingOptionsPricingForm({
|
||||
shippingOption,
|
||||
}: EditShippingOptionPricingFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<zod.infer<typeof EditShippingOptionPricingSchema>>({
|
||||
defaultValues: {
|
||||
region_prices: getInitialRegionPrices(shippingOption.prices),
|
||||
currency_prices: getInitialCurrencyPrices(shippingOption.prices),
|
||||
},
|
||||
resolver: zodResolver(EditShippingOptionPricingSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending: isLoading } = useUpdateShippingOptions(
|
||||
shippingOption.id
|
||||
)
|
||||
|
||||
const { regions } = useRegions()
|
||||
|
||||
const { store, isLoading: isStoreLoading } = useStore()
|
||||
|
||||
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(
|
||||
{
|
||||
code: store?.supported_currency_codes,
|
||||
},
|
||||
{
|
||||
enabled: !!store,
|
||||
}
|
||||
)
|
||||
|
||||
const [enabledColumns, setEnabledColumns] = useState<EnabledColumnRecord>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
store?.default_currency_code &&
|
||||
Object.keys(enabledColumns).length === 0
|
||||
) {
|
||||
setEnabledColumns({
|
||||
...enabledColumns,
|
||||
[store.default_currency_code]: ColumnType.CURRENCY,
|
||||
})
|
||||
}
|
||||
}, [store, enabledColumns])
|
||||
|
||||
const columns = useColumns({
|
||||
currencies,
|
||||
regions,
|
||||
})
|
||||
|
||||
const data = useMemo(
|
||||
() => [[...(currencies || []), ...(regions || [])]],
|
||||
[currencies, regions]
|
||||
)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const currencyPrices = Object.entries(data.currency_prices)
|
||||
.map(([code, value]) => {
|
||||
if (value === "") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const amount = getDbAmount(Number(value), code)
|
||||
|
||||
const priceRecord = {
|
||||
currency_code: code,
|
||||
amount: amount,
|
||||
}
|
||||
|
||||
const price = shippingOption.prices.find(
|
||||
(p) => p.currency_code === code && !p.price_rules!.length
|
||||
)
|
||||
|
||||
// if that currency price is already defined for the SO, we will do an update
|
||||
if (price) {
|
||||
priceRecord["id"] = price.id
|
||||
}
|
||||
|
||||
return priceRecord
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
|
||||
const regionsMap = new Map(regions.map((r) => [r.id, r.currency_code]))
|
||||
|
||||
const regionPrices = Object.entries(data.region_prices)
|
||||
.map(([region_id, value]) => {
|
||||
if (value === "") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const code = regionsMap.get(region_id)!
|
||||
|
||||
const amount = getDbAmount(Number(value), code)
|
||||
|
||||
const priceRecord = {
|
||||
region_id,
|
||||
amount: amount,
|
||||
}
|
||||
|
||||
/**
|
||||
* HACK - when trying to update prices which already have a region price
|
||||
* we get error: `Price rule with price_id: , rule_type_id: already exist`,
|
||||
* so for now, we recreate region prices.
|
||||
*/
|
||||
|
||||
// const price = shippingOption.prices.find(
|
||||
// (p) => p.price_rules?.[0]?.value === region_id
|
||||
// )
|
||||
//
|
||||
// if (price) {
|
||||
// priceRecord["id"] = price.id
|
||||
// }
|
||||
|
||||
return priceRecord
|
||||
})
|
||||
.filter((p) => !!p)
|
||||
|
||||
try {
|
||||
await mutateAsync({
|
||||
prices: [...currencyPrices, ...regionPrices],
|
||||
})
|
||||
toast.error(t("general.success"), {
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
handleSuccess()
|
||||
} catch (e) {
|
||||
toast.error(t("general.error"), {
|
||||
description: e.message,
|
||||
dismissLabel: t("general.close"),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const initializing =
|
||||
isStoreLoading || isCurrenciesLoading || !store || !currencies
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button
|
||||
size="small"
|
||||
className="whitespace-nowrap"
|
||||
isLoading={isLoading}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
|
||||
<RouteFocusModal.Body className="flex h-full w-fit overflow-auto">
|
||||
<div
|
||||
style={{ width: "100vw" }}
|
||||
className="flex size-full flex-col divide-y"
|
||||
>
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
isLoading={initializing}
|
||||
state={form}
|
||||
/>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<
|
||||
ExtendedProductDTO | ProductVariantDTO
|
||||
>()
|
||||
|
||||
const useColumns = ({
|
||||
currencies = [],
|
||||
regions = [],
|
||||
}: {
|
||||
currencies?: CurrencyDTO[]
|
||||
regions?: RegionDTO[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const colDefs: ColumnDef<ExtendedProductDTO | ProductVariantDTO>[] =
|
||||
useMemo(() => {
|
||||
return [
|
||||
...currencies.map((currency) => {
|
||||
return columnHelper.display({
|
||||
header: t("fields.priceTemplate", {
|
||||
regionOrCountry: currency.code.toUpperCase(),
|
||||
}),
|
||||
cell: ({ row, table }) => {
|
||||
return (
|
||||
<CurrencyCell
|
||||
currency={currency}
|
||||
meta={table.options.meta as DataGridMeta<any>}
|
||||
field={`currency_prices.${currency.code}`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
...regions.map((region) => {
|
||||
return columnHelper.display({
|
||||
header: t("fields.priceTemplate", {
|
||||
regionOrCountry: region.name,
|
||||
}),
|
||||
cell: ({ row, table }) => {
|
||||
return (
|
||||
<CurrencyCell
|
||||
currency={currencies.find(
|
||||
(c) => c.code === region.currency_code
|
||||
)}
|
||||
meta={table.options.meta as DataGridMeta<any>}
|
||||
field={`region_prices.${region.id}`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
}, [t, currencies, regions])
|
||||
|
||||
return colDefs
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./edit-shipping-options-pricing-form.tsx"
|
||||
@@ -0,0 +1 @@
|
||||
export { ShippingOptionsEditPricing as Component } from "./shipping-options-edit-pricing"
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { useShippingOptions } from "../../../hooks/api/shipping-options"
|
||||
import { EditShippingOptionsPricingForm } from "./components/create-shipping-options-form"
|
||||
|
||||
export function ShippingOptionsEditPricing() {
|
||||
const { so_id } = useParams()
|
||||
|
||||
const { shipping_options, isPending } = useShippingOptions({
|
||||
// TODO: change this when GET option by id endpoint is implemented
|
||||
id: [so_id],
|
||||
fields: "*prices,*prices.price_rules",
|
||||
limit: 999,
|
||||
})
|
||||
|
||||
const shippingOption = shipping_options?.find((so) => so.id === so_id)
|
||||
|
||||
if (!isPending && !shippingOption) {
|
||||
throw new Error(`Shipping option with id: ${so_id} not found`)
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{shippingOption && (
|
||||
<EditShippingOptionsPricingForm shippingOption={shippingOption} />
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -120,6 +120,14 @@ export interface UpdateShippingOptionDTO {
|
||||
id: string
|
||||
}
|
||||
)[]
|
||||
|
||||
/**
|
||||
* The shipping option pricing
|
||||
*/
|
||||
prices: (
|
||||
| { currency_code: string; amount: number; id?: string }
|
||||
| { region_id: string; amount: number; id?: string }
|
||||
)[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user