feat(admin-ui, medusa, medusa-react, medusa-js): Price List UI revamp (#5233)

* progress on cleanup

* rm forceMount

* close modal on succesful submit

* fix: invalidate price list query on useAdminCreatePriceListPrices

* sync translation keys

* add last translations

* sync translation keys

* improve tabbing between cells

* add comment

* fix: remove double variant, set collision boundary on column dropdown

* add widgets

* update lock file

* decrease details info size, and add missing status update function

* sync translation keys

* add snapshots and remove min/max

* add missing filter menu for customer groups table

* add translation keys for filter menu

* rm unused code

* Create tall-apricots-run.md

* Update tall-apricots-run.md

* fix: discard invalid paste values

* add translation keys

* bump snapshots + minor fixes

* rm console.log

* bump snapshots

* bump ui packages, and add missing tax inclusive display in New form

* update lock file

* fix filter menu

* update snapshot

* update ui package and fix sub menu position

---------

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2023-10-06 08:22:25 -07:00
1 parent 4ca70a0d0c
commit 0f34e0f381
86 files changed
+9910 -2667

No files matched your search

+7 -4
View File
@@ -35,8 +35,10 @@
"@babel/parser": "7.22.5",
"@babel/traverse": "7.22.5",
"@hookform/error-message": "^2.0.1",
"@medusajs/ui": "^1.0.0",
"@medusajs/ui-preset": "^1.0.0",
"@hookform/resolvers": "^3.3.1",
"@medusajs/icons": "1.1.0",
"@medusajs/ui": "^2.2.0",
"@medusajs/ui-preset": "1.0.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@radix-ui/react-accordion": "^1.0.1",
"@radix-ui/react-avatar": "^1.0.1",
@@ -46,7 +48,7 @@
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-portal": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.1",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-switch": "^1.0.1",
"@radix-ui/react-tooltip": "^1.0.3",
"@segment/analytics-next": "^1.51.1",
@@ -111,7 +113,8 @@
"type-fest": "^3.6.0",
"webpack": "^5.84.1",
"webpack-dev-server": "4.15.0",
"webpackbar": "^5.0.2"
"webpackbar": "^5.0.2",
"zod": "^3.22.2"
},
"devDependencies": {
"@babel/types": "7.22.5",
@@ -1,5 +1,13 @@
{
"back-button-go-back": "Go back",
"filter-menu-trigger": "View",
"filter-menu-clear-button": "Clear",
"filter-menu-select-item-default-placeholder": "Select filter",
"filter-menu-select-item-clear-button": "Clear the selected options",
"filter-menu-select-item-selected": "Selected",
"filter-menu-date-item-before": "Before",
"filter-menu-date-item-after": "After",
"filter-menu-date-item-between": "Between",
"sales-channels-display-available-count": "Available in <2>{{availableChannelsCount}}</2> out of <6>{{totalChannelsCount}}</6> Sales Channels",
"activity-drawer-activity": "Activity",
"activity-drawer-no-notifications-title": "It's quiet in here...",
@@ -111,13 +119,13 @@
"login-card-password": "Password",
"login-card-forgot-your-password": "Forgot your password?",
"metadata-add-metadata": "Add Metadata",
"product-attributes-section-title": "Attributes",
"product-attributes-section-edit-attributes": "Edit Attributes",
"product-attributes-section-dimensions": "Dimensions",
"product-attributes-section-configure-to-calculate-the-most-accurate-shipping-rates": "Configure to calculate the most accurate shipping rates",
"product-attributes-section-customs": "Customs",
"product-attributes-section-cancel": "Cancel",
"product-attributes-section-save": "Save",
"product-attributes-section-title": "Attributes",
"product-attributes-section-height": "Height",
"product-attributes-section-width": "Width",
"product-attributes-section-length": "Length",
@@ -1380,89 +1388,161 @@
"form-use-new-order-form-must-be-used-within-new-order-form-provider": "useNewOrderForm must be used within NewOrderFormProvider",
"new-order-created": "Order created",
"new-create-draft-order": "Create Draft Order",
"batch-job-price-list-prices": "Price List prices",
"batch-job-upload-a-csv-file-with-variants": "Upload a CSV file with variants and prices to update your price list. Note that any existing prices will be deleted.",
"batch-job-unsure-about-how-to-arrange-your-list": "Unsure about how to arrange your list?",
"batch-job-download-the-template-file-below-and-update-your-prices": "Download the template file below and update your prices",
"details-back-to-pricing": "Back to Pricing",
"details-raw-price-list": "Raw price list",
"sections-customer-groups": "Customer groups",
"sections-last-edited": "Last edited",
"sections-price-overrides": "Price overrides",
"sections-more": "more",
"sections-delete-price-list-heading": "Delete Price list",
"sections-are-you-sure-you-want-to-delete-this-price-list": "Are you sure you want to delete this price list?",
"sections-success": "Success",
"sections-price-list-deleted-successfully": "Price list deleted successfully",
"sections-edit-price-list-details": "Edit price list details",
"sections-delete-price-list": "Delete price list",
"edit-prices-overrides-edit-price-overrides": "Edit price overrides",
"edit-prices-overrides-success": "Success",
"edit-prices-overrides-price-overrides-updated": "Price overrides updated",
"edit-prices-overrides-cancel": "Cancel",
"edit-prices-overrides-save": "Save",
"edit-prices-overrides-count_one": "{{count}}",
"edit-prices-overrides-count_other": "{{count}}",
"edit-prices-overrides-add-prices": "Add prices",
"prices-details-edit-prices": "Edit prices",
"prices-details-prices": "Prices",
"prices-details-you-will-be-able-to-override-the-prices-for-the-products-you-add-here": "You will be able to override the prices for the products you add here",
"prices-details-remove-from-list": "Remove from list",
"prices-details-edit-manually": "Edit manually",
"prices-details-import-price-list": "Import price list",
"prices-table-search-by-name-or-sku": "Search by name or SKU...",
"prices-table-edit-prices": "Edit prices",
"prices-table-remove-product": "Remove product",
"prices-table-success": "Success",
"prices-table-deleted-prices-of-product": "Deleted prices of product: {{title}}",
"prices-table-error": "Error",
"prices-table-name": "Name",
"prices-table-collection": "Collection",
"prices-table-no-collection": "No collection",
"prices-table-variants": "Variants",
"pricing-add-price-list": "Add price list",
"pricing-price-lists": "Price lists",
"form-header-error": "Error",
"form-header-success": "Success",
"form-header-successfully-updated-price-list": "Successfully updated price list",
"form-header-publish-price-list": "Publish price list",
"form-header-save-as-draft": "Save as draft",
"form-header-save-changes": "Save changes",
"form-header-cancel": "Cancel",
"pricing-form-create-new-price-list": "Create new price list",
"pricing-form-edit-price-list": "Edit price list",
"sections-configuration": "Configuration",
"sections-optional-configuration-for-the-price-list": "Optional configuration for the price list",
"sections-price-overrides-time-application": "The price overrides apply from the time you hit the publish button and forever if left untouched.",
"sections-price-overrides-has-a-start-date": "Price overrides has a start date?",
"sections-schedule-the-price-overrides-to-activate-in-the-future": "Schedule the price overrides to activate in the future.",
"sections-price-overrides-has-an-expiry-date": "Price overrides has an expiry date?",
"sections-schedule-the-price-overrides-to-deactivate-in-the-future": "Schedule the price overrides to deactivate in the future.",
"sections-end-date": "End date",
"sections-customer-availabilty": "Customer availabilty",
"sections-specifiy-which-customer-groups-the-price-overrides-should-apply-for": "Specifiy which customer groups the price overrides should apply for.",
"sections-customer-groups-label": "Customer Groups",
"sections-general": "General",
"sections-general-information-for-the-price-list": "General information for the price list.",
"sections-name": "Name",
"sections-b-2-b-black-friday": "B2B, Black Friday...",
"sections-for-our-business-partners": "For our business partners...",
"sections-tax-inclusive-prices": "Tax inclusive prices",
"sections-choose-to-make-all-prices-in-this-list-inclusive-of-tax": "Choose to make all prices in this list inclusive of tax.",
"sections-prices": "Prices",
"sections-you-will-be-able-to-override-the-prices-for-the-products-you-add-here": "You will be able to override the prices for the products you add here",
"sections-define-the-price-overrides-for-the-price-list": "Define the price overrides for the price list",
"sections-edit-prices-label": "Edit prices",
"sections-remove-from-list": "Remove from list",
"sections-search-by-name-or-sku": "Search by name or SKU...",
"sections-edit-prices": "Edit Prices",
"sections-price-list-type": "Price list type",
"sections-select-the-type-of-the-price-list": "Select the type of the price list",
"sections-sale-prices-compare-to-price-override": "Unlike with sale prices a price override will not communicate to the customer that the price is part of a sale.",
"sections-sale": "Sale",
"sections-use-this-if-you-are-creating-prices-for-a-sale": "Use this if you are creating prices for a sale.",
"sections-override": "Override",
"sections-use-this-to-override-prices": "Use this to override prices.",
"price-list-product-filter-created-at": "Created at",
"price-list-product-filter-updated-at": "Updated at",
"price-list-details-drawer-prompt-title": "Are you sure?",
"price-list-details-drawer-prompt-description": "You have unsaved changes, are you sure you want to exit?",
"price-list-details-notification-succes-title": "Price list updated",
"price-list-details-drawer-notification-success-message": "Successfully updated price list",
"price-list-details-drawer-notification-error-title": "An error occurred",
"price-list-details-drawer-title": "Edit Price List Details",
"price-list-details-drawer-cancel-button": "Cancel",
"price-list-details-drawer-save-button": "Save",
"price-list-details-section-prompt-confirm-text": "Delete",
"price-list-details-section-prompt-cancel-text": "Cancel",
"price-list-details-section-prompt-title": "Delete price list",
"price-list-details-section-prompt-description": "Are you sure you want to delete the price list \"{{name}}\"?",
"price-list-details-section-delete-notification-success-title": "Successfully deleted price list",
"price-list-details-section-delete-notification-success-message": "The price list \"{{name}}\" was successfully deleted",
"price-list-details-section-delete-notification-error-title": "Failed to delete price list",
"price-list-details-section-customer-groups": "Customer Groups",
"price-list-details-section-last-edited": "Last edited",
"price-list-details-section-number-of-prices": "Prices",
"price-list-details-section-status-menu-expired": "Expired",
"price-list-details-section-status-menu-draft": "Draft",
"price-list-details-section-status-menu-scheduled": "Scheduled",
"price-list-details-section-status-active": "Active",
"price-list-details-section-status-menu-notification-success-title": "Successfully updated price list status",
"price-list-details-section-status-menu-notification-success-message": "The price list status was successfully updated to {{status}}",
"price-list-details-section-status-menu-notification-error-title": "Failed to update price list status",
"price-list-details-section-status-menu-item-draft": "Draft",
"price-list-details-section-status-menu-item-activate": "Activate",
"price-list-details-menu-item-edit": "Edit details",
"price-list-details-menu-item-delete": "Delete",
"price-list-edit-error": "An error occurred while loading price list. Reload the page and try again. If the issue persists, try again later.",
"price-list-new-form-prompt-title": "Are you sure?",
"price-list-new-form-prompt-exit-description": "You have unsaved changes, are you sure you want to exit?",
"price-list-new-form-prompt-back-description": "You have unsaved changes, are you sure you want to go back?",
"price-list-add-products-modal-success-title": "New prices added",
"price-list-add-products-modal-success-message": "The new prices have been added to the price list.",
"price-list-add-products-modal-error-title": "An error occurred",
"price-list-add-products-modal-back-button-cancel": "Cancel",
"price-list-add-products-modal-back-button": "Back",
"price-list-add-products-modal-next-button-continue": "Continue",
"price-list-add-products-modal-next-button-submit-and-close": "Submit and Close",
"price-list-add-products-modal-next-button-continue-save-prices": "Save Prices",
"price-list-add-products-modal-products-tab": "Choose Products",
"price-list-add-products-modal-prices-tab": "Edit Prices",
"price-list-add-products-modal-error": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.",
"price-list-edit-prices-modal-prompt-title": "Unsaved changes",
"price-list-edit-prices-modal-prompt-exit-description": "You have unsaved changes, are you sure you want to exit?",
"price-list-edit-prices-modal-prompt-back-description": "You have unsaved changes, are you sure you want to go back?",
"price-list-edit-prices-modal-notification-update-error": "An error occurred",
"price-list-edit-prices-modal-notification-remove-error-title": "An error occurred",
"price-list-edit-prices-modal-notification-remove-error-description": "Some prices were not updated correctly. Try again.",
"price-list-edit-prices-modal-notification-update-success-title": "Prices updated",
"price-list-edit-prices-modal-notification-update-success-description": "Successfully updated prices",
"price-list-edit-prices-modal-next-button-save-and-close": "Save and Close",
"price-list-edit-prices-modal-next-button-save": "Save Prices",
"price-list-edit-prices-modal-back-button-cancel": "Cancel",
"price-list-edit-prices-modal-back-button-back": "Back",
"price-list-edit-prices-modal-overview-tab": "Edit Prices",
"price-list-edit-prices-modal-error-loading": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.",
"price-list-prices-section-prompt-title": "Are you sure?",
"price-list-prices-section-prompt-description": "This will permanently delete the product prices from the list",
"price-list-prices-secton-delete-success-title": "Prices deleted",
"price-list-prices-section-delete-success-description_one": "Successfully deleted prices for {{count}} products",
"price-list-prices-section-delete-success-description_other": "Successfully deleted prices for {{count}} products",
"price-list-prices-section-delete-error-title": "An error occurred",
"price-list-prices-section-heading": "Prices",
"price-list-prices-section-search-placeholder": "Search products",
"price-list-prices-section-prices-menu-edit": "Edit prices",
"price-list-prices-section-prices-menu-add": "Add products",
"price-list-prices-section-table-load-error": "An error occured while fetching the products. Try to reload the page, or if the issue persists, try again later.",
"price-list-prices-section-bar-count_one": "{{count}} selected",
"price-list-prices-section-bar-count_other": "{{count}} selected",
"price-list-prices-section-edit-command": "Edit",
"price-list-prices-section-delete-command": "Delete",
"price-list-prices-section-select-all-checkbox-label": "Select all products on the current page",
"price-list-prices-section-select-checkbox-label": "Select row",
"price-list-prices-section-table-product": "Product",
"price-list-prices-section-table-thumbnail-alt": "{{title}} thumbnail",
"price-list-prices-section-table-collection": "Collection",
"price-list-prices-section-table-variants": "Variants",
"price-list-details-form-type-heading": "Type",
"price-list-details-form-type-description": "Choose the type of price list you want to create.",
"price-list-details-form-type-label-sale": "Sale",
"price-list-details-form-type-hint-sale": "Use this if you are creating a sale.",
"price-list-details-form-type-label-override": "Override",
"price-list-details-form-type-hint-override": "Use this if you are overriding prices.",
"price-list-details-form-general-heading": "General",
"price-list-details-form-general-description": "Choose a title and description for the price list.",
"price-list-details-form-general-name-label": "Name",
"price-list-details-form-general-name-placeholder": "Black Friday Sale",
"price-list-details-form-general-description-label": "Description",
"price-list-details-form-general-description-placeholder": "Prices for the Black Friday sale...",
"price-list-details-form-tax-inclusive-label": "Tax inclusive prices",
"price-list-details-form-tax-inclusive-hint": "Choose to make all prices in this list inclusive of tax.",
"price-list-details-form-dates-starts-at-heading": "Price list has a start date?",
"price-list-details-form-dates-starts-at-description": "Schedule the price overrides to activate in the future.",
"price-list-details-form-dates-starts-at-label": "Start date",
"price-list-details-form-ends-at-heading": "Price list has an expiry date?",
"price-list-details-form-ends-at-description": "Schedule the price overrides to deactivate in the future.",
"price-list-details-form-ends-at-label": "End date",
"price-list-details-form-customer-groups-name": "Name",
"price-list-details-form-customer-groups-members": "Members",
"price-list-details-form-customer-groups-error": "An error occurred while loading customer groups. Reload the page and try again. If the issue persists, try again later.",
"price-list-details-form-customer-groups-no-groups": "No customer groups found.",
"price-list-details-form-customer-groups-heading": "Customer availability",
"price-list-details-form-customer-groups-description": "Specify which customer groups the price overrides should apply for.",
"price-list-details-form-customer-groups-content-heading": "Customer Groups",
"price-list-details-form-customer-groups-search-placeholder": "Search",
"price-list-prices-form-products-error": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.",
"price-list-prices-form-heading": "Edit prices",
"price-list-prices-form-variant": "Variant",
"price-list-prices-form-sku": "SKU",
"price-list-prices-form-prices": "Prices",
"price-list-prices-form-prices-variant-count_one": "{{count}} variants",
"price-list-prices-form-prices-variant-count_other": "{{count}} variants",
"price-list-prices-form-add-prices-button": "Add prices",
"price-list-prices-form-prices-count_one": "{{count}} prices",
"price-list-prices-form-prices-count_other": "{{count}} prices",
"price-list-product-prices-form-invalid-data-title": "Invalid data",
"price-list-product-prices-form-invalid-data-body": "The data you pasted contains values that are not numbers.",
"price-list-product-prices-form-column-visibility-button": "View",
"price-list-product-prices-form-column-visibility-currencies-label": "Currencies",
"price-list-product-prices-form-column-visibility-regions-label": "Regions",
"price-list-product-prices-form-column-product-label": "Product",
"price-list-product-prices-form-column-currencies-price-label": "Price {{code}}",
"price-list-product-prices-form-column-regions-price-label": "Price {{code}}",
"price-list-products-form-select-all": "Select all products on the current page",
"price-list-products-form-select-row": "Select row",
"price-list-products-form-product-label": "Product",
"price-list-products-form-product-thumbnail": "{{title}} thumbnail",
"price-list-products-form-collection-label": "Collection",
"price-list-products-form-sales-channels-label": "Availability",
"price-list-products-form-sales-channels-value": "{{first}} + {{remaining}} more",
"price-list-products-form-status-label": "Status",
"price-list-products-form-inventory-label": "Inventory",
"price-list-products-form-inventory-value": "{{totalStock}} in stock across {{variants}} variants",
"price-list-products-form-loading": "Loading products",
"price-list-products-form-error": "An error occurred while loading products. Reload the page and try again. If the issue persists, try again later.",
"price-list-products-form-no-products": "No products found.",
"price-list-products-form-heading": "Choose products",
"price-list-products-form-search-placeholder": "Search",
"price-list-new-form-notification-success-title": "Price list created",
"price-list-new-form-notification-success-message": "Successfully created price list",
"price-list-new-form-notification-error-title": "An error occurred",
"price-list-new-form-next-button-save-and-publish": "Save and Publish",
"price-list-new-form-next-button-save": "Save Prices",
"price-list-new-form-next-button-continue": "Continue",
"price-list-new-form-back-button-cancel": "Cancel",
"price-list-new-form-back-button-back": "Back",
"price-list-new-form-details-tab": "Create Price List",
"price-list-new-form-products-tab": "Choose Products",
"price-list-new-form-prices-tab": "Edit Prices",
"price-list-new-form-save-as-draft": "Save as Draft",
"price-list-new-form-error-loading-products": "An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later.",
"components-success": "Success",
"components-successfully-updated-category-tree": "Successfully updated category tree",
"components-error": "Error",
@@ -1509,6 +1589,7 @@
"batch-job-failed-to-delete-the-csv-file": "Failed to delete the CSV file",
"batch-job-failed-to-cancel-the-batch-job": "Failed to cancel the batch job",
"batch-job-products-list": "products list",
"batch-job-unsure-about-how-to-arrange-your-list": "Unsure about how to arrange your list?",
"batch-job-download-template": "Download the template below to ensure you are following the correct format.",
"batch-job-imports-description": "Through imports you can add or update products. To update existing products/variants you must set an existing id in the Product/Variant id columns. If the value is unset a new record will be created. You will be asked for confirmation before we import products.",
"products-filters": "Filters",
@@ -0,0 +1,187 @@
import {
Hint as HintComponent,
Label as LabelComponent,
clx,
} from "@medusajs/ui"
import * as LabelPrimitives from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from "react"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form"
const Provider = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const Field = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within a FormField")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formErrorMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const Item = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={clx("flex flex-col space-y-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
})
Item.displayName = "Form.Item"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root>
>(({ className, ...props }, ref) => {
const { formItemId } = useFormField()
return (
<LabelComponent
ref={ref}
className={clx(className)}
htmlFor={formItemId}
weight="plus"
{...props}
/>
)
})
Label.displayName = "Form.Label"
const Control = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formErrorMessageId } =
useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formErrorMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
Control.displayName = "Form.Control"
const Hint = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<HintComponent
ref={ref}
id={formDescriptionId}
className={className}
{...props}
/>
)
})
Hint.displayName = "Form.Hint"
const ErrorMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formErrorMessageId } = useFormField()
const msg = error ? String(error?.message) : children
if (!msg) {
return null
}
return (
<HintComponent
ref={ref}
id={formErrorMessageId}
className={className}
variant={error ? "error" : "info"}
{...props}
>
{msg}
</HintComponent>
)
})
ErrorMessage.displayName = "Form.ErrorMessage"
const Form = Object.assign(Provider, {
Item,
Label,
Control,
Hint,
ErrorMessage,
Field,
})
export { Form }
@@ -0,0 +1 @@
export * from "./form"
@@ -8,8 +8,8 @@ import React, {
useState,
} from "react"
import Button from "../../fundamentals/button"
import { useWindowDimensions } from "../../../hooks/use-window-dimensions"
import Button from "../../fundamentals/button"
type FilterDropdownContainerProps = {
submitFilters: () => void
@@ -17,6 +17,9 @@ type FilterDropdownContainerProps = {
triggerElement: ReactNode
}
/**
* @deprecated Use `FilterMenu` instead
*/
const FilterDropdownContainer = ({
submitFilters,
clearFilters,
@@ -1,21 +1,24 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible"
import * as RadixPopover from "@radix-ui/react-popover"
import { addHours, atMidnight, dateToUnixTimestamp } from "../../../utils/time"
import { useEffect, useMemo, useState } from "react"
import { addHours, atMidnight, dateToUnixTimestamp } from "../../../utils/time"
import ArrowRightIcon from "../../fundamentals/icons/arrow-right-icon"
import { CalendarComponent } from "../../atoms/date-picker/date-picker"
import CheckIcon from "../../fundamentals/icons/check-icon"
import ChevronUpIcon from "../../fundamentals/icons/chevron-up"
import { DateFilters } from "../../../utils/filters"
import InputField from "../input"
import Spinner from "../../atoms/spinner"
import clsx from "clsx"
import moment from "moment"
import { DateFilters } from "../../../utils/filters"
import { CalendarComponent } from "../../atoms/date-picker/date-picker"
import Spinner from "../../atoms/spinner"
import ArrowRightIcon from "../../fundamentals/icons/arrow-right-icon"
import CheckIcon from "../../fundamentals/icons/check-icon"
import ChevronUpIcon from "../../fundamentals/icons/chevron-up"
import InputField from "../input"
const DAY_IN_SECONDS = 86400
/**
* @deprecated Use `FilterMenu` instead
*/
const FilterDropdownItem = ({
filterTitle,
options,
@@ -1,7 +1,7 @@
import { trim } from "lodash"
import React from "react"
import Button from "../../fundamentals/button"
import InputField from "../input"
import { trim } from "lodash"
type SaveFilterItemProps = {
saveFilter: () => void
@@ -9,6 +9,9 @@ type SaveFilterItemProps = {
setName: (name: string) => void
}
/**
* @deprecated Use `FilterMenu` instead
*/
const SaveFilterItem: React.FC<SaveFilterItemProps> = ({
saveFilter,
setName,
@@ -0,0 +1,496 @@
import {
Adjustments,
CheckMini,
ChevronUpDown,
XMarkMini,
} from "@medusajs/icons"
import { DateComparisonOperator } from "@medusajs/medusa"
import {
Popover,
Badge,
Button,
DatePicker,
DateRange,
Select,
Switch,
clx,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import * as React from "react"
import { useTranslation } from "react-i18next"
interface FilterMenuProps
extends React.ComponentPropsWithoutRef<typeof Popover> {
onClearFilters: () => void
}
interface FilterMenuContextValue {
onClearFilters: () => void
}
const FilterMenuContext = React.createContext<FilterMenuContextValue | null>(
null
)
const useFilterMenuContext = () => {
const context = React.useContext(FilterMenuContext)
if (!context) {
throw new Error(
"FilterMenu compound components cannot be rendered outside the FilterMenu component"
)
}
return context
}
const Root = ({ children, onClearFilters, ...props }: FilterMenuProps) => {
const { t } = useTranslation()
return (
<Popover {...props}>
<Popover.Trigger data-filter-component={true} asChild>
<Button variant="secondary" className="relative overflow-visible">
<Adjustments className="text-ui-fg-subtle" />
{t("filter-menu-trigger", "View")}
</Button>
</Popover.Trigger>
<FilterMenuContext.Provider
value={React.useMemo(
() => ({
onClearFilters,
}),
[onClearFilters]
)}
>
{children}
</FilterMenuContext.Provider>
</Popover>
)
}
Root.displayName = "FilterMenu"
const Content = ({
children,
sideOffset = 8,
side = "bottom",
alignOffset = -32,
align = "start",
className,
...props
}: React.ComponentPropsWithoutRef<typeof Popover.Content>) => {
const { onClearFilters } = useFilterMenuContext()
const { t } = useTranslation()
return (
<Popover.Content
data-filter-component={true}
sideOffset={sideOffset}
side={side}
alignOffset={alignOffset}
align={align}
className={clx(
"shadow-elevation-flyout bg-ui-bg-base min-w-[320px] rounded-lg",
className
)}
{...props}
>
{children}
<FilterMenu.Seperator />
<div className="px-3 py-2" {...props}>
<Button
variant="secondary"
type="button"
className="w-full"
onClick={onClearFilters}
>
{t("filter-menu-clear-button", "Clear")}
</Button>
</div>
</Popover.Content>
)
}
Content.displayName = "FilterMenu.Content"
type SelectItemProps = {
name: string
placeholder?: string
options: {
value: string
label: string
}[]
value?: string[]
onChange: (value: string[]) => void
}
type SelectCheckItemProps = {
checked?: boolean
onCheckedChange: (checked: boolean) => void
label: string
}
const SelectCheckItem = ({
checked,
onCheckedChange,
label,
}: SelectCheckItemProps) => {
const onClick = React.useCallback(() => {
onCheckedChange(!checked)
}, [checked, onCheckedChange])
return (
<button
type="button"
onClick={onClick}
className="bg-ui-bg-base focus:bg-ui-bg-base-hover hover:bg-ui-bg-base-hover transition-fg txt-compact-xsmall-plus flex w-full cursor-pointer items-center gap-x-2 rounded-md px-3 py-2 outline-none"
>
<div className="flex h-5 w-5 items-center justify-center">
{checked && <CheckMini />}
</div>
<span>{label}</span>
</button>
)
}
const SelectItem = ({
name,
placeholder = "Select filter",
options,
value = [],
onChange,
}: SelectItemProps) => {
const menuRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const [open, setOpen] = React.useState(false)
const { t } = useTranslation()
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (open && e.key === "ArrowDown") {
e.preventDefault()
const next = e.target
? (e.target as HTMLElement).nextElementSibling
: null
if (next) {
;(next as HTMLElement).focus()
} else {
const first = menuRef.current?.firstElementChild
if (first) {
;(first as HTMLElement).focus()
}
}
}
if (open && e.key === "ArrowUp") {
e.preventDefault()
const prev = e.target
? (e.target as HTMLElement).previousElementSibling
: null
if (prev) {
;(prev as HTMLElement).focus()
} else {
const last = menuRef.current?.lastElementChild
if (last) {
;(last as HTMLElement).focus()
}
}
}
if (open && e.key === "Escape") {
e.preventDefault()
setOpen(false)
/**
* Attempt to restore focus to the trigger element
*/
triggerRef.current?.focus()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
}, [open])
const placeholderValue = placeholder
? placeholder
: t("filter-menu-select-item-default-placeholder", "Select filter")
return (
<Popover open={open} data-filter-component={true}>
<div className="flex w-full items-center justify-between px-3 py-2">
<span className="txt-compact-xsmall-plus text-ui-fg-base">{name}</span>
<Popover.Trigger
ref={triggerRef}
className={clx(
"bg-ui-bg-field hover:bg-ui-bg-field-hover transition-fg shadow-buttons-neutral flex h-8 w-[152px] items-center gap-x-2 rounded-md py-1 pr-2 text-left",
"focus:!shadow-borders-interactive-with-active data-[state=open]:!shadow-borders-interactive-with-active outline-none",
{
"pl-2": value.length === 0,
"pl-1": value.length > 0,
}
)}
onClick={() => {
setOpen(!open)
}}
asChild
>
<button>
{value.length > 0 ? (
<Badge className="flex items-center tabular-nums">
{value.length}
<span
className="bg-ui-tag-neutral-bg focus:bg-ui-tag-neutral-bg-hover transition-fg flex h-5 w-5 items-center justify-center rounded-sm outline-none"
role="button"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation()
onChange([])
}
}}
>
<span className="sr-only">
{t(
"filter-menu-select-item-clear-button",
"Clear the selected options"
)}
</span>
<XMarkMini />
</span>
</Badge>
) : null}
<div className="flex-1">
{value.length > 0 ? (
<span className="text-ui-fg-base txt-compact-medium">
{t("filter-menu-select-item-selected", "Selected")}
</span>
) : (
<span className="text-ui-fg-muted txt-compact-medium">
{placeholderValue}
</span>
)}
</div>
<ChevronUpDown className="text-ui-fg-muted" />
</button>
</Popover.Trigger>
</div>
<Popover.Content
ref={menuRef}
sideOffset={8}
side="right"
align="start"
alignOffset={-8}
className="shadow-elevation-flyout bg-ui-bg-base min-w-[220px] overflow-hidden rounded-lg p-1"
data-filter-child={true}
>
{options.map((opt, index) => {
return (
<SelectCheckItem
checked={value.includes(opt.value)}
onCheckedChange={() => {
if (value.includes(opt.value)) {
const newValue = value.filter((v) => v !== opt.value)
onChange(newValue)
return
}
const newValue = [...value, opt.value]
onChange(newValue)
}}
key={index}
label={opt.label}
/>
)
})}
</Popover.Content>
</Popover>
)
}
SelectItem.displayName = "FilterMenu.SelectItem"
type DateItemProps = {
name: string
value?: DateComparisonOperator
onChange: (value?: DateComparisonOperator) => void
}
const DateItem = ({ name, value, onChange }: DateItemProps) => {
const [operator, setOperator] = React.useState<string>("lt")
const [expanded, setExpanded] = React.useState<boolean>(!!value)
const { t } = useTranslation()
const handleExpandChange = (expanded: boolean) => {
setExpanded(expanded)
if (!expanded) {
onChange(undefined)
}
}
const handleSingleDateChange = React.useCallback(
(date?: Date) => {
if (!date) {
onChange(undefined)
return
}
const payload: DateComparisonOperator = {
lt: undefined,
gt: undefined,
gte: undefined,
lte: undefined,
}
switch (operator) {
case "lt":
payload.lt = date
break
case "gt":
payload.gt = date
break
default:
break
}
onChange(payload)
},
[operator, onChange]
)
const handleOperatorChange = React.useCallback(
(value: string) => {
setOperator(value)
onChange(undefined)
},
[onChange]
)
const handleRangeDateChange = React.useCallback(
(range?: DateRange) => {
if (!range) {
onChange(undefined)
return
}
const payload: DateComparisonOperator = {
gte: range.from,
lte: range.to,
}
payload.gte = range.from
payload.lte = range.to
onChange(payload)
},
[onChange]
)
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between">
<span className="txt-compact-xsmall-plus text-ui-fg-base">{name}</span>
<Switch checked={expanded} onCheckedChange={handleExpandChange} />
</div>
<Collapsible.Root open={expanded}>
<Collapsible.Content data-filter-component={true} className="z-[1]">
<div className="flex flex-col gap-y-2 py-2">
<Select
size="small"
onValueChange={handleOperatorChange}
value={operator}
>
<Select.Trigger>
<Select.Value />
</Select.Trigger>
<Select.Content className="z-10">
<Select.Item value="lt">
{t("filter-menu-date-item-before", "Before")}
</Select.Item>
<Select.Item value="gt">
{t("filter-menu-date-item-after", "After")}
</Select.Item>
<Select.Item value="btwn">
{t("filter-menu-date-item-between", "Between")}
</Select.Item>
</Select.Content>
</Select>
{["lt", "gt"].includes(operator) ? (
<DatePicker
size="small"
value={
value
? value[operator as keyof DateComparisonOperator]
: undefined
}
onChange={handleSingleDateChange}
/>
) : (
<DatePicker
size="small"
mode="range"
value={
value
? {
from: value.gte,
to: value.lte,
}
: undefined
}
onChange={handleRangeDateChange}
/>
)}
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
)
}
DateItem.displayName = "FilterMenu.DateItem"
const Seperator = React.forwardRef<
React.ElementRef<typeof Popover.Seperator>,
React.ComponentPropsWithoutRef<typeof Popover.Seperator>
>(({ className, ...props }, ref) => {
return (
<Popover.Seperator
ref={ref}
className={clx("border-ui-border-base border-t", className)}
{...props}
/>
)
})
Seperator.displayName = "FilterMenu.Seperator"
const FilterMenu = Object.assign(Root, {
Content,
SelectItem,
DateItem,
Seperator,
})
export { FilterMenu }
@@ -0,0 +1 @@
export * from "./filter-menu"
@@ -43,7 +43,7 @@ const ProductAttributesSection = ({ product }: Props) => {
value={product.width}
/>
<Attribute
attribute={t("product-attributes-section-height", "Length")}
attribute={t("product-attributes-section-length", "Length")}
value={product.length}
/>
<Attribute
@@ -7,7 +7,6 @@ import NotificationBell from "../../molecules/notification-bell"
import SearchBar from "../../molecules/search-bar"
import ActivityDrawer from "../activity-drawer"
import MailDialog from "../help-dialog"
import LanguageMenu from "../../molecules/language-menu"
const Topbar: React.FC = () => {
const {
@@ -29,7 +28,7 @@ const Topbar: React.FC = () => {
)
return (
<div className="min-h-topbar max-h-topbar pr-xlarge pl-base bg-grey-0 border-grey-20 sticky top-0 z-40 flex w-full items-center justify-between border-b">
<div className="min-h-topbar max-h-topbar pr-xlarge pl-base bg-grey-0 border-grey-20 flex w-full items-center justify-between border-b">
<SearchBar />
<div className="flex items-center">
<Button
@@ -1,16 +0,0 @@
const PricingImportCSV =
"data:text/csv;charset=utf-8," +
`Product Variant ID,SKU,Price EUR,Price NA [USD]
,MEDUSA-SWEAT-SMALL,15,13.5
variant_1234,,15,13.5
`
export function downloadPricingImportCSVTemplate() {
const encodedUri = encodeURI(PricingImportCSV)
const link = document.createElement("a")
link.setAttribute("href", encodedUri)
link.setAttribute("download", "product-import-template.csv")
document.body.appendChild(link)
link.click()
}
@@ -1,206 +0,0 @@
import { useEffect, useState } from "react"
import { BatchJob } from "@medusajs/medusa"
import {
useAdminBatchJob,
useAdminCancelBatchJob,
useAdminConfirmBatchJob,
useAdminCreateBatchJob,
useAdminDeleteFile,
useAdminUploadFile,
} from "medusa-react"
import { useTranslation } from "react-i18next"
import UploadModal from "../../../components/organisms/upload-modal"
import useNotification from "../../../hooks/use-notification"
import { usePolling } from "../../../providers/polling-provider"
import { downloadPricingImportCSVTemplate } from "./download-template"
/**
* Hook returns a batch job. The endpoint is polled every 2s while the job is processing.
*/
function useImportBatchJob(batchJobId?: string) {
const [batchJob, setBatchJob] = useState<BatchJob>()
const isBatchJobProcessing =
batchJob?.status === "created" || batchJob?.status === "confirmed"
const { batch_job } = useAdminBatchJob(batchJobId!, {
enabled: !!batchJobId,
refetchInterval: isBatchJobProcessing ? 2000 : false,
})
useEffect(() => {
setBatchJob(batch_job)
}, [batch_job])
return batchJob
}
/**
* Import products container interface.
*/
type ImportPricesProps = {
priceListId: string
handleClose: () => void
}
/**
* Product import modal container.
*/
function ImportPrices(props: ImportPricesProps) {
const { t } = useTranslation()
const [fileKey, setFileKey] = useState<string | undefined>()
const [batchJobId, setBatchJobId] = useState<string | undefined>()
const notification = useNotification()
const { resetInterval } = usePolling()
const { mutateAsync: deleteFile } = useAdminDeleteFile()
const { mutateAsync: uploadFile } = useAdminUploadFile()
const { mutateAsync: createBatchJob } = useAdminCreateBatchJob()
const { mutateAsync: cancelBathJob } = useAdminCancelBatchJob(batchJobId!)
const { mutateAsync: confirmBatchJob } = useAdminConfirmBatchJob(batchJobId!)
const batchJob = useImportBatchJob(batchJobId)
const isUploaded = !!fileKey
const isPreprocessed = !!batchJob?.result
const hasError = batchJob?.status === "failed"
const progress = isPreprocessed
? (batchJob!.result?.advancement_count || 0) /
(batchJob!.result?.count || 1)
: undefined
const status = hasError
? "Error occurred while processing"
: isPreprocessed
? undefined
: isUploaded
? "Preprocessing..."
: "Uploading..."
/**
* Confirm job on submit.
*/
const onSubmit = async () => {
await confirmBatchJob()
notification(
"Success",
"Import confirmed for processing. Progress info is available in the activity drawer.",
"success"
)
props.handleClose()
}
/**
* Upload file and use returned file key to create a batch job.
*/
const processUpload = async (file: File) => {
try {
const res = await uploadFile(file as any)
const _fileKey = res.uploads![0].key
setFileKey(_fileKey)
const batchJob = await createBatchJob({
dry_run: true,
context: { fileKey: _fileKey, price_list_id: props.priceListId },
type: "price-list-import",
})
resetInterval()
setBatchJobId(batchJob.batch_job.id)
} catch (e) {
notification("Error", "Import failed.", "error")
if (fileKey) {
await deleteFile({ file_key: fileKey })
}
}
}
/**
* Returns create/update counts from stat descriptor.
*/
const getSummary = (): { toCreate: number } | undefined => {
if (!batchJob) {
return undefined
}
const res = batchJob.result?.stat_descriptors?.[0].message.match(/\d+/g)
if (!res) {
return undefined
}
return {
toCreate: Number(res[0]),
}
}
/**
* When file upload is removed, delete file from the bucket and cancel batch job.
*/
const onFileRemove = async () => {
try {
if (fileKey) {
deleteFile({ file_key: fileKey })
}
cancelBathJob()
} catch (e) {
console.log(e)
}
}
/**
* Cleanup file if batch job isn't confirmed.
*/
const onClose = () => {
props.handleClose()
if (
!["confirmed", "completed", "canceled", "failed"].includes(
batchJob?.status || ""
)
) {
if (fileKey) {
deleteFile({ file_key: fileKey })
}
if (batchJobId) {
cancelBathJob()
}
}
}
return (
<UploadModal
type="prices"
fileTitle={t("batch-job-price-list-prices", "Price List prices")}
description1Text={t(
"batch-job-upload-a-csv-file-with-variants",
"Upload a CSV file with variants and prices to update your price list. Note that any existing prices will be deleted."
)}
description2Title={t(
"batch-job-unsure-about-how-to-arrange-your-list",
"Unsure about how to arrange your list?"
)}
description2Text={t(
"batch-job-download-the-template-file-below-and-update-your-prices",
"Download the template file below and update your prices"
)}
status={status}
progress={progress}
canImport={isPreprocessed}
onSubmit={onSubmit}
onClose={onClose}
summary={getSummary()}
onFileRemove={onFileRemove}
processUpload={processUpload}
onDownloadTemplate={downloadPricingImportCSVTemplate}
/>
)
}
export default ImportPrices
@@ -0,0 +1 @@
export * from "./product-filter-menu"
@@ -0,0 +1 @@
export * from "./product-filter-menu"
@@ -0,0 +1,52 @@
import { DateComparisonOperator } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { FilterMenu } from "../../../../components/molecules/filter-menu"
type ProductFilter = {
created_at?: DateComparisonOperator
updated_at?: DateComparisonOperator
}
type ProductFilterMenuProps = {
onClearFilters: () => void
onFilterChange: (filter: ProductFilter) => void
value?: ProductFilter
}
const ProductFilterMenu = ({
value,
onClearFilters,
onFilterChange,
}: ProductFilterMenuProps) => {
const { t } = useTranslation()
const onDateChange = (
key: "created_at" | "updated_at",
date?: DateComparisonOperator
) => {
onFilterChange({
...value,
[key as keyof ProductFilter]: date,
})
}
return (
<FilterMenu onClearFilters={onClearFilters}>
<FilterMenu.Content>
<FilterMenu.DateItem
name={t("price-list-product-filter-created-at", "Created at")}
value={value?.created_at}
onChange={(obj) => onDateChange("created_at", obj)}
/>
<FilterMenu.Seperator />
<FilterMenu.DateItem
name={t("price-list-product-filter-updated-at", "Updated at")}
value={value?.updated_at}
onChange={(obj) => onDateChange("updated_at", obj)}
/>
</FilterMenu.Content>
</FilterMenu>
)
}
export { ProductFilterMenu, type ProductFilter }
@@ -1,91 +0,0 @@
import { useAdminPriceList } from "medusa-react"
import { useNavigate, useParams } from "react-router-dom"
import { useTranslation } from "react-i18next"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import RawJSON from "../../../components/organisms/raw-json"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
import { mapPriceListToFormValues } from "../pricing-form/form/mappers"
import { PriceListFormProvider } from "../pricing-form/form/pricing-form-context"
import Header from "./sections/header"
import PricesDetails from "./sections/prices-details"
const PricingDetails = () => {
const { t } = useTranslation()
const { id } = useParams()
const navigate = useNavigate()
const { price_list, isLoading, error } = useAdminPriceList(id!)
const { getWidgets } = useWidgets()
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the product is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !price_list) {
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
<div className="pb-large">
<BackButton
label={t("details-back-to-pricing", "Back to Pricing")}
path="/a/pricing"
className="mb-xsmall"
/>
<PriceListFormProvider priceList={mapPriceListToFormValues(price_list)}>
<div className="gap-y-xsmall flex flex-col">
{getWidgets("price_list.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={price_list}
injectionZone="price_list.details.before"
widget={w}
/>
)
})}
<Header priceList={price_list} />
<PricesDetails id={price_list?.id} />
{getWidgets("price_list.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={price_list}
injectionZone="price_list.details.after"
widget={w}
/>
)
})}
<RawJSON
data={price_list}
title={t("details-raw-price-list", "Raw price list")}
/>
</div>
</PriceListFormProvider>
</div>
)
}
export default PricingDetails
@@ -1,150 +0,0 @@
import { useAdminDeletePriceList } from "medusa-react"
import moment from "moment"
import * as React from "react"
import { useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import Fade from "../../../../components/atoms/fade-wrapper"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import BodyCard from "../../../../components/organisms/body-card"
import {
formatPriceListGroups,
getPriceListStatus,
} from "../../../../components/templates/price-list-table/utils"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import PriceListForm from "../../pricing-form"
import { ViewType } from "../../pricing-form/types"
const Header = ({ priceList }) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = React.useState(false)
return (
<HeadingBodyCard priceList={priceList} setIsOpen={setIsOpen}>
<div className="flex gap-12">
{priceList.customer_groups.length ? (
<div className="border-grey-20 border-l pl-6">
<span className="inter-base-regular text-grey-50">
{t("sections-customer-groups", "Customer groups")}
</span>
<p className="inter-base-regular text-grey-90">
<PriceListCustomerGroupsFormatter
groups={priceList.customer_groups}
/>
</p>
</div>
) : null}
<div className="border-grey-20 border-l pl-6">
<span className="inter-base-regular text-grey-50">
{t("sections-last-edited", "Last edited")}
</span>
<p className="inter-base-regular text-grey-90">
{moment(priceList.updated_at).format("ddd, D MMM YYYY")}
</p>
</div>
<div className="border-grey-20 border-l pl-6">
<span className="inter-base-regular text-grey-50">
{t("sections-price-overrides", "Price overrides")}
</span>
<p className="inter-base-regular text-grey-90">
{priceList.prices?.length}
</p>
</div>
</div>
{isOpen && (
<Fade isVisible={isOpen} isFullScreen={true}>
<PriceListForm
id={priceList.id}
onClose={() => setIsOpen(false)}
viewType={ViewType.EDIT_DETAILS}
/>
</Fade>
)}
</HeadingBodyCard>
)
}
const PriceListCustomerGroupsFormatter = ({ groups }) => {
const { t } = useTranslation()
const [group, other] = formatPriceListGroups(groups.map((cg) => cg.name))
return (
<>
{group}
{other && (
<span className="text-grey-40">
{" "}
+ {other} {t("sections-more", "more")}
</span>
)}
</>
)
}
const HeadingBodyCard = ({ priceList, setIsOpen, ...props }) => {
const { t } = useTranslation()
const dialog = useImperativeDialog()
const navigate = useNavigate()
const notification = useNotification()
const deletePriceList = useAdminDeletePriceList(priceList?.id)
const onDelete = async () => {
const shouldDelete = await dialog({
heading: t("sections-delete-price-list-heading", "Delete Price list"),
text: t(
"sections-are-you-sure-you-want-to-delete-this-price-list",
"Are you sure you want to delete this price list?"
),
})
if (shouldDelete) {
deletePriceList.mutate(undefined, {
onSuccess: () => {
notification(
t("sections-success", "Success"),
t(
"sections-price-list-deleted-successfully",
"Price list deleted successfully"
),
"success"
)
navigate("/a/pricing/")
},
onError: (err) => {
notification("Ooops", getErrorMessage(err), "error")
},
})
}
}
const actionables = [
{
label: t("sections-edit-price-list-details", "Edit price list details"),
onClick: () => setIsOpen(true),
icon: <EditIcon size={20} />,
},
{
label: t("sections-delete-price-list", "Delete price list"),
onClick: onDelete,
variant: "danger" as const,
icon: <TrashIcon size={20} />,
},
]
return (
<BodyCard
actionables={actionables}
forceDropdown
className="min-h-[200px]"
status={
<div className="gap-x-2xsmall flex items-center">
{getPriceListStatus(priceList)}
</div>
}
title={priceList.name}
subtitle={priceList.description}
{...props}
/>
)
}
export default Header
@@ -1,146 +0,0 @@
import { MoneyAmount, Product } from "@medusajs/medusa"
import { useAdminStore, useAdminUpdatePriceList } from "medusa-react"
import { useParams } from "react-router-dom"
import { useTranslation } from "react-i18next"
import Button from "../../../../../../components/fundamentals/button"
import { CollapsibleTree } from "../../../../../../components/molecules/collapsible-tree"
import Modal from "../../../../../../components/molecules/modal"
import LayeredModal, {
useLayeredModal,
} from "../../../../../../components/molecules/modal/layered-modal"
import PriceOverrides from "../../../../../../components/templates/price-overrides"
import useNotification from "../../../../../../hooks/use-notification"
import { mergeExistingWithDefault } from "../../../utils"
import { mapToPriceList } from "./mappers"
import ProductVariantLeaf from "./product-variant-leaf"
type EditPricesOverridesModalProps = {
product: Product
close: () => void
}
const EditPricesOverridesModal = ({
close,
product,
}: EditPricesOverridesModalProps) => {
const { t } = useTranslation()
const context = useLayeredModal()
const { id: priceListId } = useParams()
const updatePriceList = useAdminUpdatePriceList(priceListId || "")
const { store } = useAdminStore()
const defaultPrices = store?.currencies.map((curr) => ({
currency_code: curr.code,
amount: 0,
})) as MoneyAmount[]
const notification = useNotification()
const getOnClick = (variant) => () =>
context.push({
title: t(
"edit-prices-overrides-edit-price-overrides",
"Edit price overrides"
),
onBack: () => context.pop(),
view: (
<PriceOverrides
prices={mergeExistingWithDefault(
variant.prices.filter((pr) => pr.price_list_id),
defaultPrices
)}
isEdit
defaultVariant={variant}
variants={product.variants}
onClose={close}
onSubmit={(values) => {
const updatedPrices = mapToPriceList(values, variant.id)
updatePriceList.mutate(
{
prices: updatedPrices,
},
{
onSuccess: () => {
context.pop()
close()
notification(
t("edit-prices-overrides-success", "Success"),
t(
"edit-prices-overrides-price-overrides-updated",
"Price overrides updated"
),
"success"
)
},
}
)
}}
/>
),
})
return (
<LayeredModal isLargeModal context={context} handleClose={close}>
<Modal.Body className="flex h-[calc(100vh-134px)] flex-col">
<Modal.Header handleClose={close}>
<h1 className="inter-xlarge-semibold">
Price overrides{" "}
<span className="text-grey-50 inter-xlarge-regular truncate">
({product.title})
</span>
</h1>
</Modal.Header>
<Modal.Content className="flex-1">
<CollapsibleTree>
<CollapsibleTree.Parent>
<div>
<img
src={product.thumbnail || undefined}
className="rounded-base h-5 w-4"
/>
</div>
<span className="inter-small-semibold">{product.title}</span>
</CollapsibleTree.Parent>
<CollapsibleTree.Content>
{product.variants.map((variant) => (
<CollapsibleTree.Leaf>
<ProductVariantLeaf
key={variant.id}
onClick={getOnClick(variant)}
variant={variant}
prices={variant.prices.filter((pr) => pr.price_list_id)}
/>
</CollapsibleTree.Leaf>
))}
</CollapsibleTree.Content>
</CollapsibleTree>
</Modal.Content>
<Modal.Footer>
<div className="flex h-8 w-full justify-end">
<Button
variant="ghost"
className="text-small rounded-rounded mr-2 w-32 justify-center"
size="large"
onClick={close}
>
{t("edit-prices-overrides-cancel", "Cancel")}
</Button>
<Button
disabled
size="large"
className="text-small rounded-rounded w-32 justify-center"
variant="primary"
>
{t("edit-prices-overrides-save", "Save")}
</Button>
</div>
</Modal.Footer>
</Modal.Body>
</LayeredModal>
)
}
export default EditPricesOverridesModal
@@ -1,21 +0,0 @@
import { AdminPostPriceListsPriceListPriceListReq } from "@medusajs/medusa"
import { PriceOverridesFormValues } from "../../../../../../components/templates/price-overrides"
import xorObjFields from "../../../../../../utils/xorObjFields"
export const mapToPriceList = (
values: PriceOverridesFormValues,
variantId: string
) => {
return values.prices
.map((price) => ({
id: price.id,
...xorObjFields(price, "currency_code", "region_id"),
amount: price.amount,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
variant_id: variantId,
}))
.filter(
(pr) => pr.amount > 0
) as AdminPostPriceListsPriceListPriceListReq["prices"]
}
@@ -1,54 +0,0 @@
import { MoneyAmount, ProductVariant } from "@medusajs/medusa"
import * as React from "react"
import { useTranslation } from "react-i18next"
import Button from "../../../../../../components/fundamentals/button"
import ChevronRightIcon from "../../../../../../components/fundamentals/icons/chevron-right-icon"
type ProductVariantLeafProps = {
onClick: React.ButtonHTMLAttributes<HTMLButtonElement>["onClick"]
variant: ProductVariant
prices: MoneyAmount[]
}
const ProductVariantLeaf = ({
variant,
prices,
onClick,
}: ProductVariantLeafProps) => {
const { t } = useTranslation()
const { title, sku } = variant
const hasPrices = prices.length > 0
return (
<div className="flex flex-1 items-center">
<div className="truncate">
<span>{title}</span>
{sku && <span className="text-grey-50 ml-xsmall">(SKU: {sku})</span>}
</div>
<div className="text-grey-50 flex flex-1 items-center justify-end">
<div className="text-grey-50 mr-xsmall">
{hasPrices ? (
<span>
{t("edit-prices-overrides-count", "{{count}}", {
count: prices.length,
})}
</span>
) : (
<span className="inter-small-semibold text-orange-40">
{t("edit-prices-overrides-add-prices", "Add prices")}
</span>
)}
</div>
<Button
variant="ghost"
size="small"
className="h-[32px] w-[32px]"
onClick={onClick}
>
<ChevronRightIcon className="text-grey-40" />
</Button>
</div>
</div>
)
}
export default ProductVariantLeaf
@@ -1,103 +0,0 @@
import { Product } from "@medusajs/medusa"
import { debounce } from "lodash"
import { useAdminPriceListProducts } from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import Tooltip from "../../../../../components/atoms/tooltip"
import EditIcon from "../../../../../components/fundamentals/icons/edit-icon"
import InfoIcon from "../../../../../components/fundamentals/icons/info-icon"
import TrashIcon from "../../../../../components/fundamentals/icons/trash-icon"
import FocusModal from "../../../../../components/molecules/modal/focus-modal"
import useQueryFilters from "../../../../../hooks/use-query-filters"
import FormHeader from "../../../pricing-form/form-header/"
import ProductPrices from "../../../pricing-form/sections/product-prices"
import { ViewType } from "../../../pricing-form/types"
import { merge } from "./utils"
const defaultQueryFilters = {
limit: 50,
offset: 0,
}
const EditPrices = ({ close, id }) => {
const { t } = useTranslation()
const params = useQueryFilters(defaultQueryFilters)
const [selectedProducts, setSelectedProducts] = React.useState<Product[]>([])
const { products, isLoading } = useAdminPriceListProducts(
id,
params.queryObject
)
const handleSearch = (query: string) => {
params.setQuery(query)
}
React.useEffect(() => {
setSelectedProducts((state) => merge(products, state))
}, [products, merge])
const debouncedSearch = React.useMemo(() => debounce(handleSearch, 300), [])
return (
<FocusModal>
<FocusModal.Header>
<FormHeader id={id} viewType={ViewType.EDIT_PRICES} onClose={close} />
</FocusModal.Header>
<FocusModal.Main>
<div className="mb-[25%] flex justify-center">
<div className="medium:w-7/12 large:w-6/12 small:w-4/5 w-full pt-16">
<h1 className="inter-xlarge-semibold">
{t("prices-details-edit-prices", "Edit prices")}
</h1>
<div className="mt-7">
<div className="flex items-center gap-1.5">
<h6 className="inter-large-semibold">
{t("prices-details-prices", "Prices")}
</h6>
<Tooltip content="info tooltip">
<InfoIcon size={16} className="text-grey-40" />
</Tooltip>
</div>
<span className="inter-base-regular text-grey-50">
{t(
"prices-details-you-will-be-able-to-override-the-prices-for-the-products-you-add-here",
"You will be able to override the prices for the products you add here"
)}
</span>
</div>
<ProductPrices
products={selectedProducts}
setProducts={setSelectedProducts}
isLoading={isLoading}
onSearch={debouncedSearch}
onFileChosen={close}
getVariantActions={VariantActions}
/>
</div>
</div>
</FocusModal.Main>
</FocusModal>
)
}
const VariantActions = (product: Product) => {
const { t } = useTranslation()
return [
{
label: t("prices-details-edit-prices", "Edit prices"),
icon: <EditIcon size={20} />,
onClick: () => {
// open grid ui
},
},
{
label: t("prices-details-remove-from-list", "Remove from list"),
icon: <TrashIcon size={20} />,
onClick: () => {
// missing core support
},
variant: "danger" as const,
},
]
}
export default EditPrices
@@ -1,52 +0,0 @@
import { Product } from "@medusajs/medusa"
import * as React from "react"
import { useTranslation } from "react-i18next"
import Fade from "../../../../../components/atoms/fade-wrapper"
import EditIcon from "../../../../../components/fundamentals/icons/edit-icon"
import UploadIcon from "../../../../../components/fundamentals/icons/upload-icon"
import BodyCard from "../../../../../components/organisms/body-card"
import useToggleState from "../../../../../hooks/use-toggle-state"
import EditPrices from "./edit-prices"
import EditPricesOverridesModal from "./edit-prices-overrides"
import ImportPrices from "../../../batch-job/import"
import PricesTable from "./prices-table"
const Prices = ({ id }) => {
const { t } = useTranslation()
const { state: showEdit, open: openEdit, close: closeEdit } = useToggleState()
const [showUpload, openUpload, closeUpload] = useToggleState()
const [selectedProduct, setSelectedProduct] = React.useState<Product | null>(
null
)
const actionables = [
{
label: t("prices-details-edit-manually", "Edit manually"),
onClick: openEdit,
icon: <EditIcon size={20} />,
},
{
label: t("prices-details-import-price-list", "Import price list"),
onClick: openUpload,
icon: <UploadIcon size={20} />,
},
]
return (
<BodyCard title="Prices" actionables={actionables} forceDropdown>
<PricesTable id={id} selectProduct={setSelectedProduct} />
<Fade isVisible={showEdit} isFullScreen={true}>
<EditPrices close={closeEdit} id={id} />{" "}
</Fade>
{showUpload && (
<ImportPrices priceListId={id} handleClose={() => closeUpload()} />
)}
{selectedProduct && (
<EditPricesOverridesModal
product={selectedProduct}
close={() => setSelectedProduct(null)}
/>
)}
</BodyCard>
)
}
export default Prices
@@ -1,153 +0,0 @@
import { Product } from "@medusajs/medusa"
import {
useAdminDeletePriceListProductPrices,
useAdminPriceListProducts,
} from "medusa-react"
import { HeaderGroup, Row } from "react-table"
import { useTranslation } from "react-i18next"
import CancelIcon from "../../../../../../components/fundamentals/icons/cancel-icon"
import EditIcon from "../../../../../../components/fundamentals/icons/edit-icon"
import Table from "../../../../../../components/molecules/table"
import { SelectableTable } from "../../../../../../components/templates/selectable-table"
import useNotification from "../../../../../../hooks/use-notification"
import useQueryFilters from "../../../../../../hooks/use-query-filters"
import { getErrorMessage } from "../../../../../../utils/error-messages"
import usePricesColumns from "./use-columns"
const DEFAULT_PAGE_SIZE = 9
const defaultQueryProps = {
offset: 0,
limit: DEFAULT_PAGE_SIZE,
}
type PricesTableProps = {
id: string
selectProduct: (product: Product) => void
}
const PricesTable = ({ id, selectProduct }: PricesTableProps) => {
const { t } = useTranslation()
const params = useQueryFilters(defaultQueryProps)
const {
products,
isLoading,
count = 0,
} = useAdminPriceListProducts(id, params.queryObject)
const columns = usePricesColumns()
return (
<div className="flex h-full w-full flex-col justify-between overflow-y-auto">
<SelectableTable
columns={columns}
data={products || []}
renderRow={({ row }: { row: Row<Product> }) => {
const handleSelect = () => {
selectProduct(row.original)
}
return (
<PricesTableRow
{...row.getRowProps()}
product={row.original}
priceListId={id}
onClick={handleSelect}
className="hover:bg-grey-5 hover:cursor-pointer"
>
{row.cells.map((cell) => {
return (
<Table.Cell {...cell.getCellProps()}>
{cell.render("Cell")}
</Table.Cell>
)
})}
</PricesTableRow>
)
}}
renderHeaderGroup={ProductHeader}
isLoading={isLoading}
totalCount={count}
options={{
enableSearch: false,
searchPlaceholder: t(
"prices-table-search-by-name-or-sku",
"Search by name or SKU..."
),
}}
{...params}
/>
</div>
)
}
const ProductHeader = ({
headerGroup,
}: {
headerGroup: HeaderGroup<Product>
}) => {
return (
<Table.HeadRow {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((col) => (
<Table.HeadCell {...col.getHeaderProps(col.getSortByToggleProps())}>
{col.render("Header")}
</Table.HeadCell>
))}
</Table.HeadRow>
)
}
const PricesTableRow = ({
children,
priceListId,
product,
onClick,
...props
}) => {
const { t } = useTranslation()
const notification = useNotification()
const deleteProductPrices = useAdminDeletePriceListProductPrices(
priceListId,
product.id
)
const actions = [
{
label: t("prices-table-edit-prices", "Edit prices"),
icon: <EditIcon size={20} />,
onClick: onClick,
},
{
label: t("prices-table-remove-product", "Remove product"),
icon: <CancelIcon size={20} />,
variant: "danger" as const,
onClick: () => {
deleteProductPrices.mutate(undefined, {
onSuccess: () => {
notification(
t("prices-table-success", "Success"),
t(
"prices-table-deleted-prices-of-product",
"Deleted prices of product: {{title}}",
{ title: product.title }
),
"success"
)
},
onError: (err) =>
notification(
t("prices-table-error", "Error"),
getErrorMessage(err),
"error"
),
})
},
},
]
return (
<Table.Row {...props} actions={actions}>
{children}
</Table.Row>
)
}
export default PricesTable
@@ -1,64 +0,0 @@
import { Product } from "@medusajs/medusa"
import * as React from "react"
import { Column } from "react-table"
import { useTranslation } from "react-i18next"
import ImagePlaceholder from "../../../../../../components/fundamentals/image-placeholder"
import Table from "../../../../../../components/molecules/table"
const usePricesColumns = () => {
const { t } = useTranslation()
const columns = React.useMemo<Column<Product>[]>(
() => [
{
Header: <div className="pl-4">{t("prices-table-name", "Name")}</div>,
accessor: "title",
Cell: ({ row: { original } }) => (
<div className="flex items-center pl-4">
<div className="my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{original.thumbnail ? (
<img
src={original.thumbnail}
className="rounded-soft h-full object-cover"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col">
<span>{original.title}</span>
</div>
</div>
),
},
{
Header: (
<div className="w-[400px]">
{t("prices-table-collection", "Collection")}
</div>
),
accessor: "collection",
Cell: ({ cell: { value } }) => (
<Table.Cell>
{value?.title ? (
value.title
) : (
<span className="text-grey-40">
{t("prices-table-no-collection", "No collection")}
</span>
)}
</Table.Cell>
),
},
{
Header: t("prices-table-variants", "Variants"),
Cell: ({ row: { original } }) => (
<Table.Cell>{original.variants.length}</Table.Cell>
),
},
],
[]
)
return columns
}
export default usePricesColumns
@@ -1,23 +0,0 @@
import { Idable } from "../../../../../types/shared"
export const merge = <T extends Idable, U extends Idable>(
l1: T[] = [],
l2: U[] = []
) => {
const normalizedListA = normalize(l1, "id")
const normalizedListB = normalize(l2, "id")
const merged = Object.values<T | U>(normalizedListA)
Object.values(normalizedListB).forEach((element: any) => {
if (!normalizedListA[element.id]) {
merged.push(element)
}
})
return merged
}
const normalize = <T extends {}>(arr: T[], key) => {
return arr.reduce(
(obj, curr) => ((obj[curr[key]] = { ...curr }), obj),
{} as T
)
}
@@ -1,11 +0,0 @@
export const mergeExistingWithDefault = (
variantPrices: any[] = [],
defaultPrices
) => {
return defaultPrices.map((pr) => {
const price = variantPrices.find(
(vpr) => vpr?.currency_code === pr.currency_code
)
return price || pr
})
}
@@ -0,0 +1,184 @@
import { PriceList } from "@medusajs/medusa"
import { Button, Drawer, usePrompt } from "@medusajs/ui"
import { useAdminUpdatePriceList } from "medusa-react"
import * as React from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../components/helpers/form"
import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import {
PriceListDetailsForm,
type PriceListDetailsSchema,
type PriceListType,
} from "../../forms/price-list-details-form"
type PriceListDetailsDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
priceList: PriceList
}
type PriceListDetailsFormValues = {
details: PriceListDetailsSchema
}
const PriceListDetailsDrawer = ({
open,
onOpenChange,
priceList,
}: PriceListDetailsDrawerProps) => {
const { mutateAsync, isLoading } = useAdminUpdatePriceList(priceList.id)
const { t } = useTranslation()
const form = useForm<PriceListDetailsFormValues>({
defaultValues: getDefaultValues(priceList),
})
const { isFeatureEnabled } = useFeatureFlag()
const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing")
const {
reset,
formState: { isDirty },
handleSubmit,
} = form
React.useEffect(() => {
if (open) {
reset(getDefaultValues(priceList))
}
}, [priceList, open, reset])
const prompt = usePrompt()
const notification = useNotification()
const onStateChange = React.useCallback(
async (open: boolean) => {
if (isDirty) {
const response = await prompt({
title: t("price-list-details-drawer-prompt-title", "Are you sure?"),
description: t(
"price-list-details-drawer-prompt-description",
"You have unsaved changes, are you sure you want to exit?"
),
})
if (!response) {
onOpenChange(true)
return
}
}
reset()
onOpenChange(open)
},
[isDirty, t, reset, prompt, onOpenChange]
)
const onSubmit = handleSubmit(async (values) => {
await mutateAsync(
{
type: values.details.type.value as PriceListType,
name: values.details.general.name,
description: values.details.general.description,
starts_at: values.details.dates.starts_at ?? null,
ends_at: values.details.dates.ends_at ?? null,
includes_tax: isTaxInclPricesEnabled
? values.details.general.tax_inclusive
: undefined,
customer_groups: values.details.customer_groups.ids.map((id) => ({
id,
})),
},
{
onSuccess: () => {
notification(
t(
"price-list-details-notification-succes-title",
"Price list updated"
),
t(
"price-list-details-drawer-notification-success-message",
"Successfully updated price list"
),
"success"
)
onOpenChange(false)
},
onError: (err) => {
notification(
t(
"price-list-details-drawer-notification-error-title",
"An error occurred"
),
getErrorMessage(err),
"error"
)
},
}
)
})
return (
<Drawer open={open} onOpenChange={onStateChange}>
<Form {...form}>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>
{t("price-list-details-drawer-title", "Edit Price List Details")}
</Drawer.Title>
</Drawer.Header>
<Drawer.Body className="overflow-y-auto">
<PriceListDetailsForm
form={nestedForm(form, "details")}
layout="drawer"
enableTaxToggle={isTaxInclPricesEnabled}
/>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">
{t("price-list-details-drawer-cancel-button", "Cancel")}
</Button>
</Drawer.Close>
<Button onClick={onSubmit} isLoading={isLoading}>
{t("price-list-details-drawer-save-button", "Save")}
</Button>
</Drawer.Footer>
</Drawer.Content>
</Form>
</Drawer>
)
}
const getDefaultValues = (priceList: PriceList): PriceListDetailsFormValues => {
return {
details: {
type: {
value: priceList.type,
},
general: {
name: priceList.name,
description: priceList.description,
tax_inclusive: priceList.includes_tax,
},
customer_groups: {
ids: priceList.customer_groups?.map((cg) => cg.id) ?? [],
},
dates: {
starts_at: priceList.starts_at
? new Date(priceList.starts_at)
: undefined,
ends_at: priceList.ends_at ? new Date(priceList.ends_at) : undefined,
},
},
}
}
export { PriceListDetailsDrawer as EditDetailsDrawer }
@@ -0,0 +1,355 @@
import {
EllipseGreenSolid,
EllipseGreySolid,
EllipseOrangeSolid,
EllipseRedSolid,
EllipsisHorizontal,
PencilSquare,
Trash,
} from "@medusajs/icons"
import type { PriceList } from "@medusajs/medusa"
import {
Badge,
Button,
Container,
DropdownMenu,
Heading,
IconButton,
Text,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { format } from "date-fns"
import { useAdminDeletePriceList, useAdminUpdatePriceList } from "medusa-react"
import * as React from "react"
import { useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { PriceListStatus } from "../../forms/price-list-details-form"
import { EditDetailsDrawer } from "./details-drawer"
type PriceListDetailsSectionProps = {
priceList: PriceList
}
const PriceListDetailsSection = ({
priceList,
}: PriceListDetailsSectionProps) => {
const [open, setOpen] = React.useState(false)
const toggleDrawer = () => {
setOpen(!open)
}
const { t } = useTranslation()
const prompt = usePrompt()
const notification = useNotification()
const navigate = useNavigate()
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
const onDeletePriceList = async () => {
const name = priceList.name
const confirmText =
t("price-list-details-section-prompt-confirm-text", "Delete") ?? undefined
const cancelText =
t("price-list-details-section-prompt-cancel-text", "Cancel") ?? undefined
const res = await prompt({
title: t("price-list-details-section-prompt-title", "Delete price list"),
description: t(
"price-list-details-section-prompt-description",
`Are you sure you want to delete the price list "{{name}}"?`,
{
name,
}
),
confirmText,
cancelText,
})
if (!res) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
notification(
t(
"price-list-details-section-delete-notification-success-title",
"Successfully deleted price list"
),
t(
"price-list-details-section-delete-notification-success-message",
`The price list "{{name}}" was successfully deleted`,
{ name }
),
`success`
)
navigate(`/a/price-lists`, { replace: true })
},
onError: (err) => {
notification(
t(
"price-list-details-section-delete-notification-error-title",
"Failed to delete price list"
),
getErrorMessage(err),
`error`
)
},
})
}
return (
<div>
<Container>
<div className="flex flex-col gap-y-1 pb-6">
<div className="flex items-center justify-between">
<Heading>{priceList.name}</Heading>
<div className="flex items-center gap-x-2">
<PriceListStatusMenu
priceListId={priceList.id}
status={priceList.status}
endsAt={priceList.ends_at}
startsAt={priceList.starts_at}
/>
<PriceListMenu
onOpenDrawer={toggleDrawer}
onDelete={onDeletePriceList}
/>
</div>
</div>
<Text>{priceList?.description}</Text>
</div>
<div className="small:grid-cols-2 medium:grid-cols-3 grid grid-cols-1 gap-6">
<div className="border-ui-border-base flex flex-col gap-y-1 border-l px-4">
<Text size="base" className="text-ui-fg-subtle">
{t(
"price-list-details-section-customer-groups",
"Customer Groups"
)}
</Text>
{(priceList.customer_groups?.length ?? 0) > 0 ? (
<div className="flex items-center justify-between">
<Text size="large">
{priceList.customer_groups
.slice(0, 2)
.map((cg) => cg.name)
.join(", ")}
</Text>
{(priceList.customer_groups?.length || 0) > 2 && (
<Tooltip
content={
<div className="flex flex-col">
{priceList.customer_groups.slice(2).map((group) => {
return (
<Text size="small" key={group.id}>
{group.name}
</Text>
)
})}
</div>
}
>
<Badge size="small" className="cursor-default">
+{priceList.customer_groups.length - 2}
</Badge>
</Tooltip>
)}
</div>
) : (
<Text size="large" className="text-ui-fg-muted">
-
</Text>
)}
</div>
<div className="border-ui-border-base flex flex-col gap-y-1 border-l px-4">
<Text size="base" className="text-ui-fg-subtle">
{t("price-list-details-section-last-edited", "Last edited")}
</Text>
<Text size="large">
{format(new Date(priceList.updated_at), "EEE d, MMM yyyy")}
</Text>
</div>
<div className="border-ui-border-base flex flex-col gap-y-1 border-l px-4">
<Text size="base" className="text-ui-fg-subtle">
{t("price-list-details-section-number-of-prices", "Prices")}
</Text>
<Text size="large">{priceList.prices.length ?? 0}</Text>
</div>
</div>
</Container>
<EditDetailsDrawer
onOpenChange={setOpen}
open={open}
priceList={priceList}
/>
</div>
)
}
type PriceListStatusMenuProps = {
status: PriceList["status"]
endsAt: PriceList["ends_at"]
startsAt: PriceList["starts_at"]
priceListId: string
}
const PriceListStatusMenu = ({
status,
endsAt,
startsAt,
priceListId,
}: PriceListStatusMenuProps) => {
const { t } = useTranslation()
const notification = useNotification()
const { mutateAsync } = useAdminUpdatePriceList(priceListId)
const isActive = status === "active"
const isExpired = endsAt ? new Date(endsAt) < new Date() : false
const isScheduled = startsAt ? new Date(startsAt) > new Date() : false
const statusText = React.useMemo(() => {
if (isExpired) {
return t("price-list-details-section-status-menu-expired", "Expired")
}
if (status === "draft") {
return t("price-list-details-section-status-menu-draft", "Draft")
}
if (isScheduled) {
return t("price-list-details-section-status-menu-scheduled", "Scheduled")
}
return t("price-list-details-section-status-active", "Active")
}, [status, t, isExpired, isScheduled])
const statusDot = React.useMemo(() => {
if (isExpired) {
return <EllipseRedSolid />
}
if (status === "draft") {
return <EllipseGreySolid />
}
if (isScheduled) {
return <EllipseOrangeSolid />
}
return <EllipseGreenSolid />
}, [status, isExpired, isScheduled])
const onUpdateStatus = async () => {
const newStatus = isActive ? PriceListStatus.DRAFT : PriceListStatus.ACTIVE
mutateAsync(
{
status: newStatus,
},
{
onSuccess: () => {
notification(
t(
"price-list-details-section-status-menu-notification-success-title",
"Successfully updated price list status"
),
t(
"price-list-details-section-status-menu-notification-success-message",
`The price list status was successfully updated to {{status}}`,
{ status: newStatus }
),
`success`
)
},
onError: (err) => {
notification(
t(
"price-list-details-section-status-menu-notification-error-title",
"Failed to update price list status"
),
getErrorMessage(err),
`error`
)
},
}
)
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild disabled={isExpired}>
<Button
variant="secondary"
className="!text-ui-fg-base flex items-center !gap-x-1.5 pl-2.5 pr-3 capitalize"
>
{statusDot}
{statusText}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
side="bottom"
className="min-w-[200px]"
onClick={onUpdateStatus}
>
<DropdownMenu.Item>
{isActive ? <EllipseGreySolid /> : <EllipseGreenSolid />}
<span>
{isActive
? t("price-list-details-section-status-menu-item-draft", "Draft")
: t(
"price-list-details-section-status-menu-item-activate",
"Activate"
)}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
type PriceListMenuProps = {
onDelete: () => Promise<void>
onOpenDrawer: () => void
}
const PriceListMenu = ({ onDelete, onOpenDrawer }: PriceListMenuProps) => {
const { t } = useTranslation()
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton>
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="bottom">
<DropdownMenu.Item onClick={onOpenDrawer}>
<PencilSquare className="text-ui-fg-subtle" />
<span className="ml-2">
{t("price-list-details-menu-item-edit", "Edit details")}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={onDelete}>
<Trash className="text-ui-fg-subtle" />
<span className="ml-2">
{t("price-list-details-menu-item-delete", "Delete")}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
export { PriceListDetailsSection as PriceListGeneralSection }
@@ -0,0 +1 @@
export * from "./details-section"
@@ -0,0 +1,79 @@
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import { Container, Text } from "@medusajs/ui"
import { useAdminPriceList } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import Spacer from "../../../components/atoms/spacer"
import WidgetContainer from "../../../components/extensions/widget-container"
import { useWidgets } from "../../../providers/widget-provider"
import { PriceListGeneralSection } from "./details"
import { PriceListPricesSection } from "./prices"
const PriceListEdit = () => {
const { id } = useParams<{ id: string }>()
const { t } = useTranslation()
const { getWidgets } = useWidgets()
const { price_list, isLoading, isError } = useAdminPriceList(id!, {
enabled: !!id,
})
if (isLoading) {
return (
<Container className="flex min-h-[320px] items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</Container>
)
}
if (isError || !price_list) {
return (
<Container className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-x-2">
<ExclamationCircle className="text-ui-fg-base" />
<Text className="text-ui-fg-subtle">
{t(
"price-list-edit-error",
"An error occurred while loading price list. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
</Container>
)
}
return (
<>
<div className="flex flex-col gap-y-2">
{getWidgets("product.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"price_list.details.before"}
widget={w}
entity={price_list}
/>
)
})}
<PriceListGeneralSection priceList={price_list} />
<PriceListPricesSection priceList={price_list} />
{getWidgets("product.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"price_list.details.after"}
widget={w}
entity={price_list}
/>
)
})}
<Spacer />
</div>
</>
)
}
export { PriceListEdit }
@@ -0,0 +1 @@
export * from "./edit"
@@ -0,0 +1,640 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import type { PriceList, Product } from "@medusajs/medusa"
import {
Button,
FocusModal,
ProgressStatus,
ProgressTabs,
Text,
usePrompt,
} from "@medusajs/ui"
import * as React from "react"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { useAdminCreatePriceListPrices } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../components/helpers/form"
import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import {
PriceListPricesForm,
PricePayload,
getDbSafeAmount,
priceListPricesSchema,
usePricesFormData,
} from "../../forms/price-list-prices-form"
import {
PriceListProductPricesForm,
priceListProductPricesSchema,
type PriceListProductPricesSchema,
} from "../../forms/price-list-product-prices-form"
import {
PriceListProductsForm,
priceListProductsSchema,
} from "../../forms/price-list-products-form"
type AddProductsModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
priceList: PriceList
/**
* Products that are already in the price list
*/
productIds: string[]
}
enum Tab {
PRODUCTS = "products",
PRICES = "prices",
EDIT = "edit",
}
const addProductsSchema = z.object({
products: priceListProductsSchema,
prices: priceListPricesSchema,
})
type AddProductsFormValue = z.infer<typeof addProductsSchema>
type StepStatus = {
[key in Tab]: ProgressStatus
}
const AddProductsModal = ({
open,
onOpenChange,
priceList,
productIds,
}: AddProductsModalProps) => {
const [product, setProduct] = React.useState<Product | null>(null)
const [selectedIds, setSelectedIds] = React.useState<string[]>([])
const [tab, setTab] = React.useState<Tab>(Tab.PRODUCTS)
const [status, setStatus] = React.useState<StepStatus>({
[Tab.PRODUCTS]: "not-started",
[Tab.PRICES]: "not-started",
[Tab.EDIT]: "not-started",
})
const { t } = useTranslation()
const promptTitle = t("price-list-new-form-prompt-title", "Are you sure?")
const promptExitDescription = t(
"price-list-new-form-prompt-exit-description",
"You have unsaved changes, are you sure you want to exit?"
)
const promptBackDescription = t(
"price-list-new-form-prompt-back-description",
"You have unsaved changes, are you sure you want to go back?"
)
const prompt = usePrompt()
const notification = useNotification()
const { isFeatureEnabled } = useFeatureFlag()
const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing")
const form = useForm<AddProductsFormValue>({
resolver: zodResolver(addProductsSchema),
defaultValues: {
products: { ids: [] },
prices: { products: {} },
},
})
const {
trigger,
handleSubmit,
setValue,
getValues,
reset,
formState: { isDirty },
} = form
const {
control: editControl,
handleSubmit: handleEditSubmit,
reset: resetEdit,
setValue: setEditValue,
getValues: getEditValues,
formState: { isDirty: isEditDirty },
} = useForm<PriceListProductPricesSchema>({
resolver: zodResolver(priceListProductPricesSchema),
})
const { mutateAsync, isLoading: isSubmitting } =
useAdminCreatePriceListPrices(priceList.id)
const { isError, isLoading, isNotFound, regions, currencies } =
usePricesFormData({
productIds: selectedIds,
})
const onCloseModal = React.useCallback(() => {
onOpenChange(false)
setTab(Tab.PRODUCTS)
setStatus({
[Tab.PRODUCTS]: "not-started",
[Tab.PRICES]: "not-started",
[Tab.EDIT]: "not-started",
})
resetEdit()
reset({
products: { ids: [] },
prices: { products: {} },
})
}, [onOpenChange, reset, resetEdit])
const onModalStateChange = React.useCallback(
async (open: boolean) => {
if (!open && (isDirty || isEditDirty)) {
const response = await prompt({
title: promptTitle,
description: promptExitDescription,
})
if (!response) {
onOpenChange(true)
return
}
onCloseModal()
}
onOpenChange(open)
},
[
isDirty,
isEditDirty,
promptTitle,
promptExitDescription,
prompt,
onCloseModal,
onOpenChange,
]
)
const onSavePriceEdit = handleEditSubmit((data) => {
if (!product) {
return
}
setValue(`prices.products.${product.id}`, data, {
shouldDirty: true,
shouldTouch: true,
})
setProduct(null)
resetEdit(undefined, {
keepDirty: false,
keepTouched: false,
})
setStatus((prev) => ({
...prev,
[Tab.PRICES]: "in-progress",
}))
setTab(Tab.PRICES)
})
const onSubmit = handleSubmit(async (data) => {
const prices: PricePayload[] = []
for (const productId of Object.keys(data.prices.products)) {
const product = data.prices.products[productId]
for (const variantId of Object.keys(product.variants)) {
const variant = product.variants[variantId]
if (variant.currency) {
for (const currencyCode of Object.keys(variant.currency)) {
const { amount } = variant.currency[currencyCode]
if (!amount) {
continue
}
if (!currencyCode) {
continue
}
const dbSafeAmount = getDbSafeAmount(
currencyCode,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
amount: dbSafeAmount,
variant_id: variantId,
currency_code: currencyCode,
}
prices.push(payload)
}
}
if (variant.region) {
for (const regionId of Object.keys(variant.region)) {
const { amount } = variant.region[regionId]
if (!amount) {
continue
}
if (!regionId) {
continue
}
const dbSafeAmount = getDbSafeAmount(
regions.find((r) => r.id === regionId)!.currency_code,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
amount: dbSafeAmount,
variant_id: variantId,
region_id: regionId,
}
prices.push(payload)
}
}
}
}
await mutateAsync(
{
prices,
},
{
onSuccess: () => {
notification(
t(
"price-list-add-products-modal-success-title",
"New prices added"
),
t(
"price-list-add-products-modal-success-message",
"The new prices have been added to the price list."
),
"success"
)
onCloseModal()
},
onError: (err) => {
notification(
t("price-list-add-products-modal-error-title", "An error occurred"),
getErrorMessage(err),
"error"
)
},
}
)
})
const onSetProduct = React.useCallback(
(product: Product | null) => {
if (!product) {
setProduct(null)
setTab(Tab.PRICES)
return
}
const defaultValues = getValues(`prices.products.${product.id}`)
resetEdit(defaultValues)
setProduct(product)
setTab(Tab.EDIT)
},
[resetEdit, getValues]
)
/**
* When exiting the "Edit" tab, we need to check
* if the user has unsaved changes. If they do,
* we need to prompt them whether they want to
* continue or not.
*/
const onExitProductPrices = React.useCallback(
async (tab = Tab.PRICES) => {
if (isEditDirty) {
const res = await prompt({
title: promptTitle,
description: promptBackDescription,
})
if (!res) {
return
}
}
const defaultValues = product
? getValues(`prices.products.${product.id}`)
: undefined
setTab(tab)
setProduct(null)
resetEdit(defaultValues, {
keepDirty: false,
keepTouched: false,
})
},
[
prompt,
resetEdit,
getValues,
product,
isEditDirty,
promptTitle,
promptBackDescription,
]
)
/**
* If the current tab is edit, we need to
* check if the user wants to exit the edit
* tab or if they want to save the changes
* before continuing.
*/
const onTabChange = React.useCallback(
async (value: Tab) => {
if (tab === Tab.EDIT) {
await onExitProductPrices(value)
return
}
setTab(value)
},
[tab, onExitProductPrices]
)
/**
* Callback for ensuring that we don't submit prices
* for products that the user has unselected.
*/
const onUpdateSelectedProductIds = React.useCallback(
(ids: string[]) => {
setSelectedIds((prev) => {
/**
* If the previous ids are the same as the new ids,
* we need to clear the values the old ids that are no
* longer selected.
*/
for (const id of prev) {
if (!ids.includes(id)) {
setValue(`prices.products.${id}`, { variants: {} })
}
}
return ids
})
},
[setValue]
)
const onCancelPriceEdit = React.useCallback(async () => {
if (!product) {
setTab(Tab.PRICES)
return
}
if (isEditDirty) {
const res = await prompt({
title: "Are you sure?",
description: "You have unsaved changes, are you sure you want to exit?",
})
if (!res) {
return
}
}
const defaultValues = getValues(`prices.products.${product.id}`)
setProduct(null)
resetEdit(defaultValues)
setTab(Tab.PRICES)
}, [getValues, resetEdit, prompt, isEditDirty, product])
/**
* Callback for validating the products form.
*/
const onValidateProducts = React.useCallback(async () => {
const result = await trigger("products")
if (!result) {
setStatus((prev) => ({
...prev,
[Tab.PRODUCTS]: "in-progress",
}))
return
}
const ids = getValues("products.ids")
onUpdateSelectedProductIds(ids)
setTab(Tab.PRICES)
setStatus((prev) => ({
...prev,
[Tab.PRODUCTS]: "completed",
}))
}, [trigger, getValues, onUpdateSelectedProductIds])
const onBack = React.useCallback(async () => {
switch (tab) {
case Tab.PRODUCTS:
onModalStateChange(false)
break
case Tab.PRICES:
setTab(Tab.PRODUCTS)
break
case Tab.EDIT:
await onCancelPriceEdit()
break
}
}, [tab, onCancelPriceEdit, onModalStateChange])
const backButtonText = React.useMemo(() => {
switch (tab) {
case Tab.PRODUCTS:
return t("price-list-add-products-modal-back-button-cancel", "Cancel")
default:
return t("price-list-add-products-modal-back-button", "Back")
}
}, [tab, t])
const onNext = React.useCallback(async () => {
switch (tab) {
case Tab.PRODUCTS:
onValidateProducts()
break
case Tab.PRICES:
await onSubmit()
break
case Tab.EDIT:
await onSavePriceEdit()
break
}
}, [onValidateProducts, onSavePriceEdit, onSubmit, tab])
const nextButtonText = React.useMemo(() => {
switch (tab) {
case Tab.PRODUCTS:
return t(
"price-list-add-products-modal-next-button-continue",
"Continue"
)
case Tab.PRICES:
return t(
"price-list-add-products-modal-next-button-submit-and-close",
"Submit and Close"
)
case Tab.EDIT:
return t(
"price-list-add-products-modal-next-button-continue-save-prices",
"Save Prices"
)
}
}, [tab, t])
return (
<FocusModal open={open} onOpenChange={onModalStateChange}>
<ProgressTabs
value={tab}
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<FocusModal.Content>
<FocusModal.Header className="flex w-full items-center justify-between">
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
value={Tab.PRODUCTS}
className="w-full max-w-[200px]"
status={status[Tab.PRODUCTS]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t(
"price-list-add-products-modal-products-tab",
"Choose Products"
)}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.PRICES}
className="w-full max-w-[200px]"
status={status[Tab.PRICES]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("price-list-add-products-modal-prices-tab", "Edit Prices")}
</span>
</ProgressTabs.Trigger>
{product && (
<ProgressTabs.Trigger
value={Tab.EDIT}
className="w-full max-w-[200px]"
status={isEditDirty ? "in-progress" : "not-started"}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{product.title}
</span>
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
<div className="flex flex-1 items-center justify-end gap-x-2">
<Button
disabled={isSubmitting}
variant="secondary"
onClick={onBack}
>
{backButtonText}
</Button>
<Button
type="button"
className="whitespace-nowrap"
isLoading={isSubmitting}
onClick={onNext}
>
{nextButtonText}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto">
<Form {...form}>
<ProgressTabs.Content
value={Tab.PRODUCTS}
className="h-full w-full"
>
<PriceListProductsForm
form={nestedForm(form, "products")}
productIds={productIds}
/>
</ProgressTabs.Content>
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</div>
) : isError || isNotFound ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-ui-fg-subtle flex items-center gap-x-2">
<ExclamationCircle />
<Text>
{t(
"price-list-add-products-modal-error",
"An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
</div>
) : (
<React.Fragment>
<ProgressTabs.Content
value={Tab.PRICES}
className="h-full w-full"
>
<PriceListPricesForm
setProduct={onSetProduct}
form={nestedForm(form, "prices")}
productIds={selectedIds}
/>
</ProgressTabs.Content>
{product && (
<ProgressTabs.Content
value={Tab.EDIT}
className="h-full w-full"
>
<PriceListProductPricesForm
product={product}
currencies={currencies}
regions={regions}
control={editControl}
priceListTaxInclusive={priceList.includes_tax}
taxInclEnabled={isTaxInclPricesEnabled}
setValue={setEditValue}
getValues={getEditValues}
/>
</ProgressTabs.Content>
)}
</React.Fragment>
)}
</Form>
</FocusModal.Body>
</FocusModal.Content>
</ProgressTabs>
</FocusModal>
)
}
export { AddProductsModal }
@@ -0,0 +1,619 @@
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import type { PriceList, Product } from "@medusajs/medusa"
import { Button, FocusModal, ProgressTabs, Text, usePrompt } from "@medusajs/ui"
import {
useAdminDeletePriceListPrices,
useAdminUpdatePriceList,
} from "medusa-react"
import * as React from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslation } from "react-i18next"
import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import {
PriceListPricesForm,
getDbSafeAmount,
getDefaultAmount,
priceListPricesSchema,
usePricesFormData,
type PriceListPricesSchema,
type PricePayload,
} from "../../forms/price-list-prices-form"
import {
PriceListProductPricesForm,
priceListProductPricesSchema,
type PriceListProductPricesSchema,
} from "../../forms/price-list-product-prices-form"
type EditPricesModalProps = {
open: boolean
onOpenChange: (open: boolean) => void
priceList: PriceList
productIds: string[]
}
enum Tab {
PRICES = "prices",
EDIT = "edit",
}
const EditPricesModal = ({
open,
onOpenChange,
priceList,
productIds,
}: EditPricesModalProps) => {
const [tab, setTab] = React.useState<Tab>(Tab.PRICES)
const [product, setProduct] = React.useState<Product | null>(null)
const { t } = useTranslation()
const promptTitle = t(
"price-list-edit-prices-modal-prompt-title",
"Are you sure?"
)
const promptExitDescription = t(
"price-list-edit-prices-modal-prompt-exit-description",
"You have unsaved changes, are you sure you want to exit?"
)
const promptBackDescription = t(
"price-list-edit-prices-modal-prompt-back-description",
"You have unsaved changes, are you sure you want to go back?"
)
const prompt = usePrompt()
const notification = useNotification()
const { isFeatureEnabled } = useFeatureFlag()
const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing")
const pricesForm = useForm<PriceListPricesSchema>({
resolver: zodResolver(priceListPricesSchema),
})
const {
handleSubmit: handlePricesSubmit,
reset: resetPrices,
formState: { isDirty: isPricesDirty },
getValues,
setValue,
} = pricesForm
const {
control: productControl,
handleSubmit: handleProductSubmit,
reset: resetProduct,
formState: { isDirty: isProductDirty },
setValue: setProductValue,
getValues: getProductValues,
} = useForm<PriceListProductPricesSchema>({
resolver: zodResolver(priceListProductPricesSchema),
})
const { isLoading, isError, isNotFound, regions, currencies, products } =
usePricesFormData({
productIds,
priceList,
})
const { mutateAsync: updateAsync, isLoading: isSubmitting } =
useAdminUpdatePriceList(priceList.id)
const { mutateAsync: deleteAsync, isLoading: isDeleting } =
useAdminDeletePriceListPrices(priceList.id)
const onModalStateChange = React.useCallback(
async (open: boolean) => {
if (open) {
onOpenChange(open)
return
}
if (isPricesDirty || isProductDirty) {
const res = await prompt({
title: promptTitle,
description: promptExitDescription,
})
if (!res) {
return
}
}
onOpenChange(false)
},
[
prompt,
onOpenChange,
isPricesDirty,
isProductDirty,
promptExitDescription,
promptTitle,
]
)
/**
* Register default values for the price list prices form.
*/
React.useEffect(() => {
if (isLoading || isError || isNotFound || !open) {
return
}
const productData: PriceListPricesSchema = {
products: {},
}
for (const product of products) {
const productPrices: PriceListProductPricesSchema = {
variants: {},
}
for (const variant of product.variants) {
productPrices.variants[variant.id!] = {
currency: {},
region: {},
}
for (const region of regions) {
const existingPrice = priceList.prices.find(
(p) => p.variant_id === variant.id && p.region_id === region.id
)
const amount = existingPrice
? getDefaultAmount(region.currency_code, existingPrice.amount)
: null
productPrices.variants[variant.id!].region![region.id] = {
id: existingPrice ? existingPrice.id : "",
amount: amount ? `${amount}` : "",
}
}
for (const currency of currencies) {
const existingPrice = priceList.prices.find(
(p) =>
p.variant_id === variant.id &&
p.currency_code === currency.code &&
p.region_id === null
)
const amount = existingPrice
? getDefaultAmount(currency.code, existingPrice.amount)
: null
productPrices.variants[variant.id!].currency![currency.code] = {
id: existingPrice ? existingPrice.id : "",
amount: amount ? `${amount}` : "",
}
}
}
productData.products[product.id!] = productPrices
}
resetPrices(productData)
}, [
isLoading,
isError,
isNotFound,
products,
regions,
currencies,
priceList,
resetPrices,
open,
])
const onSetProduct = React.useCallback(
(product: Product | null) => {
if (!product) {
setProduct(null)
setTab(Tab.PRICES)
return
}
const defaultValues = getValues(`products.${product.id}`)
resetProduct(defaultValues)
setProduct(product)
setTab(Tab.EDIT)
},
[resetProduct, getValues]
)
/**
* When exiting the "Edit" tab, we need to check
* if the user has unsaved changes. If they do,
* we need to prompt them whether they want to
* continue or not.
*/
const onExitProductPrices = React.useCallback(
async (tab = Tab.PRICES) => {
if (isProductDirty) {
const res = await prompt({
title: promptTitle,
description: promptBackDescription,
})
if (!res) {
return
}
}
setTab(tab)
setProduct(null)
resetProduct(undefined, {
keepDirty: false,
keepTouched: false,
})
},
[isProductDirty, prompt, promptBackDescription, promptTitle, resetProduct]
)
/**
* If the current tab is edit, we need to
* check if the user wants to exit the edit
* tab or if they want to save the changes
* before continuing.
*/
const onTabChange = React.useCallback(
async (value: Tab) => {
if (tab === Tab.EDIT) {
await onExitProductPrices(value)
return
}
setTab(value)
},
[onExitProductPrices, tab]
)
const onSavePriceEdit = handleProductSubmit((data) => {
if (!product) {
return
}
setValue(`products.${product.id}`, data, {
shouldDirty: true,
shouldTouch: true,
})
setProduct(null)
resetProduct(undefined, {
keepDirty: false,
keepTouched: false,
})
setTab(Tab.PRICES)
})
const onSubmit = handlePricesSubmit(async (data) => {
const update: PricePayload[] = []
const removed: string[] = []
for (const productId of Object.keys(data.products)) {
const product = data.products[productId]
for (const variantId of Object.keys(product.variants)) {
const variant = product.variants[variantId]
if (variant.currency) {
for (const currencyCode of Object.keys(variant.currency)) {
const { id, amount } = variant.currency[currencyCode]
if (!amount) {
if (id) {
removed.push(id)
}
continue
}
if (!currencyCode) {
continue
}
const dbSafeAmount = getDbSafeAmount(
currencyCode,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
id: id ? id : undefined,
amount: dbSafeAmount,
variant_id: variantId,
currency_code: currencyCode,
}
update.push(payload)
}
}
if (variant.region) {
for (const regionId of Object.keys(variant.region)) {
const { id, amount } = variant.region[regionId]
if (!amount) {
if (id) {
removed.push(id)
}
continue
}
if (!regionId) {
continue
}
const dbSafeAmount = getDbSafeAmount(
regions.find((r) => r.id === regionId)!.currency_code,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
id: id ? id : undefined,
amount: dbSafeAmount,
variant_id: variantId,
region_id: regionId,
}
update.push(payload)
}
}
}
}
let updateSuccess = false
await updateAsync(
{
prices: update,
},
{
onSuccess: () => {
updateSuccess = true
},
onError: (err) => {
notification(
t(
"price-list-edit-prices-modal-notification-update-error",
"An error occurred"
),
getErrorMessage(err),
"error"
)
},
}
)
/**
* If the first update failed, we don't want to
* continue with the delete request.
*/
if (!updateSuccess) {
return
}
let removeSuccess = true
if (removed.length > 0) {
await deleteAsync(
{
price_ids: removed,
},
{
onSuccess: () => {
removeSuccess = true
},
onError: () => {
removeSuccess = false
},
}
)
}
/**
* If the delete request failed, we want to
* notify the user that some prices were not
* updated correctly.
*/
if (!removeSuccess) {
notification(
t(
"price-list-edit-prices-modal-notification-remove-error-title",
"An error occurred"
),
t(
"price-list-edit-prices-modal-notification-remove-error-description",
"Some prices were not updated correctly. Try again."
),
"warning"
)
} else {
notification(
t(
"price-list-edit-prices-modal-notification-update-success-title",
"Prices updated"
),
t(
"price-list-edit-prices-modal-notification-update-success-description",
"Successfully updated prices"
),
"success"
)
}
onOpenChange(false)
})
/**
* Depending on the current tab, the next button
* will have different functionality.
*/
const onNext = React.useCallback(async () => {
switch (tab) {
case Tab.PRICES:
await onSubmit()
break
case Tab.EDIT:
await onSavePriceEdit()
break
}
}, [onSubmit, onSavePriceEdit, tab])
const nextButtonText = React.useMemo(() => {
switch (tab) {
case Tab.PRICES:
return t(
"price-list-edit-prices-modal-next-button-save-and-close",
"Save and Close"
)
case Tab.EDIT:
return t("price-list-edit-prices-modal-next-button-save", "Save Prices")
}
}, [tab, t])
/**
* Depending on the current tab, the back button
* will have different functionality.
*/
const onBack = React.useCallback(async () => {
switch (tab) {
case Tab.PRICES:
onModalStateChange(false)
break
case Tab.EDIT:
await onExitProductPrices()
break
}
}, [onModalStateChange, onExitProductPrices, tab])
const backButtonText = React.useMemo(() => {
switch (tab) {
case Tab.PRICES:
return t("price-list-edit-prices-modal-back-button-cancel", "Cancel")
default:
return t("price-list-edit-prices-modal-back-button-back", "Back")
}
}, [tab, t])
return (
<FocusModal open={open} onOpenChange={onModalStateChange}>
<ProgressTabs
value={tab}
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<FocusModal.Content>
<FocusModal.Header className="flex w-full items-center justify-start">
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
value={Tab.PRICES}
disabled={isLoading || isError}
className="w-full max-w-[200px]"
status={isPricesDirty ? "in-progress" : "not-started"}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t(
"price-list-edit-prices-modal-overview-tab",
"Edit Prices"
)}
</span>
</ProgressTabs.Trigger>
{product && (
<ProgressTabs.Trigger
value={Tab.EDIT}
disabled={isLoading || isError}
className="w-full max-w-[200px]"
status={isProductDirty ? "in-progress" : "not-started"}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{product?.title}
</span>
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
<div className="ml-auto flex items-center justify-end gap-x-2">
<Button
variant="secondary"
onClick={onBack}
disabled={isSubmitting || isDeleting}
>
{backButtonText}
</Button>
<Button
onClick={onNext}
className="whitespace-nowrap"
isLoading={isSubmitting || isDeleting}
>
{nextButtonText}
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="h-full w-full overflow-y-auto">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</div>
) : isError || isNotFound ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-ui-fg-subtle flex items-center gap-x-2">
<ExclamationCircle />
<Text>
{t(
"price-list-edit-prices-modal-error-loading",
"An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
</div>
) : (
<React.Fragment>
<ProgressTabs.Content
value={Tab.PRICES}
className="h-full w-full"
>
<PriceListPricesForm
setProduct={onSetProduct}
form={nestedForm(pricesForm)}
priceListId={priceList.id}
productIds={productIds}
/>
</ProgressTabs.Content>
{product && (
<ProgressTabs.Content
value={Tab.EDIT}
className="h-full w-full"
>
<PriceListProductPricesForm
product={product}
currencies={currencies}
regions={regions}
control={productControl}
taxInclEnabled={isTaxInclPricesEnabled}
priceListTaxInclusive={priceList.includes_tax}
getValues={getProductValues}
setValue={setProductValue}
/>
</ProgressTabs.Content>
)}
</React.Fragment>
)}
</FocusModal.Body>
</FocusModal.Content>
</ProgressTabs>
</FocusModal>
)
}
export { EditPricesModal }
@@ -0,0 +1 @@
export * from "./prices-section"
@@ -0,0 +1,648 @@
import {
CurrencyDollar,
EllipsisHorizontal,
ExclamationCircle,
PencilSquare,
PhotoSolid,
Spinner,
Tag,
Trash,
} from "@medusajs/icons"
import type { PriceList, Product } from "@medusajs/medusa"
import {
Checkbox,
CommandBar,
Container,
DropdownMenu,
Heading,
IconButton,
Input,
Table,
Text,
clx,
usePrompt,
} from "@medusajs/ui"
import {
PaginationState,
RowSelectionState,
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type Row,
} from "@tanstack/react-table"
import {
useAdminDeletePriceListProductPrices,
useAdminDeletePriceListProductsPrices,
useAdminPriceListProducts,
} from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useDebouncedSearchParam } from "../../../../hooks/use-debounced-search-param"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import {
getDateComparisonOperatorFromSearchParams,
getStringFromSearchParams,
} from "../../../../utils/search-param-utils"
import { ProductFilter, ProductFilterMenu } from "../../components"
import { AddProductsModal } from "./add-products-modal"
import { EditPricesModal } from "./edit-prices-modal"
type PriceListPricesSectionProps = {
priceList: PriceList
}
const PAGE_SIZE = 10
const TABLE_HEIGHT = (PAGE_SIZE + 1) * 48
const PriceListPricesSection = ({ priceList }: PriceListPricesSectionProps) => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { t } = useTranslation()
const [showAddProductsModal, setShowAddProductsModal] = React.useState(false)
const [showEditPricesModal, setShowEditPricesModal] = React.useState(false)
const [productIdsToEdit, setProductIdsToEdit] = React.useState<
string[] | null
>(null)
/**
* Table state.
*/
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
/**
* Calculate the offset based on the pagination state.
*/
const offset = React.useMemo(
() => pagination.pageIndex * pagination.pageSize,
[pagination.pageIndex, pagination.pageSize]
)
const { query, setQuery } = useDebouncedSearchParam()
const onFiltersChange = (filters: ProductFilter) => {
const current = new URLSearchParams(searchParams)
if (filters.created_at) {
current.set("created_at", JSON.stringify(filters.created_at))
} else {
current.delete("created_at")
}
if (filters.updated_at) {
current.set("updated_at", JSON.stringify(filters.updated_at))
} else {
current.delete("updated_at")
}
navigate({ search: current.toString() }, { replace: true })
}
const onClearFilters = () => {
const current = new URLSearchParams(searchParams)
current.delete("created_at")
current.delete("updated_at")
navigate({ search: current.toString() }, { replace: true })
}
const prompt = usePrompt()
const notification = useNotification()
const { mutateAsync, isLoading: isDeletingProductPrices } =
useAdminDeletePriceListProductsPrices(priceList.id)
const handleDeleteProductPrices = async () => {
const res = await prompt({
title: t("price-list-prices-section-prompt-title", "Are you sure?"),
description: t(
"price-list-prices-section-prompt-description",
"This will permanently delete the product prices from the list"
),
})
if (!res) {
return
}
await mutateAsync(
{
product_ids: Object.keys(rowSelection),
},
{
onSuccess: () => {
notification(
t(
"price-list-prices-secton-delete-success-title",
"Prices deleted"
),
t(
"price-list-prices-section-delete-success-description",
`Successfully deleted prices for {{count}} products`,
{
count: Object.keys(rowSelection).length,
}
),
"success"
)
setRowSelection({})
},
onError: (err) => {
notification(
t(
"price-list-prices-section-delete-error-title",
"An error occurred"
),
getErrorMessage(err),
"error"
)
},
}
)
}
const { products, count, isLoading, isError } = useAdminPriceListProducts(
priceList.id,
{
limit: PAGE_SIZE,
offset,
expand: "variants,collection",
created_at: getDateComparisonOperatorFromSearchParams(
"created_at",
searchParams
),
updated_at: getDateComparisonOperatorFromSearchParams(
"updated_at",
searchParams
),
q: getStringFromSearchParams("q", searchParams),
},
{
keepPreviousData: true,
}
)
const { products: allProducts } = useAdminPriceListProducts(
priceList.id,
{
limit: count,
fields: "id",
},
{
enabled: !!count,
}
)
const onEditPricesModalOpenChange = React.useCallback((open: boolean) => {
switch (open) {
case true:
setShowEditPricesModal(true)
break
case false:
setShowEditPricesModal(false)
setProductIdsToEdit(null)
setRowSelection({})
break
}
}, [])
const onEditAllProductPrices = React.useCallback(() => {
setProductIdsToEdit(allProducts?.map((p) => p.id) as string[])
setShowEditPricesModal(true)
}, [allProducts])
const onEditSelectedProductPrices = React.useCallback(() => {
setProductIdsToEdit(Object.keys(rowSelection))
setShowEditPricesModal(true)
}, [rowSelection])
const onEditSingleProductPrices = (id: string) => {
setProductIdsToEdit([id])
setShowEditPricesModal(true)
}
const pageCount = React.useMemo(() => {
return count ? Math.ceil(count / PAGE_SIZE) : 0
}, [count])
const { columns } = usePriceListProudctColumns({
onEditProductPrices: onEditSingleProductPrices,
})
const table = useReactTable({
columns,
data: (products as Product[] | undefined) ?? [],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
state: {
rowSelection,
pagination,
},
meta: {
priceListId: priceList.id,
},
pageCount,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
manualPagination: true,
onPaginationChange: setPagination,
})
return (
<Container className="p-0">
<div className="flex items-center justify-between px-8 pt-6 pb-4">
<Heading>{t("price-list-prices-section-heading", "Prices")}</Heading>
<div className="flex items-center gap-x-2">
<ProductFilterMenu
value={{
created_at: getDateComparisonOperatorFromSearchParams(
"created_at",
searchParams
),
updated_at: getDateComparisonOperatorFromSearchParams(
"updated_at",
searchParams
),
}}
onFilterChange={onFiltersChange}
onClearFilters={onClearFilters}
/>
<Input
type="search"
size="small"
placeholder={
t(
"price-list-prices-section-search-placeholder",
"Search products"
) ?? undefined
}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton>
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="bottom">
<DropdownMenu.Item onClick={onEditAllProductPrices}>
<CurrencyDollar className="text-ui-fg-subtle" />
<span className="ml-2">
{t(
"price-list-prices-section-prices-menu-edit",
"Edit prices"
)}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => setShowAddProductsModal(true)}>
<Tag className="text-ui-fg-subtle" />
<span className="ml-2">
{t(
"price-list-prices-section-prices-menu-add",
"Add products"
)}
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
</div>
<div
className="border-ui-border-base relative h-full flex-1 border-b"
style={{
height: TABLE_HEIGHT,
}}
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</div>
)}
{isError && (
<div className="text-ui-fg-subtle absolute inset-0 flex items-center justify-center gap-x-2">
<ExclamationCircle />
<Text size="small">
{t(
"price-list-prices-section-table-load-error",
"An error occured while fetching the products. Try to reload the page, or if the issue persists, try again later."
)}
</Text>
</div>
)}
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th]:w-1/3 [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
"transition-fg [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<Table.Pagination
count={count ?? 0}
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
pageIndex={pagination.pageIndex}
pageCount={pageCount}
pageSize={pagination.pageSize}
/>
<AddProductsModal
productIds={(allProducts?.map((p) => p.id) as string[]) ?? []}
priceList={priceList}
open={showAddProductsModal}
onOpenChange={setShowAddProductsModal}
/>
{productIdsToEdit && (
<EditPricesModal
open={showEditPricesModal}
onOpenChange={onEditPricesModalOpenChange}
productIds={productIdsToEdit}
priceList={priceList}
/>
)}
<CommandBar open={Object.keys(rowSelection).length > 0}>
<CommandBar.Bar>
<CommandBar.Value>
{t("price-list-prices-section-bar-count", "{{count}} selected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
shortcut="e"
label={t("price-list-prices-section-edit-command", "Edit")}
action={onEditSelectedProductPrices}
disabled={
isDeletingProductPrices ||
showAddProductsModal ||
showEditPricesModal
}
/>
<CommandBar.Seperator />
<CommandBar.Command
shortcut="d"
label={t("price-list-prices-section-delete-command", "Delete")}
action={handleDeleteProductPrices}
disabled={
isDeletingProductPrices ||
showAddProductsModal ||
showEditPricesModal
}
/>
</CommandBar.Bar>
</CommandBar>
</Container>
)
}
const columnHelper = createColumnHelper<Product>()
type UsePriceListProudctColumnsProps = {
onEditProductPrices: (id: string) => void
}
const usePriceListProudctColumns = ({
onEditProductPrices,
}: UsePriceListProudctColumnsProps) => {
const { t } = useTranslation()
const columns = React.useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={
t(
"price-list-prices-section-select-all-checkbox-label",
"Select all products on the current page"
) ?? undefined
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={
t(
"price-list-prices-section-select-checkbox-label",
"Select row"
) ?? undefined
}
/>
)
},
}),
columnHelper.accessor("title", {
header: () => t("price-list-prices-section-table-product", "Product"),
cell: (info) => {
const title = info.getValue()
const thumbnail = info.row.original.thumbnail
return (
<div className="flex items-center gap-x-3">
<div className="bg-ui-bg-subtle flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
{thumbnail ? (
<img
src={thumbnail}
alt={
t(
"price-list-prices-section-table-thumbnail-alt",
"{{title}} thumbnail",
{
title,
}
) ?? undefined
}
className="h-full w-full object-cover"
/>
) : (
<PhotoSolid />
)}
</div>
<Text size="small" className="text-ui-fg-base">
{title}
</Text>
</div>
)
},
}),
columnHelper.accessor("collection", {
header: () =>
t("price-list-prices-section-table-collection", "Collection"),
cell: (info) => info.getValue()?.title ?? "-",
}),
columnHelper.accessor("variants", {
header: () => t("price-list-prices-section-table-variants", "Variants"),
cell: (info) => {
const variants = info.getValue()
return variants?.length ?? "-"
},
}),
columnHelper.display({
id: "actions",
cell: ({ table, row }) => {
const { priceListId } = table.options.meta as {
priceListId: string | undefined
}
return (
<PriceListProductRowActions
row={row}
priceListId={priceListId}
onEditProductPrices={onEditProductPrices}
/>
)
},
}),
],
[t, onEditProductPrices]
)
return { columns }
}
type PriceListProductRowActionsProps = {
row: Row<Product>
priceListId?: string
onEditProductPrices: (id: string) => void
}
const PriceListProductRowActions = ({
row,
priceListId,
onEditProductPrices,
}: PriceListProductRowActionsProps) => {
const { mutateAsync } = useAdminDeletePriceListProductPrices(
priceListId!,
row.original.id
)
const prompt = usePrompt()
const notification = useNotification()
const onDelete = async () => {
const response = await prompt({
title: "Are you sure?",
description:
"This will permanently delete the product prices from the list",
})
if (!response) {
return
}
return mutateAsync(undefined, {
onSuccess: ({ deleted }) => {
if (deleted) {
notification(
"Prices deleted",
`Successfully deleted prices for ${row.original.title}`,
"success"
)
}
if (!deleted) {
notification(
"Failed to delete prices",
`No prices were deleted for ${row.original.title}`,
"error"
)
}
},
onError: (err) => {
notification("An error occurred", getErrorMessage(err), "error")
},
})
}
const onEdit = () => {
onEditProductPrices(row.original.id)
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="bottom">
<DropdownMenu.Item onClick={onEdit}>
<PencilSquare className="text-ui-fg-subtle" />
<span className="ml-2">Edit prices</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={onDelete}>
<Trash className="text-ui-fg-subtle" />
<span className="ml-2">Delete prices</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
export { PriceListPricesSection }
@@ -0,0 +1,3 @@
export * from "./price-list-details-form"
export * from "./schema"
export * from "./types"
@@ -0,0 +1,750 @@
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import type { CustomerGroup } from "@medusajs/medusa"
import {
Checkbox,
Container,
DatePicker,
Heading,
Input,
RadioGroup,
Switch,
Table,
Text,
Textarea,
clx,
} from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table"
import { useAdminCustomerGroups } from "medusa-react"
import * as React from "react"
import { DateComparisonOperator } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../components/helpers/form"
import { FilterMenu } from "../../../../components/molecules/filter-menu"
import { useDebounce } from "../../../../hooks/use-debounce"
import { type NestedForm } from "../../../../utils/nested-form"
import { PriceListDetailsSchema } from "./types"
interface PriceListDetailsFormProps {
form: NestedForm<PriceListDetailsSchema>
layout: "drawer" | "focus"
enableTaxToggle?: boolean
}
const PriceListDetailsForm = ({
form,
layout,
enableTaxToggle,
}: PriceListDetailsFormProps) => {
return (
<div className="flex w-full flex-col gap-y-12">
<PriceListType form={form} layout={layout} />
<PriceListGeneral
form={form}
layout={layout}
enableTaxToggle={enableTaxToggle}
/>
<PriceListDates form={form} layout={layout} />
<PriceListCustomerGroups form={form} layout={layout} />
</div>
)
}
/** Type */
const PriceListType = ({ form, layout }: PriceListDetailsFormProps) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-6">
<div>
<Heading level="h2">
{t("price-list-details-form-type-heading", "Type")}
</Heading>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-type-description",
"Choose the type of price list you want to create."
)}
</Text>
</div>
<Form.Field
control={form.control}
name={form.path("type.value")}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<RadioGroup
{...field}
onValueChange={field.onChange}
className={clx("grid gap-4", {
"grid-cols-2": layout === "focus",
"grid-cols-1": layout === "drawer",
})}
>
<RadioGroup.ChoiceBox
id="type_opt_sale"
aria-describedby="type_opt_sale_desc"
value="sale"
label={t("price-list-details-form-type-label-sale", "Sale")}
description={t(
"price-list-details-form-type-hint-sale",
"Use this if you are creating a sale."
)}
/>
<RadioGroup.ChoiceBox
id="type_opt_override"
aria-describedby="type_opt_override_desc"
value="override"
label={t(
"price-list-details-form-type-label-override",
"Override"
)}
description={t(
"price-list-details-form-type-hint-override",
"Use this if you are overriding prices."
)}
/>
</RadioGroup>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)
}
/** General */
const PriceListGeneral = ({
form,
layout,
enableTaxToggle,
}: PriceListDetailsFormProps) => {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-6">
<div>
<Heading level="h2">
{t("price-list-details-form-general-heading", "General")}
</Heading>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-general-description",
"Choose a title and description for the price list."
)}
</Text>
</div>
<div
className={clx("grid gap-4", {
"grid-cols-1": layout === "drawer",
"grid-cols-2": layout === "focus",
})}
>
<Form.Field
control={form.control}
name={form.path("general.name")}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("price-list-details-form-general-name-label", "Name")}
</Form.Label>
<Form.Control>
<Input
{...field}
placeholder={
t(
"price-list-details-form-general-name-placeholder",
"Black Friday Sale"
) ?? undefined
}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name={form.path("general.description")}
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t(
"price-list-details-form-general-description-label",
"Description"
)}
</Form.Label>
<Form.Control>
<Textarea
{...field}
placeholder={
t(
"price-list-details-form-general-description-placeholder",
"Prices for the Black Friday sale..."
) ?? undefined
}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
{enableTaxToggle && (
<Form.Field
control={form.control}
name={form.path("general.tax_inclusive")}
render={({ field: { value, onChange, ...rest } }) => {
return (
<Form.Item>
<div className="flex items-start justify-between">
<div className="flex flex-col">
<Form.Label>
{t(
"price-list-details-form-tax-inclusive-label",
"Tax inclusive prices"
)}
</Form.Label>
<Form.Hint className="!txt-medium">
{t(
"price-list-details-form-tax-inclusive-hint",
"Choose to make all prices in this list inclusive of tax."
)}
</Form.Hint>
</div>
<Form.Control>
<Switch
{...rest}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
</Form.Item>
)
}}
/>
)}
</div>
)
}
/** Dates */
const PriceListDates = ({ form, layout }: PriceListDetailsFormProps) => {
const [startOpen, setStartOpen] = React.useState(
!!form.getValues(form.path("dates.starts_at"))
)
const [endOpen, setEndOpen] = React.useState(
!!form.getValues(form.path("dates.ends_at"))
)
const { t } = useTranslation()
const toggleStart = (state: boolean) => {
if (!state) {
form.setValue(form.path("dates.starts_at"), undefined, {
shouldDirty: true,
shouldTouch: true,
})
}
setStartOpen(state)
}
const toggleEnd = (state: boolean) => {
if (!state) {
form.setValue(form.path("dates.ends_at"), undefined, {
shouldDirty: true,
shouldTouch: true,
})
}
setEndOpen(state)
}
return (
<div className="flex flex-col gap-y-12">
<div>
<div>
<div className="flex items-center justify-between">
<Heading level="h2">
{t(
"price-list-details-form-dates-starts-at-heading",
"Price list has a start date?"
)}
</Heading>
<Switch checked={startOpen} onCheckedChange={toggleStart} />
</div>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-dates-starts-at-description",
"Schedule the price overrides to activate in the future."
)}
</Text>
</div>
<Collapsible.Root open={startOpen}>
<Collapsible.Content forceMount className="group">
<div
className={clx(
"hidden gap-4 pt-6 group-data-[state='open']:grid",
{
"grid-cols-2": layout === "focus",
"grid-cols-1": layout === "drawer",
}
)}
>
<Form.Field
control={form.control}
name={form.path("dates.starts_at")}
render={({ field: { ref: _ref, value, ...rest } }) => {
return (
<Form.Item>
<Form.Label>
{t(
"price-list-details-form-dates-starts-at-label",
"Start date"
)}
</Form.Label>
<Form.Control>
<DatePicker
{...rest}
value={value ?? undefined}
showTimePicker
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
<div>
<div>
<div className="flex items-center justify-between">
<Heading level="h2">
{t(
"price-list-details-form-ends-at-heading",
"Price list has an expiry date?"
)}
</Heading>
<Switch checked={endOpen} onCheckedChange={toggleEnd} />
</div>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-ends-at-description",
"Schedule the price overrides to deactivate in the future."
)}
</Text>
</div>
<Collapsible.Root open={endOpen}>
<Collapsible.Content forceMount className="group">
<div
className={clx(
"hidden gap-4 pt-6 group-data-[state='open']:grid",
{
"grid-cols-2": layout === "focus",
"grid-cols-1": layout === "drawer",
}
)}
>
<Form.Field
control={form.control}
name={form.path("dates.ends_at")}
render={({ field: { ref: _ref, value, ...rest } }) => {
return (
<Form.Item>
<Form.Label>
{t("price-list-details-form-ends-at-label", "End date")}
</Form.Label>
<Form.Control>
<DatePicker
{...rest}
value={value ?? undefined}
showTimePicker
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
)
}
/** Customer groups */
const columnHelper = createColumnHelper<CustomerGroup>()
const useCustomerGroupsColumns = () => {
const { t } = useTranslation()
const columns = React.useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all customer groups on the current page"
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
)
},
}),
columnHelper.accessor("name", {
header: () => t("price-list-details-form-customer-groups-name", "Name"),
cell: (info) => info.getValue(),
}),
columnHelper.accessor("customers", {
header: () => (
<div className="w-full text-right">
{t("price-list-details-form-customer-groups-members", "Members")}
</div>
),
cell: (info) => (
<div className="w-full text-right">
{info.getValue()?.length || "-"}
</div>
),
}),
],
[t]
)
return {
columns,
}
}
const PAGE_SIZE = 4
const PriceListCustomerGroups = ({
form,
layout,
}: PriceListDetailsFormProps) => {
const { register, path, setValue } = form
/**
* Open state, used to toggle the collapsible.
*
* If the form has a value for customer_groups, we
* default to open.
*/
const [open, setOpen] = React.useState(
!!form.getValues(form.path("customer_groups.ids"))?.length
)
const { t } = useTranslation()
const [query, setQuery] = React.useState("")
const debouncedQuery = useDebounce(query, 500)
/**
* Table state.
*/
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({
...form.getValues(form.path("customer_groups.ids"))?.reduce(
(acc, id) => ({
...acc,
[id]: true,
}),
{}
),
})
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
/**
* Calculate the offset based on the pagination state.
*/
const offset = React.useMemo(
() => pagination.pageIndex * pagination.pageSize,
[pagination.pageIndex, pagination.pageSize]
)
const [filters, setFilters] = React.useState<{
created_at?: DateComparisonOperator
updated_at?: DateComparisonOperator
}>({})
React.useEffect(() => {
if (!open) {
setFilters({})
}
}, [open])
const { customer_groups, count, isLoading, isError } = useAdminCustomerGroups(
{
limit: PAGE_SIZE,
offset,
expand: "customers",
q: debouncedQuery,
},
{
keepPreviousData: true,
}
)
const pageCount = React.useMemo(() => {
return count ? Math.ceil(count / PAGE_SIZE) : 0
}, [count])
const { columns } = useCustomerGroupsColumns()
const table = useReactTable({
columns,
data: customer_groups ?? [],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
state: {
rowSelection,
pagination,
},
pageCount,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
manualPagination: true,
onPaginationChange: setPagination,
})
/**
* Register the form field.
*/
React.useEffect(() => {
register(path("customer_groups.ids"))
}, [register, path])
/**
* Update the form value when the row selection changes.
*
* The RowSelectionState will only contain the rows that are
* selected, so we need to get the keys and set the value to
* the array of keys.
*/
React.useEffect(() => {
setValue(path("customer_groups.ids"), Object.keys(rowSelection), {
shouldDirty: true,
shouldTouch: true,
})
}, [rowSelection, path, setValue])
if (isLoading) {
return (
<div>
<Spinner className="animate-spin" />
</div>
)
}
if (isError) {
return (
<Container>
<div className="flex items-center gap-x-2">
<ExclamationCircle />
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-customer-groups-error",
"An error occurred while loading customer groups. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
</Container>
)
}
if (!customer_groups) {
return (
<Container>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-customer-groups-no-groups",
"No customer groups found."
)}
</Text>
</Container>
)
}
return (
<div className="w-full">
<div>
<div className="flex items-center justify-between">
<Heading level="h2">
{t(
"price-list-details-form-customer-groups-heading",
"Customer availability"
)}
</Heading>
<Switch checked={open} onCheckedChange={setOpen} />
</div>
<Text className="text-ui-fg-subtle">
{t(
"price-list-details-form-customer-groups-description",
"Specify which customer groups the price overrides should apply for."
)}
</Text>
</div>
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Content>
<div className="pt-6">
<Container className="overflow-hidden p-0 ">
<div
className={clx("flex px-8 pt-6 pb-4", {
"items-center justify-between": layout === "focus",
"flex-col gap-y-4": layout === "drawer",
})}
>
<Heading>
{t(
"price-list-details-form-customer-groups-content-heading",
"Customer Groups"
)}
</Heading>
<div
className={clx("flex items-center gap-x-2", {
"w-full": layout === "drawer",
})}
>
<FilterMenu onClearFilters={() => setFilters({})}>
<FilterMenu.Content side="top">
<FilterMenu.DateItem
name="Created at"
value={filters.created_at}
onChange={(v) => {
setFilters((prev) => ({
...prev,
created_at: v,
}))
}}
/>
<FilterMenu.Seperator />
<FilterMenu.DateItem
name="Updated at"
value={filters.updated_at}
onChange={(v) => {
setFilters((prev) => ({
...prev,
updated_at: v,
}))
}}
/>
</FilterMenu.Content>
</FilterMenu>
<Input
className={clx({
"w-full": layout === "drawer",
})}
type="search"
placeholder={
t(
"price-list-details-form-customer-groups-search-placeholder",
"Search"
) ?? undefined
}
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
<Table.Pagination
count={count ?? 0}
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
pageIndex={pagination.pageIndex}
pageCount={pageCount}
pageSize={pagination.pageSize}
/>
</Container>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
)
}
export { PriceListDetailsForm }
@@ -0,0 +1,37 @@
import * as z from "zod"
export const priceListDetailsSchema = z.object({
type: z.object({
value: z.enum(["sale", "override"], {
required_error: "Type is required",
}),
}),
general: z.object({
name: z.string().nonempty({ message: "Name is required" }),
description: z.string().nonempty({ message: "Description is required" }),
tax_inclusive: z.boolean(),
}),
dates: z
.object({
starts_at: z.date().nullable().optional(),
ends_at: z.date().nullable().optional(),
})
.superRefine((data, ctx) => {
if (data.starts_at && data.ends_at && data.starts_at > data.ends_at) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Start date cannot be after end date",
path: ["starts_at"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "End date cannot be before start date",
path: ["ends_at"],
})
}
}),
customer_groups: z.object({
ids: z.array(z.string()),
}),
})
@@ -0,0 +1,20 @@
import * as z from "zod"
import { priceListDetailsSchema } from "./schema"
export type PriceListDetailsSchema = z.infer<typeof priceListDetailsSchema>
/**
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
*/
export enum PriceListStatus {
ACTIVE = "active",
DRAFT = "draft",
}
/**
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
*/
export enum PriceListType {
SALE = "sale",
OVERRIDE = "override",
}
@@ -0,0 +1,42 @@
import { currencies as CURRENCY_MAP } from "../../../../utils/currencies"
/**
* Gets the presentational amount for a given currency code
* @param code - currency code
* @param amount - amount in cents
*/
export const getDefaultAmount = (code: string, amount?: number) => {
const meta = CURRENCY_MAP[code.toUpperCase()]
if (!amount) {
return null
}
if (!meta) {
if (process.env.NODE_ENV === "development") {
console.warn(`No currency meta found for ${code}. Cannot format.`)
}
return null
}
return amount / Math.pow(10, meta.decimal_digits)
}
export const getDbSafeAmount = (code: string, amount?: number) => {
const meta = CURRENCY_MAP[code.toUpperCase()]
if (!amount) {
return null
}
if (!meta) {
if (process.env.NODE_ENV === "development") {
console.warn(`No currency meta found for ${code}. Cannot format.`)
}
return null
}
return amount * Math.pow(10, meta.decimal_digits)
}
@@ -0,0 +1,6 @@
export * from "./price-list-prices-form"
export { getDbSafeAmount, getDefaultAmount } from "./helpers"
export { priceListPricesSchema } from "./schema"
export type { PriceListPricesSchema, PricePayload } from "./types"
export { usePricesFormData } from "./use-prices-form-data"
@@ -0,0 +1,218 @@
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { Button, Heading, Input, Text } from "@medusajs/ui"
import { useAdminProducts } from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { useDebounce } from "../../../../hooks/use-debounce"
import { NestedForm } from "../../../../utils/nested-form"
import { ProductFilter, ProductFilterMenu } from "../../components"
import { PriceListPricesSchema } from "./types"
type PriceListPricesFormProps = {
form: NestedForm<PriceListPricesSchema>
setProduct: (product: Product | null) => void
productIds: string[]
priceListId?: string
}
const PriceListPricesForm = ({
form,
setProduct,
productIds,
priceListId,
}: PriceListPricesFormProps) => {
const { path, getValues } = form
const { t } = useTranslation()
const [filters, setFilters] = React.useState<ProductFilter>({})
const [query, setQuery] = React.useState("")
const debouncedQuery = useDebounce(query, 500)
const {
products,
isLoading: isLoadingProducts,
isError: isErrorProducts,
} = useAdminProducts(
{
id: productIds,
q: debouncedQuery,
limit: productIds?.length,
price_list_id: priceListId ? [priceListId] : undefined,
...filters,
expand: "variants.prices",
},
{
keepPreviousData: true,
}
)
const errorMessage = t(
"price-list-prices-form-products-error",
"An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later."
)
if (isErrorProducts) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex items-center gap-x-2">
<ExclamationCircle />
<Text className="text-ui-fg-subtle">{errorMessage}</Text>
</div>
</div>
)
}
if (isLoadingProducts) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="animate-spin" />
</div>
)
}
if (!products) {
return (
<div className="flex h-full w-full items-center justify-center gap-x-2">
<ExclamationCircle />
<Text className="text-ui-fg-subtle">{errorMessage}</Text>
</div>
)
}
return (
<div className="relative flex h-full w-full flex-col">
<div>
<div className="border-ui-border-base bg-ui-bg-base z-10 flex items-center justify-between border-b px-4 py-3">
<Heading>
{t("price-list-prices-form-heading", "Edit prices")}
</Heading>
<div className="flex items-center gap-x-2">
<ProductFilterMenu
onClearFilters={() => setFilters({})}
value={filters}
onFilterChange={setFilters}
/>
<Input
type="search"
placeholder="Search"
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div className="border-ui-border-base border-b">
<table className="w-full text-left">
<thead>
<tr className="[&_th]:text-ui-fg-subtle [&_th]:txt-compact-small-plus border-ui-border-base [&_th]:w-1/3 [&_th]:border-r [&_th]:px-4 [&_th]:py-2.5 [&_th:last-of-type]:border-r-0">
<th>{t("price-list-prices-form-variant", "Variant")}</th>
<th>{t("price-list-prices-form-sku", "SKU")}</th>
<th className="text-right">
{t("price-list-prices-form-prices", "Prices")}
</th>
</tr>
</thead>
</table>
</div>
</div>
<div className="relative flex h-full flex-1 flex-col overflow-y-auto">
{products.map((product) => {
return (
<div key={product.id}>
<div className="bg-ui-bg-subtle border-ui-border-base sticky top-0 flex items-center justify-between border-b p-4">
<div className="flex items-center gap-x-4">
<div className="bg-ui-bg-subtle h-10 w-[30px] overflow-hidden rounded-md">
{product.thumbnail && (
<img
src={product.thumbnail}
className="h-full w-full object-cover"
/>
)}
</div>
<div>
<Text size="small" weight="plus">
{product.title}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t(
"price-list-prices-form-prices-variant-count",
"{{count}} variants",
{
count: product.variants.length,
}
)}
</Text>
</div>
</div>
<Button
variant="secondary"
onClick={() => {
setProduct(product as Product)
}}
>
{t("price-list-prices-form-add-prices-button", "Add prices")}
</Button>
</div>
<table className="w-full">
<tbody>
{product.variants.map((variant) => {
let count = 0
const setPrices = getValues(
path(`products.${product.id}.variants.${variant.id}`)
)
if (setPrices) {
if (setPrices.currency) {
for (const key of Object.keys(setPrices.currency)) {
const item = setPrices.currency[key]
if (item.amount) {
count++
}
}
}
if (setPrices.region) {
for (const key of Object.keys(setPrices.region)) {
const item = setPrices.region[key]
if (item.amount) {
count++
}
}
}
}
return (
<tr
key={variant.id}
className="[&_td]:text-ui-fg-subtle [&_td]:txt-compact-small border-ui-border-base border-b [&_td:last-of-type]:border-r-0 [&_td]:w-1/3 [&_td]:border-r [&_td]:px-4 [&_td]:py-2.5"
>
<td>{variant.title}</td>
<td>{variant.sku ? variant.sku : "-"}</td>
<td className="text-right">
{t(
"price-list-prices-form-prices-count",
"{{count}} prices",
{
count,
}
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
})}
</div>
</div>
)
}
export { PriceListPricesForm }
@@ -0,0 +1,32 @@
import * as z from "zod"
export const priceListPricesSchema = z.object({
products: z.record(
z.string(),
z.object({
variants: z.record(
z.string(),
z.object({
currency: z
.record(
z.string(),
z.object({
id: z.string().nullable().optional(),
amount: z.string().nullable(),
})
)
.optional(),
region: z
.record(
z.string(),
z.object({
id: z.string().nullable().optional(),
amount: z.string().nullable(),
})
)
.optional(),
})
),
})
),
})
@@ -0,0 +1,12 @@
import { z } from "zod"
import { priceListPricesSchema } from "./schema"
export type PricePayload = {
id?: string
amount: number
currency_code?: string
region_id?: string
variant_id: string
}
export type PriceListPricesSchema = z.infer<typeof priceListPricesSchema>
@@ -0,0 +1,70 @@
import type { PriceList } from "@medusajs/medusa"
import { useAdminProducts, useAdminRegions, useAdminStore } from "medusa-react"
import * as React from "react"
type UsePricesFormDataProps = {
priceList?: PriceList
productIds: string[]
}
const usePricesFormData = ({
priceList,
productIds,
}: UsePricesFormDataProps) => {
const {
products,
isLoading: isLoadingProducts,
isError: isErrorProducts,
} = useAdminProducts(
{
id: productIds,
limit: productIds?.length,
price_list_id: priceList ? [priceList.id] : undefined,
},
{
keepPreviousData: true,
}
)
const {
store,
isLoading: isLoadingStore,
isError: isStoreError,
} = useAdminStore()
const currencies = React.useMemo(() => {
if (!store) {
return []
}
return store.currencies
}, [store])
const {
regions,
isLoading: isLoadingRegions,
isError: isErrorRegions,
} = useAdminRegions(
{
limit: 1000,
},
{
keepPreviousData: true,
}
)
const isLoading = isLoadingProducts || isLoadingStore || isLoadingRegions
const isError = isErrorProducts || isStoreError || isErrorRegions
const isNotFound = !products || !regions || !currencies
return {
isError,
isLoading,
isNotFound,
products: products ?? [],
currencies,
regions: regions ?? [],
}
}
export { usePricesFormData }
@@ -0,0 +1,566 @@
import {
BorderAttributes,
BoundingBox,
CellWithParentRow,
DragPosition,
Point,
} from "./types"
const EDITABLE_ATTRIBUTE = "data-editable"
export const getKey = (point: Point) => {
return `${point.row}-${point.col}`
}
export const getPoint = (key: string): Point => {
const [row, col] = key.split("-")
return { row: parseInt(row), col: parseInt(col) }
}
export const getPointFromElement = (
element: HTMLElement | null
): Point | null => {
if (!element) {
return null
}
const target = element.closest("td") as HTMLElement | null
if (!target || !isParentRow(target)) {
return null
}
const col = target.cellIndex
const row = target.parentElement.rowIndex
if (!row || !col) {
return null
}
return { row, col }
}
export const getElementFromPoint = (
point: Point | null,
root?: HTMLElement | null
): HTMLElement | null => {
const parent = root || document
if (!point) {
return null
}
const cell = parent.querySelector(
`td[data-row-index="${point.row}"][data-col-index="${point.col}"]`
) as HTMLElement | null
return cell
}
/**
* Checks if the parent of an element is a table row.
* @param element - The element to check
* @returns `true` if the parent of the element is a table row, `false` otherwise
*/
export const isParentRow = (
element: HTMLElement | null
): element is CellWithParentRow => {
if (!element) {
return false
}
const parent = element.parentElement
return !!parent && parent.nodeName === "TR"
}
/**
* Validates that the target of an interaction is a valid cell.
* @param target - The target element to validate
* @returns - The validated target element or `null` if the target is invalid
*/
export const validateTarget = (
target: HTMLElement | null
): CellWithParentRow | null => {
if (!target) {
return null
}
const cell = target.closest("td")
if (!cell) {
return null
}
const isEditable = cell.getAttribute(EDITABLE_ATTRIBUTE) === "true"
if (!isEditable) {
return null
}
const isNestedInRow = isParentRow(cell)
if (!isNestedInRow) {
return null
}
return cell
}
function generateRangeArray(start: number, end: number): number[] {
if (start > end) {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;[end, start] = [start, end]
}
const result: number[] = []
for (let i = start; i <= end; i++) {
result.push(i)
}
return result
}
export const getDragToFillSelection = (
target: HTMLElement | null,
selection: Record<string, boolean>
): { range: Record<string, boolean>; position: DragPosition } | null => {
if (!target) {
return null
}
const isValidTarget = validateTarget(target)
if (!isValidTarget) {
return null
}
const position = getPointFromElement(target)
if (!position) {
return null
}
const selectionPoints = Object.keys(selection).map((key) => getPoint(key))
const { topLeft, bottomRight } = calculateBoundingBoxes(selectionPoints)[0]
if (!topLeft || !bottomRight) {
return null
}
// Find out if the position is left, right, above or below the selection
const isLeft = position.col < topLeft.col
const isRight = position.col > bottomRight.col
const isAbove = position.row < topLeft.row
const isBelow = position.row > bottomRight.row
// If the position is not left, right, above or below the selection, we return null, as the position is not valid
if (!isLeft && !isRight && !isAbove && !isBelow) {
return null
}
const acceptedRows = generateRangeArray(topLeft.row, bottomRight.row)
const acceptedCols = generateRangeArray(topLeft.col, bottomRight.col)
let isValidPosition = false
// If the position is left or right of the selection, we check if the position is in the accepted row range
if (isLeft || isRight) {
const isInAcceptedRowRange = acceptedRows.includes(position.row)
if (!isInAcceptedRowRange) {
isValidPosition = false
}
isValidPosition = true
}
// If the position is above or below the selection, we check if the position is in the accepted column range
if (isAbove || isBelow) {
const isInAcceptedColRange = acceptedCols.includes(position.col)
if (!isInAcceptedColRange) {
isValidPosition = false
}
isValidPosition = true
}
if (!isValidPosition) {
// If the position is not valid, we attempt to find the nearest valid position that is within the accepted row or column range
if (isAbove && isRight) {
// Find out if we are closest to a valid row or column
const rowDifference = topLeft.row - position.row
const colDifference = position.col - bottomRight.col
if (rowDifference < colDifference) {
const start = { row: topLeft.row - 1, col: bottomRight.col }
const end = { row: position.row, col: topLeft.col }
return { range: getRange(start, end), position: "above" }
}
const start = { row: topLeft.row, col: bottomRight.col + 1 }
const end = { row: bottomRight.row, col: position.col }
return { range: getRange(start, end), position: "right" }
}
if (isAbove && isLeft) {
const rowDifference = topLeft.row - position.row
const colDifference = topLeft.col - position.col
if (rowDifference < colDifference) {
const start = { row: topLeft.row - 1, col: topLeft.col }
const end = { row: position.row, col: bottomRight.col }
return { range: getRange(start, end), position: "above" }
}
const start = { row: topLeft.row, col: position.col }
const end = { row: bottomRight.row, col: topLeft.col - 1 }
return { range: getRange(start, end), position: "left" }
}
if (isBelow && isRight) {
const rowDifference = position.row - bottomRight.row
const colDifference = position.col - bottomRight.col
if (rowDifference < colDifference) {
const start = { row: bottomRight.row, col: bottomRight.col + 1 }
const end = { row: position.row, col: topLeft.col }
return { range: getRange(start, end), position: "below" }
}
const start = { row: bottomRight.row + 1, col: topLeft.col }
const end = { row: position.row, col: bottomRight.col }
return { range: getRange(start, end), position: "right" }
}
if (isBelow && isLeft) {
const rowDifference = position.row - bottomRight.row
const colDifference = topLeft.col - position.col
if (rowDifference < colDifference) {
const start = { row: bottomRight.row, col: topLeft.col - 1 }
const end = { row: position.row, col: bottomRight.col }
return { range: getRange(start, end), position: "below" }
}
const start = { row: bottomRight.row + 1, col: bottomRight.col }
const end = { row: position.row, col: topLeft.col }
return { range: getRange(start, end), position: "left" }
}
return null
}
if (isLeft) {
const start = { row: topLeft.row, col: position.col }
const end = { row: bottomRight.row, col: topLeft.col - 1 }
return { range: getRange(start, end), position: "left" }
}
if (isRight) {
const start = { row: topLeft.row, col: bottomRight.col + 1 }
const end = { row: bottomRight.row, col: position.col }
return { range: getRange(start, end), position: "right" }
}
if (isAbove) {
const start = { row: topLeft.row - 1, col: topLeft.col }
const end = { row: position.row, col: bottomRight.col }
return { range: getRange(start, end), position: "above" }
}
if (isBelow) {
const start = { row: bottomRight.row + 1, col: topLeft.col }
const end = { row: position.row, col: bottomRight.col }
return { range: getRange(start, end), position: "below" }
}
return null
}
/**
* Gets the range of cells between two points.
* @param start - The start point
* @param end - The end point
* @returns A map of cell keys for the range
*/
export const getRange = (start: Point, end: Point): Record<string, boolean> => {
const range: Record<string, boolean> = {}
const minX = Math.min(start.col, end.col)
const maxX = Math.max(start.col, end.col)
const minY = Math.min(start.row, end.row)
const maxY = Math.max(start.row, end.row)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
range[`${y}-${x}`] = true
}
}
return range
}
/**
* Gets the cell attributes for a given range.
* @param start - The start point
* @param end - The end point
* @param root - The root element to search for cells, defaults to `document`
* @returns A list of cell attributes for the range
*/
export const getCellAttributes = (
start: Point,
end: Point,
root?: HTMLElement | null
) => {
const parent = root || document
const range = getRange(start, end)
const attributes = Object.keys(range).map((key) => {
const point = getPoint(key)
const cell = parent.querySelector(
`td[data-row-index="${point.row}"][data-col-index="${point.col}"]`
)
if (!cell) {
return null
}
const variantId = cell.getAttribute("data-variant-id")
const regionId = cell.getAttribute("data-region-id")
const currencyCode = cell.getAttribute("data-currency-code")
const type = cell.getAttribute("data-type") as
| "min_quantity"
| "max_quantity"
| "amount"
if (!variantId || (!regionId && !currencyCode) || !type) {
return null
}
return {
variantId,
regionId,
currencyCode,
type,
}
})
return attributes.filter(Boolean) as {
variantId: string
regionId: string | null
currencyCode: string | null
type: "min_quantity" | "max_quantity" | "amount"
}[]
}
/**
* Calculates the bounding boxes for a list of points.
* @param points - The points to calculate bounding boxes for
* @returns Returns a list of bounding boxes for the given points
*/
export const calculateBoundingBoxes = (points: Point[]): BoundingBox[] => {
const grid = new Map<number, Set<number>>()
for (const { col, row } of points) {
if (!grid.has(col)) {
grid.set(col, new Set<number>())
}
grid.get(col)!.add(row)
}
const visited = new Set<string>()
const components: Point[][] = []
const dfs = (col: number, row: number, component: Point[]) => {
if (
!grid.has(col) ||
!grid.get(col)!.has(row) ||
visited.has(`${col}-${row}`)
) {
return
}
visited.add(`${col}-${row}`)
component.push({ col, row })
const neighbours: Point[] = [
{ col: col - 1, row },
{ col: col + 1, row },
{ col, row: row - 1 },
{ col, row: row + 1 },
]
for (const { col, row } of neighbours) {
dfs(col, row, component)
}
}
for (const { col, row } of points) {
if (!visited.has(`${col}-${row}`)) {
const component: Point[] = []
dfs(col, row, component)
components.push(component)
}
}
const boundingBoxes: BoundingBox[] = components.map((component) => {
const minX = Math.min(...component.map((point) => point.col))
const maxX = Math.max(...component.map((point) => point.col))
const minY = Math.min(...component.map((point) => point.row))
const maxY = Math.max(...component.map((point) => point.row))
const topLeft: Point = {
col: minX,
row: minY,
}
const bottomRight: Point = {
col: maxX,
row: maxY,
}
return {
topLeft,
bottomRight,
}
})
return boundingBoxes
}
/**
* Checks if a point is inside a bounding box.
* @param point - The point to check
* @param boundingBox - The bounding box to check
* @returns Whether the point is inside the bounding box
*/
const isPointInsideBoundingBox = (
point: Point,
boundingBox: BoundingBox
): boolean => {
const { topLeft, bottomRight } = boundingBox
if (!topLeft || !bottomRight) {
return false
}
return (
point.col >= topLeft.col &&
point.col <= bottomRight.col &&
point.row >= topLeft.row &&
point.row <= bottomRight.row
)
}
/**
* Gets the border attributes for a point.
* @param point - The point to get the border attributes for
* @param boundingBoxes - The bounding boxes to check
* @returns A map of border attributes for the point
*/
export const getBorderAttributes = (
point: Point | null,
boundingBoxes: BoundingBox[]
): BorderAttributes => {
const borders = {
top: false,
left: false,
bottom: false,
right: false,
}
if (!point) {
return borders
}
for (const boundingBox of boundingBoxes) {
if (isPointInsideBoundingBox(point, boundingBox)) {
if (point.row === boundingBox.topLeft?.row) {
borders.top = true
}
if (point.col === boundingBox.topLeft?.col) {
borders.left = true
}
if (point.row === boundingBox.bottomRight?.row) {
borders.bottom = true
}
if (point.col === boundingBox.bottomRight?.col) {
borders.right = true
}
}
}
return borders
}
export const getRect = (
selection: Record<string, boolean>,
parent: HTMLElement | null
) => {
if (!parent) {
return null
}
const keys = Object.keys(selection)
if (!keys.length) {
return null
}
const points = keys.map((key) => getPoint(key))
// Get elements from the points and filter out nulls
const elements = points
.map((point) => getElementFromPoint(point))
.filter(Boolean)
if (!elements.length) {
return null
}
// Get the width and height of the selection as well as offset from the parents top left corner
const firstElement = elements[0]
const lastElement = elements[elements.length - 1]
if (!firstElement || !lastElement) {
return null
}
const firstRect = firstElement.getBoundingClientRect()
const lastRect = lastElement.getBoundingClientRect()
const width = lastRect.right - firstRect.left
const height = lastRect.bottom - firstRect.top
const parentRect = parent.getBoundingClientRect()
const left = firstRect.left - parentRect.left
const top = firstRect.top - parentRect.top
return {
width,
height,
left,
top,
}
}
@@ -0,0 +1,3 @@
export * from "./price-list-product-prices-form"
export * from "./schema"
export type { PriceListProductPricesSchema } from "./types"
@@ -0,0 +1,187 @@
import { Command } from "../../../../hooks/use-command-history"
/**
* A sorted set implementation that uses binary search to find the insertion index.
*/
class SortedSet<T> {
private items: T[] = []
constructor(initialItems?: T[]) {
if (initialItems) {
this.insertMultiple(initialItems)
}
}
insert(value: T): void {
const insertionIndex = this.findInsertionIndex(value)
if (this.items[insertionIndex] !== value) {
this.items.splice(insertionIndex, 0, value)
}
}
remove(value: T): void {
const index = this.findInsertionIndex(value)
if (this.items[index] === value) {
this.items.splice(index, 1)
}
}
getPrev(value: T): T | null {
const index = this.findInsertionIndex(value)
if (index === 0) {
return null
}
return this.items[index - 1]
}
getNext(value: T): T | null {
const index = this.findInsertionIndex(value)
if (index === this.items.length - 1) {
return null
}
return this.items[index + 1]
}
getFirst(): T | null {
if (this.items.length === 0) {
return null
}
return this.items[0]
}
getLast(): T | null {
if (this.items.length === 0) {
return null
}
return this.items[this.items.length - 1]
}
toArray(): T[] {
return [...this.items]
}
private insertMultiple(values: T[]): void {
values.forEach((value) => this.insert(value))
}
private findInsertionIndex(value: T): number {
let left = 0
let right = this.items.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (this.items[mid] === value) {
return mid
} else if (this.items[mid] < value) {
left = mid + 1
} else {
right = mid - 1
}
}
return left
}
}
type PasteCommandArgs = {
selection: Record<string, boolean>
next: string[][]
prev: string[][]
setter: (selection: Record<string, boolean>, values: string[][]) => void
}
class DeleteCommand implements Command {
private _selection: Record<string, boolean>
private _prev: string[][]
private _next: string[][]
private _setter: (
selection: Record<string, boolean>,
values: string[][]
) => void
constructor({ selection, prev, next, setter }: PasteCommandArgs) {
this._selection = selection
this._prev = prev
this._next = next
this._setter = setter
}
execute(): void {
this._setter(this._selection, this._next)
}
undo(): void {
this._setter(this._selection, this._prev)
}
redo(): void {
this.execute()
}
}
class PasteCommand implements Command {
private _selection: Record<string, boolean>
private _prev: string[][]
private _next: string[][]
private _setter: (
selection: Record<string, boolean>,
values: string[][]
) => void
constructor({ selection, prev, next, setter }: PasteCommandArgs) {
this._selection = selection
this._prev = prev
this._next = next
this._setter = setter
}
execute(): void {
this._setter(this._selection, this._next)
}
undo(): void {
this._setter(this._selection, this._prev)
}
redo(): void {
this.execute()
}
}
type UpdateCommandArgs = {
prev: any
next: any
setter: (value: any) => void
}
class UpdateCommand implements Command {
private _prev: any
private _next: any
private _setter: (value: any) => void
constructor({ prev, next, setter }: UpdateCommandArgs) {
this._prev = prev
this._next = next
this._setter = setter
}
execute(): void {
this._setter(this._next)
}
undo(): void {
this._setter(this._prev)
}
redo(): void {
this.execute()
}
}
export { DeleteCommand, PasteCommand, SortedSet, UpdateCommand }
@@ -0,0 +1,27 @@
import * as z from "zod"
export const priceListProductPricesSchema = z.object({
variants: z.record(
z.string(),
z.object({
currency: z
.record(
z.string(),
z.object({
id: z.string().nullable().optional(),
amount: z.string().nullable(),
})
)
.optional(),
region: z
.record(
z.string(),
z.object({
id: z.string().nullable().optional(),
amount: z.string().nullable(),
})
)
.optional(),
})
),
})
@@ -0,0 +1,91 @@
import { Noop } from "react-hook-form"
import { z } from "zod"
import { priceListProductPricesSchema } from "./schema"
export type PricePayload = {
id?: string
amount: number
currency_code?: string
region_id?: string
variant_id: string
min_quantity?: number
max_quantity?: number
}
export type Point = {
row: number
col: number
}
export type CellWithParentRow = HTMLTableCellElement & {
parentElement: HTMLTableRowElement
}
/**
* Represents the state of a cell.
*/
export type CellState = {
isSelected: boolean
isAnchor: boolean
isRangeEnd: boolean
borders: BorderAttributes
outline: BorderAttributes
}
/**
* Base props for a controlled cell.
*/
export interface CellProps {
value: string | undefined | null
name: string | undefined
onChange: (...event: any[]) => void
onBlur: Noop
onRegisterCell: (point: Point) => void
onUnregisterCell: (point: Point) => void
onDragToFillStart: (e: React.MouseEvent) => void
onCellMouseDown: (e: React.MouseEvent) => void
onCellOver: (e: React.MouseEvent) => void
getCellState: (point: Point | null) => CellState
setIsEditing: (state: boolean) => void
onNextRow: () => void
variantId: string
currencyCode?: string
regionId?: string
symbol?: string
decimalScale: number
type: "amount" | "min_quantity" | "max_quantity"
}
/**
* Represents a range of cells.
*/
export type BoundingBox = {
topLeft: Point | null
bottomRight: Point | null
}
/**
* Attributes for a points border.
*/
export type BorderAttributes = {
top: boolean
left: boolean
bottom: boolean
right: boolean
}
export type RectStyles = {
top: number
left: number
width: number
height: number
}
export type PriceListProductPricesSchema = z.infer<
typeof priceListProductPricesSchema
>
// Hack to make the type checker happy.
export type PriceListProductPricesPath = "variants.0.region.0.amount"
export type DragPosition = "above" | "below" | "left" | "right"
@@ -0,0 +1,3 @@
export * from "./price-list-products-form"
export * from "./schema"
export * from "./types"
@@ -0,0 +1,483 @@
import { ExclamationCircle, PhotoSolid, Spinner } from "@medusajs/icons"
import type { Product } from "@medusajs/medusa"
import {
Checkbox,
Heading,
Input,
StatusBadge,
Table,
Text,
clx,
} from "@medusajs/ui"
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table"
import { useAdminProducts } from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../components/helpers/form"
import { useDebounce } from "../../../../hooks/use-debounce"
import { NestedForm } from "../../../../utils/nested-form"
import { ProductFilter, ProductFilterMenu } from "../../components"
import { PriceListProductsSchema } from "./types"
interface PriceListProductsFormProps {
form: NestedForm<PriceListProductsSchema>
/**
* Products that are already part of the price list.
*/
productIds?: string[]
}
const PAGE_SIZE = 20
const columnHelper = createColumnHelper<Product>()
const usePriceListProductsFormColumns = () => {
const { t } = useTranslation()
const columns = React.useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={
t(
"price-list-products-form-select-all",
"Select all products on the current page"
) ?? undefined
}
/>
)
},
cell: ({ table, row }) => {
const { productIds } = table.options.meta as {
productIds: string[]
}
const isSelected = row.getIsSelected() || productIds.includes(row.id)
return (
<Checkbox
checked={isSelected}
disabled={productIds.includes(row.id)}
onCheckedChange={(value) => {
row.toggleSelected(!!value)
}}
aria-label={
t("price-list-products-form-select-row", "Select row") ??
undefined
}
/>
)
},
}),
columnHelper.accessor("title", {
header: () => t("price-list-products-form-product-label", "Product"),
cell: (info) => {
const title = info.getValue()
const thumbnail = info.row.original.thumbnail
return (
<div className="flex items-center gap-x-3">
<div className="bg-ui-bg-subtle flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
{thumbnail ? (
<img
src={thumbnail}
alt={
t(
"price-list-products-form-product-thumbnail",
"{{title}} thumbnail",
{
title,
}
) ?? undefined
}
className="h-full w-full object-cover"
/>
) : (
<PhotoSolid />
)}
</div>
<Text size="small" className="text-ui-fg-base">
{title}
</Text>
</div>
)
},
}),
columnHelper.accessor("collection", {
header: () =>
t("price-list-products-form-collection-label", "Collection"),
cell: (info) => info.getValue()?.title ?? "-",
}),
columnHelper.accessor("sales_channels", {
header: () =>
t("price-list-products-form-sales-channels-label", "Availability"),
cell: (info) => {
const salesChannels = info.getValue()
if (!salesChannels || salesChannels.length === 0) {
return "-"
}
const first = salesChannels[0].name
const remaining = salesChannels.length - 1
if (!remaining) {
return <span>{first}</span>
}
return (
<span>
{t(
"price-list-products-form-sales-channels-value",
"{{first}} + {{remaining}} more",
{
first,
remaining,
}
)}
</span>
)
},
}),
columnHelper.accessor("status", {
header: () => t("price-list-products-form-status-label", "Status"),
cell: (info) => {
const status = info.getValue()
return (
<StatusBadge
color={status === "published" ? "green" : "grey"}
className="capitalize"
>
{status}
</StatusBadge>
)
},
}),
columnHelper.accessor("variants", {
header: () => (
<div className="text-right">
{t("price-list-products-form-inventory-label", "Inventory")}
</div>
),
cell: (info) => {
let content: string | undefined = "-"
const variants = info.getValue()
if (!variants || variants.length === 0) {
content = "-"
}
const totalStock = variants.reduce((acc, curr) => {
return acc + curr.inventory_quantity
}, 0)
content =
t(
"price-list-products-form-inventory-value",
"{{totalStock}} in stock across {{variants}} variants",
{
totalStock,
variants: variants.length,
}
) ?? undefined
return <div className="text-right">{content}</div>
},
}),
],
[t]
)
return { columns }
}
const PriceListProductsForm = ({
form,
productIds,
}: PriceListProductsFormProps) => {
const {
register,
path,
setValue,
getValues,
control,
formState: { isDirty },
} = form
const { t } = useTranslation()
/**
* Table state.
*/
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
(getValues(path("ids")) ?? []).reduce((acc, curr) => {
return {
...acc,
[curr]: true,
}
}, {} as RowSelectionState)
)
React.useEffect(() => {
const values = getValues(path("ids")) ?? []
setRowSelection(
values.reduce((acc, curr) => {
return {
...acc,
[curr]: true,
}
}, {} as RowSelectionState)
)
}, [getValues, path])
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
/**
* Calculate the offset based on the pagination state.
*/
const offset = React.useMemo(
() => pagination.pageIndex * pagination.pageSize,
[pagination.pageIndex, pagination.pageSize]
)
const [filters, setFilters] = React.useState<ProductFilter>({
created_at: undefined,
updated_at: undefined,
})
/**
* Search query.
*/
const [query, setQuery] = React.useState<string>("")
const debouncedQuery = useDebounce(query, 500)
const { products, count, isLoading, isError } = useAdminProducts(
{
limit: PAGE_SIZE,
offset,
expand: "variants,sales_channels,collection",
q: debouncedQuery,
...filters,
},
{
keepPreviousData: true,
}
)
const pageCount = React.useMemo(() => {
return count ? Math.ceil(count / PAGE_SIZE) : 0
}, [count])
const { columns } = usePriceListProductsFormColumns()
const table = useReactTable({
columns,
data: (products as Product[] | undefined) ?? [],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
state: {
rowSelection,
pagination,
},
meta: {
productIds: productIds ?? [],
},
pageCount,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
manualPagination: true,
onPaginationChange: setPagination,
})
/**
* Register the form field.
*/
React.useEffect(() => {
register(path("ids"))
}, [register, path])
/**
* Update the form value when the row selection changes.
*
* The RowSelectionState will only contain the rows that are
* selected, so we need to get the keys and set the value to
* the array of keys.
*/
React.useEffect(() => {
setValue(path("ids"), Object.keys(rowSelection), {
shouldDirty: true,
shouldTouch: true,
})
}, [rowSelection, path, setValue])
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="animate-spin" />
<span className="sr-only">
{t("price-list-products-form-loading", "Loading products")}
</span>
</div>
)
}
if (isError) {
return (
<div className="flex h-full w-full items-center justify-center gap-x-2">
<ExclamationCircle />
<Text className="text-ui-fg-subtle">
{t(
"price-list-products-form-error",
"An error occurred while loading products. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
)
}
if (!products) {
return (
<div className="flex h-full w-full items-center justify-center gap-x-2">
<ExclamationCircle />
<Text className="text-ui-fg-subtle">
{t("price-list-products-form-no-products", "No products found.")}
</Text>
</div>
)
}
return (
<div className="flex h-full flex-col">
<div className="border-ui-border-base flex items-center justify-between border-b px-8 pt-6 pb-4">
<div className="flex items-center gap-x-3">
<Heading>
{t("price-list-products-form-heading", "Choose products")}
</Heading>
{isDirty && (
<Form.Field
control={control}
name={path("ids")}
render={() => {
return (
<Form.Item>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
</div>
<div className={clx("flex items-center gap-x-2")}>
<ProductFilterMenu
onClearFilters={() =>
setFilters({
created_at: undefined,
updated_at: undefined,
})
}
onFilterChange={setFilters}
value={filters}
/>
<Input
type="search"
placeholder={
t("price-list-products-form-search-placeholder", "Search") ??
undefined
}
size="small"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div className="border-ui-border-base relative h-full flex-1 overflow-y-auto border-b">
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx(
{
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
productIds?.includes(row.id),
},
{
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
row.getIsSelected(),
}
)}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<Table.Pagination
count={count ?? 0}
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
pageIndex={pagination.pageIndex}
pageCount={pageCount}
pageSize={pagination.pageSize}
/>
</div>
)
}
export { PriceListProductsForm }
@@ -0,0 +1,7 @@
import * as z from "zod"
export const priceListProductsSchema = z.object({
ids: z.string().array().min(1, {
message: "At least one product must be selected",
}),
})
@@ -0,0 +1,4 @@
import * as z from "zod"
import { priceListProductsSchema } from "./schema"
export type PriceListProductsSchema = z.infer<typeof priceListProductsSchema>
@@ -1,82 +1,16 @@
import { Route, Routes, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import Spacer from "../../components/atoms/spacer"
import RouteContainer from "../../components/extensions/route-container"
import WidgetContainer from "../../components/extensions/widget-container"
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../components/organisms/body-card"
import TableViewHeader from "../../components/organisms/custom-table-header"
import { useRoutes } from "../../providers/route-provider"
import { useWidgets } from "../../providers/widget-provider"
import PricingDetails from "./details"
import New from "./new"
import PricingTable from "./pricing-table"
const PricingIndex = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const actionables = [
{
label: t("pricing-add-price-list", "Add price list"),
onClick: () => navigate(`/a/pricing/new`),
icon: <PlusIcon size={20} />,
},
]
const { getWidgets } = useWidgets()
return (
<div className="gap-y-xsmall flex h-full flex-col">
{getWidgets("price_list.list.before").map((w, index) => {
return (
<WidgetContainer
key={index}
widget={w}
entity={null}
injectionZone="price_list.list.before"
/>
)
})}
<div className="flex w-full grow flex-col">
<BodyCard
actionables={actionables}
customHeader={
<TableViewHeader
views={[t("pricing-price-lists", "Price lists")]}
/>
}
className="h-fit"
>
<PricingTable />
</BodyCard>
<Spacer />
</div>
</div>
)
}
const Pricing = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/pricing")
import { Route, Routes } from "react-router-dom"
import { PriceListEdit } from "./edit"
import { PriceListNew } from "./new"
import { PriceListOverview } from "./overview"
const PriceListRoute = () => {
return (
<Routes>
<Route index element={<PricingIndex />} />
<Route path="/new" element={<New />} />
<Route path="/:id" element={<PricingDetails />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/pricing"} />}
/>
)
})}
<Route index element={<PriceListOverview />} />
<Route path="new" element={<PriceListNew />} />
<Route path=":id" element={<PriceListEdit />} />
</Routes>
)
}
export default Pricing
export default PriceListRoute
@@ -1,13 +0,0 @@
import PriceListForm from "./pricing-form"
import { PriceListFormProvider } from "./pricing-form/form/pricing-form-context"
import { ViewType } from "./pricing-form/types"
const New = () => {
return (
<PriceListFormProvider>
<PriceListForm viewType={ViewType.CREATE} />
</PriceListFormProvider>
)
}
export default New
@@ -0,0 +1 @@
export * from "./new"
@@ -0,0 +1,709 @@
import { zodResolver } from "@hookform/resolvers/zod"
import {
Button,
FocusModal,
ProgressTabs,
Text,
usePrompt,
type ProgressStatus,
} from "@medusajs/ui"
import * as React from "react"
import { useForm, useWatch } from "react-hook-form"
import * as z from "zod"
import { ExclamationCircle, Spinner } from "@medusajs/icons"
import { Product } from "@medusajs/medusa"
import { useAdminCreatePriceList } from "medusa-react"
import { Form } from "../../../components/helpers/form"
import useNotification from "../../../hooks/use-notification"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
import {
PriceListDetailsForm,
priceListDetailsSchema,
PriceListStatus,
PriceListType,
} from "../forms/price-list-details-form"
import {
getDbSafeAmount,
PriceListPricesForm,
priceListPricesSchema,
usePricesFormData,
type PricePayload,
} from "../forms/price-list-prices-form"
import {
PriceListProductPricesForm,
priceListProductPricesSchema,
type PriceListProductPricesSchema,
} from "../forms/price-list-product-prices-form"
import { useTranslation } from "react-i18next"
import {
PriceListProductsForm,
priceListProductsSchema,
} from "../forms/price-list-products-form"
enum Tab {
DETAILS = "details",
PRODUCTS = "products",
PRICES = "prices",
EDIT = "edit",
}
const priceListNewSchema = z.object({
details: priceListDetailsSchema,
products: priceListProductsSchema,
prices: priceListPricesSchema,
})
type PriceListNewSchema = z.infer<typeof priceListNewSchema>
type StepStatus = {
[key in Tab]: ProgressStatus
}
const PriceListNew = () => {
const [open, setOpen] = React.useState(false)
const [product, setProduct] = React.useState<Product | null>(null)
const [selectedIds, setSelectedIds] = React.useState<string[]>([])
const [tab, setTab] = React.useState<Tab>(Tab.DETAILS)
const [status, setStatus] = React.useState<StepStatus>({
[Tab.DETAILS]: "not-started",
[Tab.PRODUCTS]: "not-started",
[Tab.PRICES]: "not-started",
[Tab.EDIT]: "not-started",
})
const { t } = useTranslation()
const promptTitle = t("price-list-new-form-prompt-title", "Are you sure?")
const promptExitDescription = t(
"price-list-new-form-prompt-exit-description",
"You have unsaved changes, are you sure you want to exit?"
)
const promptBackDescription = t(
"price-list-new-form-prompt-back-description",
"You have unsaved changes, are you sure you want to go back?"
)
const prompt = usePrompt()
const notification = useNotification()
const { isFeatureEnabled } = useFeatureFlag()
const isTaxInclPricesEnabled = isFeatureEnabled("tax_inclusive_pricing")
const form = useForm<PriceListNewSchema>({
resolver: zodResolver(priceListNewSchema),
defaultValues: {
details: {
type: {
value: "sale",
},
general: {
name: "",
description: "",
tax_inclusive: false,
},
customer_groups: {
ids: [],
},
dates: {
ends_at: null,
starts_at: null,
},
},
products: {
ids: [],
},
prices: {
products: {},
},
},
})
const {
trigger,
reset,
getValues,
setValue,
handleSubmit,
formState: { isDirty },
} = form
const taxToggleState = useWatch({
control: form.control,
name: "details.general.tax_inclusive",
defaultValue: false,
})
const {
control: editControl,
handleSubmit: handleEditSubmit,
reset: resetEdit,
formState: { isDirty: isEditDirty },
setValue: setEditValue,
getValues: getEditValues,
} = useForm<PriceListProductPricesSchema>({
resolver: zodResolver(priceListProductPricesSchema),
})
const { mutateAsync, isLoading: isSubmitting } = useAdminCreatePriceList()
const { isLoading, isError, isNotFound, regions, currencies } =
usePricesFormData({
productIds: selectedIds,
})
const onCloseModal = React.useCallback(() => {
setOpen(false)
setTab(Tab.DETAILS)
setStatus({
[Tab.DETAILS]: "not-started",
[Tab.PRODUCTS]: "not-started",
[Tab.PRICES]: "not-started",
[Tab.EDIT]: "not-started",
})
resetEdit()
reset()
}, [reset, resetEdit])
const onModalStateChange = React.useCallback(
async (open: boolean) => {
if (!open && (isDirty || isEditDirty)) {
const response = await prompt({
title: promptTitle,
description: promptExitDescription,
})
if (!response) {
setOpen(true)
return
}
onCloseModal()
}
setOpen(open)
},
[
isDirty,
isEditDirty,
promptTitle,
promptExitDescription,
prompt,
onCloseModal,
]
)
/**
* On hitting "Save Prices" in the edit tab, we need
* to update the corresponding product in the form.
*/
const onSavePriceEdit = handleEditSubmit((data) => {
if (!product) {
return
}
setValue(`prices.products.${product.id}`, data, {
shouldDirty: true,
shouldTouch: true,
})
setProduct(null)
resetEdit(undefined, {
keepDirty: false,
keepTouched: false,
})
setTab(Tab.PRICES)
})
const onSubmit = React.useCallback(
async (status: PriceListStatus) => {
await handleSubmit(async (data) => {
const prices: PricePayload[] = []
/**
* Loop through all the products and variants
* and create a payload for each price.
*
* If a price does not have an amount, we
* skip it.
*/
for (const productId of Object.keys(data.prices.products)) {
const product = data.prices.products[productId]
for (const variantId of Object.keys(product.variants)) {
const variant = product.variants[variantId]
if (variant.currency) {
for (const currencyCode of Object.keys(variant.currency)) {
const { amount } = variant.currency[currencyCode]
if (!amount) {
continue
}
const dbSafeAmount = getDbSafeAmount(
currencyCode,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
amount: dbSafeAmount,
variant_id: variantId,
currency_code: currencyCode,
}
prices.push(payload)
}
}
if (variant.region) {
for (const regionId of Object.keys(variant.region)) {
const { amount } = variant.region[regionId]
if (!amount) {
continue
}
const dbSafeAmount = getDbSafeAmount(
regions.find((r) => r.id === regionId)!.currency_code,
parseFloat(amount)
)
if (!dbSafeAmount) {
continue
}
const payload: PricePayload = {
amount: dbSafeAmount,
variant_id: variantId,
region_id: regionId,
}
prices.push(payload)
}
}
}
}
await mutateAsync(
{
name: data.details.general.name,
description: data.details.general.description,
type: data.details.type.value as PriceListType,
includes_tax: isTaxInclPricesEnabled
? data.details.general.tax_inclusive
: undefined,
customer_groups: data.details.customer_groups.ids.map((id) => ({
id,
})),
status: status,
ends_at: data.details.dates.ends_at || undefined,
starts_at: data.details.dates.starts_at || undefined,
prices,
},
{
onSuccess: () => {
notification(
t(
"price-list-new-form-notification-success-title",
"Price list created"
),
t(
"price-list-new-form-notification-success-message",
`Successfully created price list`
),
"success"
)
onCloseModal()
},
onError: (err) => {
notification(
t(
"price-list-new-form-notification-error-title",
"An error occurred"
),
getErrorMessage(err),
"error"
)
},
}
)
})()
},
[
handleSubmit,
mutateAsync,
notification,
onCloseModal,
t,
isTaxInclPricesEnabled,
regions,
]
)
const onSetProduct = React.useCallback(
(product: Product | null) => {
if (!product) {
setProduct(null)
setTab(Tab.PRICES)
return
}
const defaultValues = getValues(`prices.products.${product.id}`)
resetEdit(defaultValues)
setProduct(product)
setTab(Tab.EDIT)
},
[resetEdit, getValues]
)
/**
* When exiting the "Edit" tab, we need to check
* if the user has unsaved changes. If they do,
* we need to prompt them whether they want to
* continue or not.
*/
const onExitProductPrices = React.useCallback(
async (tab = Tab.PRICES) => {
if (isEditDirty) {
const res = await prompt({
title: promptTitle,
description: promptBackDescription,
})
if (!res) {
return
}
}
setTab(tab)
setProduct(null)
resetEdit(undefined, {
keepDirty: false,
keepTouched: false,
})
},
[prompt, resetEdit, isEditDirty, promptTitle, promptBackDescription]
)
/**
* If the current tab is edit, we need to
* check if the user wants to exit the edit
* tab or if they want to save the changes
* before continuing.
*/
const onTabChange = React.useCallback(
async (value: Tab) => {
if (tab === Tab.EDIT) {
await onExitProductPrices(value)
return
}
setTab(value)
},
[tab, onExitProductPrices]
)
/**
* Callback for ensuring that we don't submit prices
* for products that the user has unselected.
*/
const onUpdateSelectedProductIds = React.useCallback(
(ids: string[]) => {
setSelectedIds((prev) => {
/**
* If the previous ids are the same as the new ids,
* we need to unregister the old ids that are no
* longer selected.
*/
for (const id of prev) {
if (!ids.includes(id)) {
setValue(`prices.products.${id}`, { variants: {} })
}
}
return ids
})
},
[setValue]
)
/**
* Callback for validating the details form.
*/
const onValidateDetails = React.useCallback(async () => {
const result = await trigger("details")
if (!result) {
setStatus((prev) => ({
...prev,
[Tab.DETAILS]: "in-progress",
}))
return
}
setTab(Tab.PRODUCTS)
setStatus((prev) => ({
...prev,
[Tab.DETAILS]: "completed",
}))
}, [trigger])
/**
* Callback for validating the products form.
*/
const onValidateProducts = React.useCallback(async () => {
const result = await trigger("products")
if (!result) {
setStatus((prev) => ({
...prev,
[Tab.PRODUCTS]: "in-progress",
}))
return
}
const ids = getValues("products.ids")
onUpdateSelectedProductIds(ids)
setTab(Tab.PRICES)
setStatus((prev) => ({
...prev,
[Tab.PRODUCTS]: "completed",
}))
}, [trigger, getValues, onUpdateSelectedProductIds])
/**
* Depending on the current tab, the next button
* will have different functionality.
*/
const onNext = React.useCallback(async () => {
switch (tab) {
case Tab.DETAILS:
await onValidateDetails()
break
case Tab.PRODUCTS:
await onValidateProducts()
break
case Tab.PRICES:
await onSubmit(PriceListStatus.ACTIVE)
break
case Tab.EDIT:
await onSavePriceEdit()
break
}
}, [onValidateDetails, onValidateProducts, onSubmit, onSavePriceEdit, tab])
const nextButtonText = React.useMemo(() => {
switch (tab) {
case Tab.PRICES:
return t(
"price-list-new-form-next-button-save-and-publish",
"Save and Publish"
)
case Tab.EDIT:
return t("price-list-new-form-next-button-save", "Save Prices")
default:
return t("price-list-new-form-next-button-continue", "Continue")
}
}, [tab, t])
/**
* Depending on the current tab, the back button
* will have different functionality.
*/
const onBack = React.useCallback(async () => {
switch (tab) {
case Tab.DETAILS:
await onModalStateChange(false)
break
case Tab.PRODUCTS:
setTab(Tab.DETAILS)
break
case Tab.PRICES:
setTab(Tab.PRODUCTS)
break
case Tab.EDIT:
await onExitProductPrices()
break
}
}, [onModalStateChange, onExitProductPrices, tab])
const backButtonText = React.useMemo(() => {
switch (tab) {
case Tab.DETAILS:
return t("price-list-new-form-back-button-cancel", "Cancel")
default:
return t("price-list-new-form-back-button-back", "Back")
}
}, [tab, t])
return (
<FocusModal open={open} onOpenChange={onModalStateChange}>
<FocusModal.Trigger asChild>
<Button variant="secondary">Create New</Button>
</FocusModal.Trigger>
<ProgressTabs
value={tab}
onValueChange={(tab) => onTabChange(tab as Tab)}
>
<FocusModal.Content>
<FocusModal.Header className="flex w-full items-center justify-start">
<ProgressTabs.List className="border-ui-border-base -my-2 ml-2 min-w-0 flex-1 border-l">
<ProgressTabs.Trigger
value={Tab.DETAILS}
className="w-full min-w-0 max-w-[200px]"
status={status[Tab.DETAILS]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("price-list-new-form-details-tab", "Create Price List")}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.PRODUCTS}
disabled={status[Tab.DETAILS] !== "completed"}
className="w-full min-w-0 max-w-[200px]"
status={status[Tab.PRODUCTS]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("price-list-new-form-products-tab", "Choose Products")}
</span>
</ProgressTabs.Trigger>
<ProgressTabs.Trigger
value={Tab.PRICES}
disabled={
status[Tab.DETAILS] !== "completed" &&
status[Tab.PRODUCTS] !== "completed"
}
className="w-full min-w-0 max-w-[200px]"
status={status[Tab.PRICES]}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{t("price-list-new-form-prices-tab", "Edit Prices")}
</span>
</ProgressTabs.Trigger>
{product && (
<ProgressTabs.Trigger
value={Tab.EDIT}
disabled={isLoading || isError}
className="w-full min-w-0 max-w-[200px]"
status={isEditDirty ? "in-progress" : "not-started"}
>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap">
{product?.title}
</span>
</ProgressTabs.Trigger>
)}
</ProgressTabs.List>
<div className="ml-auto flex items-center justify-end gap-x-2">
<Button
variant="secondary"
onClick={onBack}
disabled={isSubmitting}
>
{backButtonText}
</Button>
{tab === Tab.PRICES && !isSubmitting && (
<Button onClick={() => onSubmit(PriceListStatus.DRAFT)}>
{t("price-list-new-form-save-as-draft", "Save as Draft")}
</Button>
)}
<Button type="button" onClick={onNext} isLoading={isSubmitting}>
{nextButtonText}
</Button>
</div>
</FocusModal.Header>
{open && (
<FocusModal.Body className="flex h-full w-full flex-col items-center overflow-y-auto">
<Form {...form}>
<ProgressTabs.Content
value={Tab.DETAILS}
className="h-full w-full max-w-[720px]"
>
<div className="px-8 py-12">
<PriceListDetailsForm
form={nestedForm(form, "details")}
layout="focus"
enableTaxToggle={isTaxInclPricesEnabled}
/>
</div>
</ProgressTabs.Content>
<ProgressTabs.Content
value={Tab.PRODUCTS}
className="h-full w-full"
>
<PriceListProductsForm form={nestedForm(form, "products")} />
</ProgressTabs.Content>
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Spinner className="text-ui-fg-subtle animate-spin" />
</div>
) : isError || isNotFound ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-ui-fg-subtle flex items-center gap-x-2">
<ExclamationCircle />
<Text>
{t(
"price-list-new-form-error-loading-products",
"An error occurred while preparing the form. Reload the page and try again. If the issue persists, try again later."
)}
</Text>
</div>
</div>
) : (
<React.Fragment>
<ProgressTabs.Content
value={Tab.PRICES}
className="h-full w-full"
>
<PriceListPricesForm
setProduct={onSetProduct}
form={nestedForm(form, "prices")}
productIds={selectedIds}
/>
</ProgressTabs.Content>
{product && (
<ProgressTabs.Content
value={Tab.EDIT}
className="h-full w-full"
>
<PriceListProductPricesForm
priceListTaxInclusive={taxToggleState}
taxInclEnabled={isTaxInclPricesEnabled}
product={product}
currencies={currencies}
regions={regions}
control={editControl}
getValues={getEditValues}
setValue={setEditValue}
/>
</ProgressTabs.Content>
)}
</React.Fragment>
)}
</Form>
</FocusModal.Body>
)}
</FocusModal.Content>
</ProgressTabs>
</FocusModal>
)
}
export { PriceListNew }
@@ -0,0 +1 @@
export * from "./overview"
@@ -0,0 +1,479 @@
import {
EllipseGreenSolid,
EllipseGreySolid,
EllipsisHorizontal,
ExclamationCircle,
PencilSquare,
Spinner,
Trash,
} from "@medusajs/icons"
import { DateComparisonOperator, PriceList } from "@medusajs/medusa"
import {
Container,
DropdownMenu,
Heading,
IconButton,
Input,
StatusBadge,
Table,
Text,
clx,
usePrompt,
} from "@medusajs/ui"
import {
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"
import {
useAdminDeletePriceList,
useAdminPriceLists,
useAdminUpdatePriceList,
} from "medusa-react"
import * as React from "react"
import { useNavigate, useSearchParams } from "react-router-dom"
import Spacer from "../../../components/atoms/spacer"
import WidgetContainer from "../../../components/extensions/widget-container"
import { FilterMenu } from "../../../components/molecules/filter-menu"
import { useDebouncedSearchParam } from "../../../hooks/use-debounced-search-param"
import useNotification from "../../../hooks/use-notification"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorMessage } from "../../../utils/error-messages"
import {
getDateComparisonOperatorFromSearchParams,
getStringArrayFromSearchParams,
getStringFromSearchParams,
} from "../../../utils/search-param-utils"
import { PriceListStatus } from "../forms/price-list-details-form"
import { PriceListNew } from "../new"
const PAGE_SIZE = 10
const TABLE_HEIGHT = (PAGE_SIZE + 1) * 48
const PriceListTableFilters = () => {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const onStatusChange = (status: string[]) => {
const current = new URLSearchParams(searchParams)
if (status.length === 0) {
current.delete("status")
navigate({ search: current.toString() }, { replace: true })
return
}
if (current.has("status")) {
current.delete("status")
}
current.set("status", status.join(","))
navigate({ search: current.toString() }, { replace: true })
}
const onDateChange = (
key: "updated_at" | "created_at",
value?: DateComparisonOperator
) => {
const current = new URLSearchParams(searchParams)
if (value) {
current.set(key, JSON.stringify(value))
} else {
current.delete(key)
}
navigate({ search: current.toString() }, { replace: true })
}
const onClearFilters = () => {
const reset = new URLSearchParams()
navigate({ search: reset.toString() }, { replace: true })
}
return (
<FilterMenu onClearFilters={onClearFilters}>
<FilterMenu.Content>
<FilterMenu.SelectItem
name="Status"
onChange={onStatusChange}
options={[
{ label: "Active", value: "active" },
{ label: "Draft", value: "draft" },
]}
value={getStringArrayFromSearchParams("status", searchParams)}
/>
<FilterMenu.Seperator />
<FilterMenu.DateItem
name="Created at"
value={getDateComparisonOperatorFromSearchParams(
"created_at",
searchParams
)}
onChange={(dc) => onDateChange("created_at", dc)}
/>
<FilterMenu.Seperator />
<FilterMenu.DateItem
name="Updated at"
value={getDateComparisonOperatorFromSearchParams(
"updated_at",
searchParams
)}
onChange={(dc) => onDateChange("updated_at", dc)}
/>
</FilterMenu.Content>
</FilterMenu>
)
}
const PriceListOverview = () => {
const { getWidgets } = useWidgets()
const [searchParams] = useSearchParams()
const { query, setQuery } = useDebouncedSearchParam()
const navigate = useNavigate()
const { price_lists, count, isLoading, isError } = useAdminPriceLists(
{
limit: PAGE_SIZE,
status: getStringArrayFromSearchParams("status", searchParams) as
| PriceListStatus[]
| undefined,
created_at: getDateComparisonOperatorFromSearchParams(
"created_at",
searchParams
),
updated_at: getDateComparisonOperatorFromSearchParams(
"updated_at",
searchParams
),
q: getStringFromSearchParams("q", searchParams),
expand: "customer_groups",
},
{
keepPreviousData: true,
}
)
const table = useReactTable<PriceList>({
data: price_lists ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
if (isLoading) {
return (
<Container
style={{
height: TABLE_HEIGHT + 143, // Table height + header height + pagination height
}}
className="flex items-center justify-center"
>
<Spinner className="text-ui-fg-subtle animate-spin" />
</Container>
)
}
if (isError) {
return (
<Container
style={{
height: TABLE_HEIGHT + 143, // Table height + header height + pagination height
}}
className="flex items-center justify-center"
>
<div className="flex items-center gap-x-2">
<ExclamationCircle className="text-ui-fg-base" />
<Text className="text-ui-fg-subtle">
An error occurred while loading the price lists. Try to reload the
page or try again later.
</Text>
</div>
</Container>
)
}
return (
<div>
<div className="flex flex-col gap-y-2">
{getWidgets("price_list.list.before").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"price_list.list.before"}
widget={w}
entity={undefined}
/>
)
})}
<Container className="overflow-hidden p-0">
<div className="flex items-center justify-between px-8 pt-6 pb-4">
<Heading>Price Lists</Heading>
<div className="flex items-center gap-x-2">
<PriceListTableFilters />
<Input
size="small"
type="search"
placeholder="Search"
value={query}
onChange={(e) => {
setQuery(e.target.value)
}}
/>
<PriceListNew />
</div>
</div>
<div
style={{
height: TABLE_HEIGHT,
}}
>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th]:w-1/5 [&_th:last-of-type]:w-[1%]"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{table.getRowModel().rows.map((row) => (
<Table.Row
key={row.id}
className={clx("cursor-pointer [&_td:last-of-type]:w-[1%]")}
onClick={() => {
navigate(`/a/pricing/${row.original.id}`)
}}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<Table.Pagination
className={clx({
"border-ui-border-base border-t":
price_lists?.length !== PAGE_SIZE,
})}
count={count ?? 0}
canNextPage={table.getCanNextPage()}
canPreviousPage={table.getCanPreviousPage()}
nextPage={table.nextPage}
previousPage={table.previousPage}
pageIndex={table.getState().pagination.pageIndex}
pageCount={table.getPageCount()}
pageSize={PAGE_SIZE}
/>
</Container>
{getWidgets("price_list.list.after").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"price_list.list.after"}
widget={w}
entity={undefined}
/>
)
})}
</div>
<Spacer />
</div>
)
}
const columnHelper = createColumnHelper<PriceList>()
const columns = [
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("description", {
header: "Description",
cell: (info) => (
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("status", {
header: "Status",
cell: ({ getValue, row }) => {
const startsAt = row.original.starts_at
const endsAt = row.original.ends_at
const isExpired = endsAt ? new Date(endsAt) < new Date() : false
const isScheduled = startsAt ? new Date(startsAt) > new Date() : false
const isDraft = getValue() === PriceListStatus.DRAFT
const color = isExpired
? "red"
: isDraft
? "grey"
: isScheduled
? "orange"
: "green"
const text = isExpired
? "Expired"
: isDraft
? "Draft"
: isScheduled
? "Scheduled"
: "Active"
return (
<StatusBadge color={color}>
<span className="capitalize">{text}</span>
</StatusBadge>
)
},
}),
columnHelper.accessor("customer_groups", {
header: "Customer Groups",
cell: (info) => info.getValue()?.length || "-",
}),
columnHelper.display({
id: "actions",
cell: (info) => {
return <PriceListTableRowActions row={info.row} />
},
}),
]
type PriceListTableRowActionsProps = {
row: Row<PriceList>
}
const PriceListTableRowActions = ({ row }: PriceListTableRowActionsProps) => {
const { mutateAsync: deleteFn } = useAdminDeletePriceList(row.original.id)
const { mutateAsync: updateFn } = useAdminUpdatePriceList(row.original.id)
const prompt = usePrompt()
const notification = useNotification()
const navigate = useNavigate()
const handleDelete = async () => {
const response = await prompt({
title: "Are you sure?",
description: "This will permanently delete the price list",
verificationText: row.original.name,
})
if (!response) {
return
}
return deleteFn(undefined, {
onSuccess: () => {
notification(
"Price list deleted",
`Successfully deleted ${row.original.name}`,
"success"
)
},
onError: (err) => {
notification("An error occurred", getErrorMessage(err), "error")
},
})
}
const toggleStatus = async (e: React.MouseEvent) => {
e.stopPropagation()
return updateFn(
{
status:
row.original.status === "active"
? PriceListStatus.DRAFT
: PriceListStatus.ACTIVE,
},
{
onSuccess: () => {
notification(
"Price list updated",
`Successfully updated ${row.original.name}`,
"success"
)
},
onError: (err) => {
notification("An error occurred", getErrorMessage(err), "error")
},
}
)
}
const handleNavigate = (e: React.MouseEvent) => {
e.stopPropagation()
navigate(`/a/pricing/${row.original.id}`)
}
const isExpired = row.original.ends_at
? new Date(row.original.ends_at) < new Date()
: false
const isActive = row.original.status === "active"
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton variant="transparent">
<EllipsisHorizontal className="text-ui-fg-subtle" />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={handleNavigate}>
<PencilSquare className="text-ui-fg-subtle" />
<span className="ml-2">Edit</span>
</DropdownMenu.Item>
{!isExpired && (
<DropdownMenu.Item onClick={toggleStatus}>
{isActive ? <EllipseGreySolid /> : <EllipseGreenSolid />}
<span className="ml-2">
{isActive ? "Mark as draft" : "Mark as active"}
</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Separator />
<DropdownMenu.Item onClick={handleDelete}>
<Trash className="text-ui-fg-subtle" />
<span className="ml-2">Delete</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
export { PriceListOverview }
@@ -1,191 +0,0 @@
import { useAdminCreatePriceList, useAdminUpdatePriceList } from "medusa-react"
import { useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import Button from "../../../../components/fundamentals/button"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import useNotification from "../../../../hooks/use-notification"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { getErrorMessage } from "../../../../utils/error-messages"
import {
mapFormValuesToCreatePriceList,
mapFormValuesToUpdatePriceListDetails,
mapFormValuesToUpdatePriceListPrices,
} from "../form/mappers"
import { usePriceListForm } from "../form/pricing-form-context"
import {
CreatePriceListFormValues,
HeaderAction,
PriceListFormProps,
PriceListFormValues,
PriceListStatus,
ViewType,
} from "../types"
const FormHeader = (props: PriceListFormProps & { onClose?: () => void }) => {
const { t } = useTranslation()
const { handleSubmit } = usePriceListForm()
const navigate = useNavigate()
const notification = useNotification()
const closeForm = () => {
if (props.viewType !== ViewType.CREATE && props.onClose) {
props.onClose()
} else {
navigate(-1)
}
}
const createPriceList = useAdminCreatePriceList()
const updatePriceList = useAdminUpdatePriceList(props.id!)
const { isFeatureEnabled } = useFeatureFlag()
const onPublish = (values: CreatePriceListFormValues) => {
const data = mapFormValuesToCreatePriceList(values, PriceListStatus.ACTIVE)
if (isFeatureEnabled("tax_inclusive_pricing")) {
data.includes_tax = !!values.includes_tax
}
createPriceList.mutate(data, {
onSuccess: ({ price_list }) => {
navigate(`/a/pricing/${price_list.id}`)
},
onError: (error) => {
notification(
t("form-header-error", "Error"),
getErrorMessage(error),
"error"
)
},
})
}
const onSaveAsDraft = (values: CreatePriceListFormValues) => {
const data = mapFormValuesToCreatePriceList(values, PriceListStatus.DRAFT)
if (isFeatureEnabled("tax_inclusive_pricing")) {
data.includes_tax = values.includes_tax
}
createPriceList.mutate(data, {
onSuccess: ({ price_list }) => {
navigate(`/a/pricing/${price_list.id}`)
},
onError: (error) => {
notification(
t("form-header-error", "Error"),
getErrorMessage(error),
"error"
)
},
})
}
const onUpdateDetails = (values: PriceListFormValues) => {
const data = mapFormValuesToUpdatePriceListDetails(values)
if (isFeatureEnabled("tax_inclusive_pricing")) {
data.includes_tax = values.includes_tax
}
updatePriceList.mutate(data, {
onSuccess: ({ price_list }) => {
notification(
t("form-header-success", "Success"),
t(
"form-header-successfully-updated-price-list",
"Successfully updated price list"
),
"success"
)
closeForm()
},
onError: (error) => {
notification(
t("form-header-error", "Error"),
getErrorMessage(error),
"error"
)
},
})
}
const onUpdatePrices = (values: PriceListFormValues) => {
updatePriceList.mutate(mapFormValuesToUpdatePriceListPrices(values), {
onSuccess: ({ price_list }) => {
props.onClose && props.onClose()
},
onError: (error) => {
notification(
t("form-header-error", "Error"),
getErrorMessage(error),
"error"
)
},
})
}
let mainAction: HeaderAction
let secondaryAction: HeaderAction
switch (props.viewType) {
case ViewType.CREATE:
mainAction = {
label: t("form-header-publish-price-list", "Publish price list"),
onClick: handleSubmit(onPublish),
}
secondaryAction = {
label: t("form-header-save-as-draft", "Save as draft"),
onClick: handleSubmit(onSaveAsDraft),
}
break
case ViewType.EDIT_DETAILS:
mainAction = {
label: t("form-header-save-changes", "Save changes"),
onClick: handleSubmit(onUpdateDetails),
}
secondaryAction = {
label: t("form-header-cancel", "Cancel"),
onClick: closeForm,
}
break
case ViewType.EDIT_PRICES:
mainAction = {
label: t("form-header-save-changes", "Save changes"),
onClick: handleSubmit(onUpdatePrices),
}
secondaryAction = {
label: t("form-header-cancel", "Cancel"),
onClick: closeForm,
}
break
}
return (
<div className="medium:w-8/12 flex w-full justify-between px-8">
<Button
size="small"
variant="ghost"
onClick={closeForm}
className="rounded-rounded h-8 w-8 border"
>
<CrossIcon size={20} />
</Button>
<div className="gap-x-small flex">
<Button
onClick={secondaryAction.onClick}
size="small"
variant="ghost"
className="rounded-rounded border"
>
{secondaryAction.label}
</Button>
<Button
size="small"
variant="primary"
onClick={mainAction.onClick}
className="rounded-rounded"
>
{mainAction.label}
</Button>
</div>
</div>
)
}
export default FormHeader
@@ -1,109 +0,0 @@
import {
AdminPostPriceListsPriceListPriceListReq,
AdminPostPriceListsPriceListReq,
PriceList,
} from "@medusajs/medusa"
import xorObjFields from "../../../../utils/xorObjFields"
import {
CreatePriceListFormValues,
CreatePriceListPricesFormValues,
PriceListFormValues,
PriceListStatus,
PriceListType,
} from "../types"
export const mapPriceListToFormValues = (
priceList: PriceList
): PriceListFormValues => {
return {
description: priceList.description,
type: priceList.type,
name: priceList.name,
ends_at: priceList.ends_at ? new Date(priceList.ends_at) : null,
starts_at: priceList.starts_at ? new Date(priceList.starts_at) : null,
prices: priceList.prices.map((p) => ({
amount: p.amount,
max_quantity: p.max_quantity,
min_quantity: p.min_quantity,
variant_id: p.variant_id,
currency_code: p.currency_code,
region_id: p.region_id,
})),
customer_groups: priceList.customer_groups.map((pl) => ({
label: pl.name,
value: pl.id,
})),
includes_tax: priceList.includes_tax,
}
}
export const mapFormValuesToCreatePriceList = (
values: CreatePriceListFormValues,
status: PriceListStatus
): AdminPostPriceListsPriceListReq => {
let prices
if (values.prices) {
prices = Object.entries(values.prices)
.map(([variantId, price]) =>
price.map((pr) => ({
variant_id: variantId,
amount: pr.amount,
...xorObjFields(pr, "currency_code", "region_id"),
min_quantity: pr.min_quantity,
max_quantity: pr.max_quantity,
}))
)
.flat(1)
}
return {
description: values.description!,
name: values.name!,
type: PriceListType.SALE,
status,
customer_groups: values.customer_groups
? values.customer_groups.map((cg) => ({ id: cg.value }))
: undefined,
ends_at: values.ends_at || undefined,
starts_at: values.starts_at || undefined,
prices,
}
}
export const mapFormValuesToUpdatePriceListDetails = (
values: PriceListFormValues
): AdminPostPriceListsPriceListPriceListReq => {
return {
name: values.name || undefined,
description: values.description || undefined,
customer_groups: values.customer_groups
? values.customer_groups.map((cg) => ({ id: cg.value }))
: [],
ends_at: values.ends_at,
starts_at: values.starts_at,
type: values.type || undefined,
}
}
export const mapFormValuesToUpdatePriceListPrices = (
values: PriceListFormValues & { prices: CreatePriceListPricesFormValues }
): AdminPostPriceListsPriceListPriceListReq | void => {
let prices
if (values.prices) {
prices = Object.entries(values.prices)
.map(([variantId, price]) =>
price.map((pr) => ({
variant_id: variantId,
amount: pr.amount,
...xorObjFields(pr, "currency_code", "region_id"),
min_quantity: pr.min_quantity,
max_quantity: pr.max_quantity,
}))
)
.flat(1)
return {
prices,
}
}
}
@@ -1,168 +0,0 @@
import React, { useState } from "react"
import {
FieldValues,
FormProvider,
SubmitHandler,
useForm,
useFormContext,
useWatch,
} from "react-hook-form"
import { weekFromNow } from "../../../../utils/date-utils"
import {
ConfigurationField,
CreatePriceListPricesFormValues,
PriceListFormValues,
PriceListType,
} from "../types"
const defaultState: PriceListFormValues = {
customer_groups: [],
name: null,
description: null,
ends_at: null,
starts_at: null,
prices: null,
type: PriceListType.SALE,
includes_tax: null,
}
const PriceListFormContext = React.createContext<{
configFields: Pick<
PriceListFormValues,
"starts_at" | "ends_at" | "customer_groups"
>
handleConfigurationSwitch: (values: string[]) => void
prices: CreatePriceListPricesFormValues | null
setPrices: React.Dispatch<
React.SetStateAction<CreatePriceListPricesFormValues | null>
>
handleSubmit: <T extends FieldValues>(
submitHandler: SubmitHandler<T>
) => (e?: React.BaseSyntheticEvent) => Promise<void>
} | null>(null)
type FormProviderProps = {
priceList?: PriceListFormValues
children?: React.ReactNode
}
export const PriceListFormProvider: React.FC<FormProviderProps> = ({
priceList = defaultState,
children,
}) => {
const [configFields, setConfigFields] = useState<
Pick<PriceListFormValues, "starts_at" | "ends_at" | "customer_groups">
>({
customer_groups: priceList.customer_groups,
ends_at: priceList.ends_at,
starts_at: priceList.starts_at,
})
const methods = useForm<PriceListFormValues>({
defaultValues: priceList,
})
const [prices, setPrices] = useState<CreatePriceListPricesFormValues | null>(
null
)
const currentStartsAt = useWatch({
name: "starts_at",
control: methods.control,
})
const currentEndsAt = useWatch({
name: "ends_at",
control: methods.control,
})
const currentCustomerGroups = useWatch({
name: "customer_groups",
control: methods.control,
})
const disableConfiguration = (configField: ConfigurationField) => {
let configToSave: unknown | null = null
switch (configField) {
case "customer_groups":
configToSave = currentCustomerGroups
break
case "starts_at":
configToSave = currentStartsAt
break
case "ends_at":
configToSave = currentEndsAt
break
}
// we save the configuration field value to the state, so that if the user re-enables the field we can populate it with the previous value
setConfigFields({
...configFields,
[configField]: configToSave,
})
// use timeout to avoid flashing default value
setTimeout(() => methods.setValue(configField, null), 300)
}
const enableConfiguration = (configField: ConfigurationField) => {
// we get the configuration field value from the state, so that if the user re-enables the field we can populate it with the previous value
if (configFields[configField]) {
methods.setValue(configField, configFields[configField])
} else {
// if the configuration field value is null, we set a default value
switch (configField) {
case "starts_at":
methods.setValue("starts_at", new Date())
break
case "ends_at":
methods.setValue("ends_at", weekFromNow())
break
case "customer_groups":
methods.setValue("customer_groups", [])
break
}
}
}
const handleConfigurationSwitch = (values: string[]) => {
for (const key of Object.keys(configFields)) {
if (values.includes(key)) {
enableConfiguration(key as ConfigurationField)
} else {
disableConfiguration(key as ConfigurationField)
}
}
}
const handleSubmit = (submitHandler) => {
return methods.handleSubmit((values) => {
submitHandler({ ...values, prices })
})
}
return (
<FormProvider {...methods}>
<PriceListFormContext.Provider
value={{
configFields,
handleConfigurationSwitch,
prices,
handleSubmit,
setPrices,
}}
>
{children}
</PriceListFormContext.Provider>
</FormProvider>
)
}
export const usePriceListForm = () => {
const context = React.useContext(PriceListFormContext)
const form = useFormContext<PriceListFormValues>()
if (context === null) {
throw new Error(
"usePriceListForm must be used within a PriceListFormProvider"
)
}
return { ...form, ...context }
}
@@ -1,47 +0,0 @@
import { useTranslation } from "react-i18next"
import FocusModal from "../../../components/molecules/modal/focus-modal"
import Accordion from "../../../components/organisms/accordion"
import FormHeader from "./form-header"
import Configuration from "./sections/configuration"
import General from "./sections/general"
import Prices from "./sections/prices"
import Type from "./sections/type"
import { PriceListFormProps, ViewType } from "./types"
const PriceListForm = (props: PriceListFormProps) => {
const { t } = useTranslation()
return (
<FocusModal>
<FocusModal.Header>
<FormHeader {...props} />
</FocusModal.Header>
<FocusModal.Main>
<div className="mb-[25%] flex justify-center">
<div className="medium:w-7/12 large:w-6/12 small:w-4/5 w-full pt-16">
<h1 className="inter-xlarge-semibold mb-[28px]">
{props.viewType === ViewType.CREATE
? t(
"pricing-form-create-new-price-list",
"Create new price list"
)
: t("pricing-form-edit-price-list", "Edit price list")}
</h1>
<Accordion type="multiple" defaultValue={["type"]}>
<Type />
<General />
<Configuration />
{props.viewType !== ViewType.EDIT_DETAILS && (
<Prices
isEdit={props.viewType !== ViewType.CREATE}
id={props.id}
/>
)}
</Accordion>
</div>
</div>
</FocusModal.Main>
</FocusModal>
)
}
export default PriceListForm
@@ -1,222 +0,0 @@
import { PriceList } from "@medusajs/medusa"
import clsx from "clsx"
import { useAdminCustomerGroups } from "medusa-react"
import React, { useState } from "react"
import { Controller } from "react-hook-form"
import { useTranslation } from "react-i18next"
import DatePicker from "../../../../components/atoms/date-picker/date-picker"
import TimePicker from "../../../../components/atoms/date-picker/time-picker"
import Switch from "../../../../components/atoms/switch"
import Accordion from "../../../../components/organisms/accordion"
import { weekFromNow } from "../../../../utils/date-utils"
import { usePriceListForm } from "../form/pricing-form-context"
import { ConfigurationFields } from "../types"
import { NextSelect } from "../../../../components/molecules/select/next-select"
type ConfigurationProps = {
priceList?: PriceList
}
const checkForEnabledConfigs = (config: ConfigurationFields): string[] => {
const { t } = useTranslation()
const enabledConfigs: string[] = []
if (config.customer_groups && config.customer_groups.length > 0) {
enabledConfigs.push("customer_groups")
}
if (config.starts_at) {
enabledConfigs.push("starts_at")
}
if (config.ends_at) {
enabledConfigs.push("ends_at")
}
return enabledConfigs
}
const Configuration: React.FC<ConfigurationProps> = () => {
const { t } = useTranslation()
const { customer_groups, isLoading } = useAdminCustomerGroups()
const { control, handleConfigurationSwitch, configFields } =
usePriceListForm()
const [openItems, setOpenItems] = useState<string[]>(
checkForEnabledConfigs(configFields)
)
return (
<Accordion.Item
forceMountContent
title={t("sections-configuration", "Configuration")}
tooltip={t(
"sections-optional-configuration-for-the-price-list",
"Optional configuration for the price list"
)}
value="configuration"
description={t(
"sections-price-overrides-time-application",
"The price overrides apply from the time you hit the publish button and forever if left untouched."
)}
>
<Accordion
type="multiple"
value={openItems}
onValueChange={(values) => {
handleConfigurationSwitch(values)
setOpenItems(values)
}}
>
<div className="mt-5">
<Accordion.Item
headingSize="medium"
forceMountContent
className="border-b-0"
title={t(
"sections-price-overrides-has-a-start-date",
"Price overrides has a start date?"
)}
subtitle={t(
"sections-schedule-the-price-overrides-to-activate-in-the-future",
"Schedule the price overrides to activate in the future."
)}
value="starts_at"
customTrigger={
<Switch checked={openItems.indexOf("starts_at") > -1} />
}
>
<div
className={clsx(
"gap-xsmall accordion-margin-transition flex items-center",
{
"mt-4": openItems.indexOf("starts_at") > -1,
}
)}
>
<Controller
name="starts_at"
control={control}
render={({ field: { value, onChange } }) => {
const ensuredDate = value || new Date()
return (
<>
<DatePicker
date={ensuredDate}
label={t("sections-start-date", "Start date")}
onSubmitDate={onChange}
/>
<TimePicker
date={ensuredDate}
label={t("sections-start-date", "Start date")}
onSubmitDate={onChange}
/>
</>
)
}}
/>
</div>
</Accordion.Item>
<Accordion.Item
headingSize="medium"
forceMountContent
className="border-b-0"
title={t(
"sections-price-overrides-has-an-expiry-date",
"Price overrides has an expiry date?"
)}
subtitle={t(
"sections-schedule-the-price-overrides-to-deactivate-in-the-future",
"Schedule the price overrides to deactivate in the future."
)}
value="ends_at"
customTrigger={
<Switch checked={openItems.indexOf("ends_at") > -1} />
}
>
<div
className={clsx(
"gap-xsmall accordion-margin-transition flex items-center",
{
"mt-4": openItems.indexOf("ends_at") > -1,
}
)}
>
<Controller
name="ends_at"
control={control}
render={({ field: { value, onChange } }) => {
const ensuredDate = value || weekFromNow()
return (
<>
<DatePicker
date={ensuredDate}
label={t("sections-end-date", "End date")}
onSubmitDate={onChange}
/>
<TimePicker
date={ensuredDate}
label={t("sections-end-date", "End date")}
onSubmitDate={onChange}
/>
</>
)
}}
/>
</div>
</Accordion.Item>
<Accordion.Item
headingSize="medium"
forceMountContent
className="border-b-0"
title={t("sections-customer-availabilty", "Customer availabilty")}
subtitle={t(
"sections-specifiy-which-customer-groups-the-price-overrides-should-apply-for",
"Specifiy which customer groups the price overrides should apply for."
)}
value="customer_groups"
customTrigger={
<Switch checked={openItems.indexOf("customer_groups") > -1} />
}
>
<Controller
name="customer_groups"
control={control}
render={({ field: { value, onChange, ref } }) => {
return (
<div
className={clsx(
"gap-xsmall accordion-margin-transition flex w-full items-center",
{
"mt-4": openItems.indexOf("customer_groups") > -1,
}
)}
>
<NextSelect
options={
customer_groups?.map((cg) => ({
label: cg.name,
value: cg.id,
})) || []
}
label={t(
"sections-customer-groups-label",
"Customer Groups"
)}
onChange={onChange}
isMulti={true}
selectAll={true}
isSearchable={true}
value={value}
ref={ref}
isLoading={isLoading}
/>
</div>
)
}}
/>
</Accordion.Item>
</div>
</Accordion>
</Accordion.Item>
)
}
export default Configuration
@@ -1,67 +0,0 @@
import { Controller } from "react-hook-form"
import { useTranslation } from "react-i18next"
import Switch from "../../../../components/atoms/switch"
import FeatureToggle from "../../../../components/fundamentals/feature-toggle"
import InputField from "../../../../components/molecules/input"
import Accordion from "../../../../components/organisms/accordion"
import { usePriceListForm } from "../form/pricing-form-context"
const General = () => {
const { t } = useTranslation()
const { control, register } = usePriceListForm()
return (
<Accordion.Item
forceMountContent
required
title={t("sections-general", "General")}
tooltip={t(
"sections-general-information-for-the-price-list",
"General information for the price list."
)}
value="general"
>
<div className="gap-y-small group-radix-state-open:mt-5 accordion-margin-transition flex flex-col">
<InputField
label={t("sections-name", "Name")}
required
placeholder={t("sections-b-2-b-black-friday", "B2B, Black Friday...")}
{...register("name", { required: "Name is required" })}
/>
<InputField
label={t("sections-description", "Description")}
required
placeholder={t(
"sections-for-our-business-partners",
"For our business partners..."
)}
{...register("description", { required: "Description is required" })}
/>
<FeatureToggle featureFlag="tax_inclusive_pricing">
<div className="mt-3">
<div className="flex justify-between">
<h2 className="inter-base-semibold">
{t("sections-tax-inclusive-prices", "Tax inclusive prices")}
</h2>
<Controller
control={control}
name="includes_tax"
render={({ field: { value, onChange } }) => {
return <Switch checked={!!value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
{t(
"sections-choose-to-make-all-prices-in-this-list-inclusive-of-tax",
"Choose to make all prices in this list inclusive of tax."
)}
</p>
</div>
</FeatureToggle>
</div>
</Accordion.Item>
)
}
export default General
@@ -1,57 +0,0 @@
import { Product } from "@medusajs/medusa"
import { useAdminPriceListProducts } from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import Accordion from "../../../../components/organisms/accordion"
import { merge } from "../../details/sections/prices-details/utils"
import ProductPrices from "./product-prices"
type PricesSectionProps = {
isEdit?: boolean
id?: string
}
const defaultQueryFilters = {
limit: 50,
offset: 0,
}
const PricesSection = ({ isEdit = false, id }: PricesSectionProps) => {
const { t } = useTranslation()
const {
products = [],
isInitialLoading,
isLoading,
} = useAdminPriceListProducts(id!, defaultQueryFilters, {
enabled: isEdit,
})
const [selectedProducts, setSelectedProducts] = React.useState<Product[]>([])
const mergedProducts = merge(products, selectedProducts)
return (
<Accordion.Item
forceMountContent
required
value="prices"
title={t("sections-prices", "Prices")}
description={t(
"sections-you-will-be-able-to-override-the-prices-for-the-products-you-add-here",
"You will be able to override the prices for the products you add here"
)}
tooltip={t(
"sections-define-the-price-overrides-for-the-price-list",
"Define the price overrides for the price list"
)}
>
<ProductPrices
products={mergedProducts}
isLoading={isEdit ? isLoading : isInitialLoading}
setProducts={setSelectedProducts}
onFileChosen={console.log}
/>
</Accordion.Item>
)
}
export default PricesSection
@@ -1,204 +0,0 @@
import { MoneyAmount, Product, ProductVariant } from "@medusajs/medusa"
import { useAdminStore } from "medusa-react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import LoadingContainer from "../../../../components/atoms/loading-container"
import Button from "../../../../components/fundamentals/button"
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
import PlusIcon from "../../../../components/fundamentals/icons/plus-icon"
import SearchIcon from "../../../../components/fundamentals/icons/search-icon"
import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import { ActionType } from "../../../../components/molecules/actionables"
import InputField from "../../../../components/molecules/input"
import Modal from "../../../../components/molecules/modal"
import ProductVariantTree from "../../../../components/organisms/product-variant-tree"
import AddProductsModal from "../../../../components/templates/add-products-modal"
import PriceOverrides from "../../../../components/templates/price-overrides"
import { mergeExistingWithDefault } from "../../details/utils"
import { usePriceListForm } from "../form/pricing-form-context"
import { CreatePriceListPricesFormValues } from "../types"
export type ProductPricesProps = {
products: Product[]
setProducts: (products: Product[]) => void
getVariantActions?: (
product: Product,
setProduct: (product: Product) => void
) => ActionType[] | undefined
getProductActions?: (product: Product) => ActionType[] | undefined
isLoading?: boolean
onSearch?: (query: string) => void
onFileChosen?: (files: any[]) => void
}
const ProductPrices = ({
products,
setProducts,
isLoading = false,
onSearch,
onFileChosen,
}: ProductPricesProps) => {
const { t } = useTranslation()
const [showAdd, setShowAdd] = React.useState(false)
const [selectedVariant, setSelectedVariant] =
React.useState<ProductVariant | null>(null)
const unselect = () => setSelectedVariant(null)
const { prices, setPrices } = usePriceListForm()
const { store } = useAdminStore()
const onChange = (e) => {
const query = e.target.value
if (onSearch) {
onSearch(query)
}
}
const defaultPrices = store?.currencies.map((curr) => ({
currency_code: curr.code,
amount: 0,
})) as MoneyAmount[]
const getVariantActions = (variant) => {
return [
{
label: t("sections-edit-prices-label", "Edit prices"),
icon: <EditIcon />,
onClick: () => {
setSelectedVariant(variant)
},
},
{
label: t("sections-remove-from-list", "Remove from list"),
icon: <TrashIcon size={20} />,
onClick: () => {
// missing core support
},
variant: "danger" as const,
},
]
}
const handleSubmit = (values) => {
values.variants.forEach((variantId: string) => {
const prices: CreatePriceListPricesFormValues[number] = values.prices
.filter((pr) => pr.amount > 0)
.map((pr) => ({
amount: pr.amount,
currency_code: pr.currency_code,
}))
setPrices((state) => ({
...state,
[variantId]: prices,
}))
unselect()
})
}
const selectedProduct = findProduct(products, selectedVariant)
return (
<div className="mt-6">
<div>
{onSearch && (
<div className="mb-2">
<InputField
placeholder={t(
"sections-search-by-name-or-sku",
"Search by name or SKU..."
)}
prefix={<SearchIcon />}
onChange={onChange}
/>
</div>
)}
<div>
<LoadingContainer isLoading={isLoading}>
{products.map((product) => (
<div className="mt-2">
<ProductVariantTree
product={product}
key={product.id}
getVariantActions={getVariantActions}
/>
</div>
))}
</LoadingContainer>
</div>
</div>
<div className="mt-6">
<Button
variant="secondary"
size="medium"
className="rounded-rounded w-full"
onClick={() => setShowAdd(true)}
>
<PlusIcon />
Add Products Manually
</Button>
</div>
{/* {onFileChosen && (
<div className="mt-3">
<FileUploadField
className="py-8"
onFileChosen={onFileChosen}
filetypes={[".csx", ".xlsx", ".xls"]}
placeholder="Only .csv files up to 5MB are supported"
text={
<span>
Drop your price list file here, or{" "}
<span className="text-violet-60">click to browse</span>
</span>
}
/>
</div>
)} */}
{showAdd && (
<AddProductsModal
onSave={setProducts}
initialSelection={products}
close={() => setShowAdd(false)}
/>
)}
{selectedVariant && (
<Modal open handleClose={unselect}>
<Modal.Body>
<Modal.Header handleClose={unselect}>
<h2 className="inter-xlarge-semibold">
{t("sections-edit-prices", "Edit Prices")}
</h2>
</Modal.Header>
<PriceOverrides
onClose={unselect}
variants={selectedProduct.variants}
prices={
prices
? mergeExistingWithDefault(
prices[selectedVariant.id],
defaultPrices
)
: defaultPrices
}
defaultVariant={selectedVariant}
onSubmit={handleSubmit}
/>
</Modal.Body>
</Modal>
)}
</div>
)
}
const findProduct = (
products: Product[] = [],
variant: ProductVariant | null
): Product => {
return products.find((product) =>
product.variants.find((v) => v.id === variant?.id)
)!
}
export default ProductPrices
@@ -1,64 +0,0 @@
import { Controller } from "react-hook-form"
import { useTranslation } from "react-i18next"
import Accordion from "../../../../components/organisms/accordion"
import RadioGroup from "../../../../components/organisms/radio-group"
import { usePriceListForm } from "../form/pricing-form-context"
import { PriceListType } from "../types"
const Type = () => {
const { t } = useTranslation()
const { control } = usePriceListForm()
return (
<Accordion.Item
forceMountContent
required
value="type"
title={t("sections-price-list-type", "Price list type")}
description={t(
"sections-select-the-type-of-the-price-list",
"Select the type of the price list"
)}
tooltip={t(
"sections-sale-prices-compare-to-price-override",
"Unlike with sale prices a price override will not communicate to the customer that the price is part of a sale."
)}
>
<Controller
name="type"
control={control}
rules={{ required: true }}
render={({ field: { onChange, value } }) => {
return (
<RadioGroup.Root
value={value ?? undefined}
onValueChange={onChange}
className="gap-base group-radix-state-open:mt-5 accordion-margin-transition flex items-center"
>
<RadioGroup.Item
value={PriceListType.SALE}
className="flex-1"
label={t("sections-sale", "Sale")}
description={t(
"sections-use-this-if-you-are-creating-prices-for-a-sale",
"Use this if you are creating prices for a sale."
)}
/>
<RadioGroup.Item
value={PriceListType.OVERRIDE}
className="flex-1"
label={t("sections-override", "Override")}
description={t(
"sections-use-this-to-override-prices",
"Use this to override prices."
)}
/>
</RadioGroup.Root>
)
}}
/>
</Accordion.Item>
)
}
export default Type
@@ -1,104 +0,0 @@
/* eslint-disable no-unused-vars */
import { BaseSyntheticEvent } from "react"
import { Option } from "../../../../types/shared"
interface BasePriceProps {
id?: string
amount: number
variant_id: string
min_quantity?: number
max_quantity?: number
}
interface RegionPriceProps extends BasePriceProps {
region_id: string
currency_code?: never
}
interface CurrencyPriceProps extends BasePriceProps {
currency_code: string
region_id?: never
}
/**
* @description Can have either a region_id or a currency_code, but not both.
*/
type PriceProps = RegionPriceProps | CurrencyPriceProps
export type PriceListFormValues = {
name: string | null
description: string | null
starts_at: Date | null
ends_at: Date | null
customer_groups: Option[] | null
type: PriceListType | null
prices: PriceProps[] | null
includes_tax: boolean
}
export type CreatePriceListPricesFormValues = {
[k: string]: RegionPriceProps[] | CurrencyPriceProps[]
}
export type CreatePriceListFormValues = {
name: string | null
description: string | null
starts_at: Date | null
ends_at: Date | null
customer_groups: Option[] | null
type: PriceListType | null
prices: CreatePriceListPricesFormValues | null
includes_tax: boolean
}
export type ConfigurationField = keyof Pick<
PriceListFormValues,
"starts_at" | "ends_at" | "customer_groups"
>
export type ConfigurationFields = Pick<
PriceListFormValues,
"starts_at" | "ends_at" | "customer_groups"
>
export enum PriceListStatus {
ACTIVE = "active",
DRAFT = "draft",
}
export enum PriceListType {
SALE = "sale",
OVERRIDE = "override",
}
export enum ViewType {
CREATE = "create",
EDIT_DETAILS = "edit_details",
EDIT_PRICES = "edit_prices",
}
interface BasePriceListFormProps {
viewType: ViewType
}
interface CreatePriceListFormProps extends BasePriceListFormProps {
viewType: ViewType.CREATE
id?: never
}
interface EditPriceListFormProps extends BasePriceListFormProps {
viewType: ViewType.EDIT_DETAILS | ViewType.EDIT_PRICES
id: string
}
export type PriceListFormProps =
| CreatePriceListFormProps
| EditPriceListFormProps
export type HeaderAction = {
label: string
onClick:
| (() => void)
| ((e?: BaseSyntheticEvent<object, any, any> | undefined) => Promise<void>)
}
@@ -1,67 +0,0 @@
import { useAdminPriceLists } from "medusa-react"
import useSetSearchParams from "../../hooks/use-set-search-params"
import { usePriceListTableColumns } from "../../components/templates/price-list-table/use-price-list-columns"
import { usePriceListFilters } from "../../components/templates/price-list-table/use-price-list-filters"
import { PriceListTable } from "../../components/templates/price-list-table/price-list-table"
import PriceListsFilter from "../../components/templates/price-list-table/price-list-filters"
import { useLocation } from "react-router-dom"
/**
* Default filtering config for querying price lists endpoint.
*/
const DEFAULT_PAGE_SIZE = 15
const defaultQueryProps = {
expand: "customer_groups",
offset: 0,
limit: DEFAULT_PAGE_SIZE,
}
const PricingTable = () => {
const location = useLocation()
const params = usePriceListFilters(location.search, defaultQueryProps)
const [columns] = usePriceListTableColumns()
const {
price_lists,
isLoading,
count = 0,
} = useAdminPriceLists(params.queryObject, {
keepPreviousData: true,
})
useSetSearchParams(params.representationObject)
const resetFilters = () => {
params.setQuery("")
params.reset()
}
return (
<div>
<PriceListTable
isLoading={isLoading}
columns={columns}
count={count}
priceLists={price_lists || []}
options={{
enableSearch: true,
filter: (
<PriceListsFilter
filters={params.filters}
submitFilters={params.setFilters}
clearFilters={resetFilters}
tabs={params.availableTabs}
onTabClick={params.setTab}
activeTab={params.activeFilterTab}
onRemoveTab={params.removeTab}
onSaveTab={params.saveTab}
/>
),
}}
{...params}
/>
</div>
)
}
export default PricingTable
@@ -0,0 +1,66 @@
import * as React from "react"
/**
* Base interface for a command that can be managed
* by the `useCommandHistory` hook.
*/
interface Command {
execute: () => void
undo: () => void
redo: () => void
}
const useCommandHistory = (maxHistory = 20) => {
const [past, setPast] = React.useState<Command[]>([])
const [future, setFuture] = React.useState<Command[]>([])
const canUndo = past.length > 0
const canRedo = future.length > 0
const undo = React.useCallback(() => {
if (!canUndo) {
return
}
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
previous.undo()
setPast(newPast)
setFuture([previous, ...future.slice(0, maxHistory - 1)])
}, [canUndo, future, past, maxHistory])
const redo = React.useCallback(() => {
if (!canRedo) {
return
}
const next = future[0]
const newFuture = future.slice(1)
next.redo()
setPast([...past, next].slice(0, maxHistory - 1))
setFuture(newFuture)
}, [canRedo, future, past, maxHistory])
const execute = React.useCallback(
(command: Command) => {
command.execute()
setPast((past) => [...past, command].slice(0, maxHistory - 1))
setFuture([])
},
[maxHistory]
)
return {
undo,
redo,
execute,
canUndo,
canRedo,
}
}
export { useCommandHistory, type Command }
@@ -0,0 +1,33 @@
import * as React from "react"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useDebounce } from "./use-debounce"
/**
* Hook for managing a debounced search param
*/
export const useDebouncedSearchParam = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [query, setQuery] = React.useState<string>(searchParams.get("q") || "")
const debouncedQuery = useDebounce(query, 500)
React.useEffect(() => {
const current = new URLSearchParams(searchParams)
if (!debouncedQuery) {
current.delete("q")
} else {
current.set("q", debouncedQuery)
}
navigate({ search: current.toString() }, { replace: true })
}, [debouncedQuery, searchParams, navigate])
return {
query,
setQuery,
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import Inventory from "../domain/inventory"
import Oauth from "../domain/oauth"
import Orders from "../domain/orders"
import DraftOrders from "../domain/orders/draft-orders"
import Pricing from "../domain/pricing"
import PriceListRoute from "../domain/pricing"
import ProductCategories from "../domain/product-categories"
import ProductsRoute from "../domain/products"
import PublishableApiKeys from "../domain/publishable-api-keys"
@@ -55,7 +55,7 @@ const DashboardRoutes = () => {
<Route path="draft-orders/*" element={<DraftOrders />} />
<Route path="discounts/*" element={<Discounts />} />
<Route path="customers/*" element={<Customers />} />
<Route path="pricing/*" element={<Pricing />} />
<Route path="pricing/*" element={<PriceListRoute />} />
<Route path="settings/*" element={<Settings />} />
<Route path="sales-channels/*" element={<SalesChannels />} />
<Route
@@ -0,0 +1,86 @@
import { DateComparisonOperator } from "@medusajs/types"
/**
* Takes a key and a URLSearchParams and returns the parsed DateComparisonOperator if found
* @param key - The key to search for
* @param search - The URLSearchParams to search in
* @returns - The parsed DateComparisonOperator or undefined if not found
*/
export const getDateComparisonOperatorFromSearchParams = (
key: string,
search: URLSearchParams
): DateComparisonOperator | undefined => {
const value = search.get(key)
if (!value) {
return undefined
}
const parsed = JSON.parse(value)
const acceptedKeys = ["lt", "gt", "gte", "lte"]
const filtered = Object.keys(parsed).reduce((acc, key) => {
if (acceptedKeys.includes(key)) {
acc[key as keyof DateComparisonOperator] = parsed[key]
}
return acc
}, {} as DateComparisonOperator)
if (Object.keys(filtered).length === 0) {
return undefined
}
const parsedDates = Object.keys(filtered).reduce((acc, key) => {
if (filtered[key as keyof DateComparisonOperator]) {
const stringValue = filtered[
key as keyof DateComparisonOperator
] as unknown as string
acc[key as keyof DateComparisonOperator] = new Date(stringValue)
}
return acc
}, {} as DateComparisonOperator)
return parsedDates
}
/**
* Takes a key and a URLSearchParams and returns the parsed string array if found
* @param key - The key to search for
* @param search - The URLSearchParams to search in
* @returns - The parsed string array or undefined if not found
*/
export const getStringArrayFromSearchParams = (
key: string,
search: URLSearchParams
): string[] | undefined => {
const value = search.get(key)
if (!value) {
return undefined
}
return value.split(",").filter((s) => !!s)
}
/**
* Takes a key and a URLSearchParams and returns the parsed string if found
* @param key - The key to search for
* @param search - The URLSearchParams to search in
* @returns - The parsed string or undefined if not found
*/
export const getStringFromSearchParams = (
key: string,
search: URLSearchParams
): string | undefined => {
const value = search.get(key)
if (!value) {
return undefined
}
return value
}