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:
86 files changed
+9910
-2667
No files matched your search
@@ -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"
|
||||
+52
@@ -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
|
||||
-146
@@ -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
|
||||
-21
@@ -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"]
|
||||
}
|
||||
-54
@@ -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
|
||||
-103
@@ -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
|
||||
-153
@@ -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
|
||||
-64
@@ -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"
|
||||
+750
@@ -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"
|
||||
+218
@@ -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>
|
||||
+70
@@ -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 }
|
||||
+566
@@ -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"
|
||||
+187
@@ -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 }
|
||||
+2132
File diff suppressed because it is too large.
Load diff
+27
@@ -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"
|
||||
+483
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user