From a0fca165701a85f0f2b71efa590ee42f0cc2fd69 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Wed, 27 Aug 2025 10:14:17 +0200 Subject: [PATCH] feat: Add Draft Order plugin (#13291) * feat: Add draft order plugin * version draft order plugin * update readme * chore: Update scripts * Create purple-dolls-cheer.md * port over latest changes * chore: Make package public --- .changeset/purple-dolls-cheer.md | 5 + package.json | 1 + packages/plugins/draft-order/README.md | 86 + packages/plugins/draft-order/package.json | 101 + .../admin/components/common/action-menu.tsx | 123 ++ .../admin/components/common/address-card.tsx | 84 + .../components/common/conditional-tooltip.tsx | 20 + .../admin/components/common/customer-card.tsx | 80 + .../admin/components/common/data-table.tsx | 391 ++++ .../src/admin/components/common/form.tsx | 238 +++ .../admin/components/common/inline-tip.tsx | 57 + .../admin/components/common/keybound-form.tsx | 42 + .../admin/components/common/page-skeleton.tsx | 66 + .../src/admin/components/common/thumbnail.tsx | 22 + .../draft-orders/active-order-changes.tsx | 85 + .../draft-orders/activity-section.tsx | 433 ++++ .../draft-orders/customer-section.tsx | 211 ++ .../draft-orders/general-section.tsx | 91 + .../draft-orders/json-view-section.tsx | 193 ++ .../draft-orders/metadata-section.tsx | 31 + .../draft-orders/shipping-section.tsx | 400 ++++ .../draft-orders/summary-section.tsx | 288 +++ .../src/admin/components/inputs/combobox.tsx | 398 ++++ .../components/inputs/country-select.tsx | 76 + .../admin/components/inputs/number-input.tsx | 126 ++ .../admin/components/inputs/switch-block.tsx | 39 + .../src/admin/components/modals/index.ts | 7 + .../components/modals/route-drawer/index.ts | 1 + .../modals/route-drawer/route-drawer.tsx | 88 + .../modals/route-focus-modal/index.ts | 1 + .../route-focus-modal/route-focus-modal.tsx | 110 + .../modals/route-modal-form/index.ts | 1 + .../route-modal-form/route-modal-form.tsx | 85 + .../modals/route-modal-provider/index.ts | 2 + .../route-modal-context.tsx | 12 + .../route-modal-provider/route-provider.tsx | 39 + .../route-modal-provider/use-route-modal.tsx | 12 + .../components/modals/stacked-drawer/index.ts | 1 + .../modals/stacked-drawer/stacked-drawer.tsx | 85 + .../modals/stacked-focus-modal/index.ts | 1 + .../stacked-focus-modal.tsx | 98 + .../modals/stacked-modal-provider/index.ts | 2 + .../stacked-modal-context.tsx | 10 + .../stacked-modal-provider.tsx | 54 + .../use-stacked-modal.ts | 14 + .../utilities/generic-forward-ref.tsx | 13 + .../src/admin/hooks/api/customers.tsx | 139 ++ .../src/admin/hooks/api/draft-orders.tsx | 581 ++++++ .../src/admin/hooks/api/orders.tsx | 457 +++++ .../src/admin/hooks/api/product-variants.tsx | 35 + .../src/admin/hooks/api/products.tsx | 16 + .../src/admin/hooks/api/promotions.tsx | 39 + .../src/admin/hooks/api/regions.tsx | 65 + .../src/admin/hooks/api/sales-channels.tsx | 61 + .../src/admin/hooks/api/shipping-options.tsx | 61 + .../draft-order/src/admin/hooks/api/users.tsx | 41 + .../admin/hooks/common/use-combobox-data.tsx | 98 + .../common/use-data-table-date-filters.tsx | 90 + .../hooks/common/use-debounced-search.tsx | 29 + .../admin/hooks/common/use-query-params.tsx | 24 + .../order-edits/use-cancel-order-edit.tsx | 33 + .../order-edits/use-initiate-order-edit.tsx | 47 + .../src/admin/lib/data/countries.ts | 1767 +++++++++++++++++ .../src/admin/lib/data/currencies.ts | 40 + .../admin/lib/queries/draft-order-details.ts | 7 + .../draft-order/src/admin/lib/queries/sdk.ts | 8 + .../src/admin/lib/schemas/address.ts | 14 + .../src/admin/lib/utils/address-utils.ts | 88 + .../src/admin/lib/utils/date-utils.ts | 33 + .../src/admin/lib/utils/number-utils.ts | 3 + .../src/admin/lib/utils/order-utils.ts | 40 + .../src/admin/lib/utils/string-utils.ts | 3 + .../routes/draft-orders/@create/page.tsx | 780 ++++++++ .../[id]/@billing-address/page.tsx | 246 +++ .../draft-orders/[id]/@custom-items/page.tsx | 51 + .../routes/draft-orders/[id]/@email/page.tsx | 111 ++ .../routes/draft-orders/[id]/@items/page.tsx | 1162 +++++++++++ .../draft-orders/[id]/@metadata/page.tsx | 420 ++++ .../draft-orders/[id]/@promotions/page.tsx | 369 ++++ .../draft-orders/[id]/@sales-channel/page.tsx | 164 ++ .../[id]/@shipping-address/page.tsx | 259 +++ .../draft-orders/[id]/@shipping/page.tsx | 1044 ++++++++++ .../[id]/@transfer-ownership/page.tsx | 480 +++++ .../admin/routes/draft-orders/[id]/page.tsx | 109 + .../src/admin/routes/draft-orders/page.tsx | 245 +++ .../draft-order/src/admin/tsconfig.json | 24 + .../src/types/http/draft-orders/payloads.ts | 26 + .../src/types/http/draft-orders/responses.ts | 5 + .../src/types/http/orders/entity.ts | 5 + .../src/types/http/orders/requests.ts | 13 + .../plugins/draft-order/tailwind.config.ts | 9 + packages/plugins/draft-order/tsconfig.json | 37 + yarn.lock | 829 +++++++- 93 files changed, 14526 insertions(+), 4 deletions(-) create mode 100644 .changeset/purple-dolls-cheer.md create mode 100644 packages/plugins/draft-order/README.md create mode 100644 packages/plugins/draft-order/package.json create mode 100644 packages/plugins/draft-order/src/admin/components/common/action-menu.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/address-card.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/conditional-tooltip.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/customer-card.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/data-table.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/form.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/inline-tip.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/keybound-form.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/page-skeleton.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/common/thumbnail.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/active-order-changes.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/activity-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/customer-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/general-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/json-view-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/metadata-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/shipping-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/draft-orders/summary-section.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/inputs/combobox.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/inputs/country-select.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/inputs/number-input.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/inputs/switch-block.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-drawer/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-drawer/route-drawer.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-focus-modal/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-focus-modal/route-focus-modal.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-form/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-form/route-modal-form.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-provider/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-provider/route-modal-context.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-provider/route-provider.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/route-modal-provider/use-route-modal.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-drawer/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-drawer/stacked-drawer.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-focus-modal/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-focus-modal/stacked-focus-modal.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-modal-provider/index.ts create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-modal-provider/stacked-modal-context.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-modal-provider/stacked-modal-provider.tsx create mode 100644 packages/plugins/draft-order/src/admin/components/modals/stacked-modal-provider/use-stacked-modal.ts create mode 100644 packages/plugins/draft-order/src/admin/components/utilities/generic-forward-ref.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/customers.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/draft-orders.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/orders.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/product-variants.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/products.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/promotions.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/regions.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/sales-channels.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/shipping-options.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/api/users.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/common/use-combobox-data.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/common/use-data-table-date-filters.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/common/use-debounced-search.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/common/use-query-params.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/order-edits/use-cancel-order-edit.tsx create mode 100644 packages/plugins/draft-order/src/admin/hooks/order-edits/use-initiate-order-edit.tsx create mode 100644 packages/plugins/draft-order/src/admin/lib/data/countries.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/data/currencies.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/queries/draft-order-details.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/queries/sdk.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/schemas/address.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/utils/address-utils.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/utils/date-utils.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/utils/number-utils.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/utils/order-utils.ts create mode 100644 packages/plugins/draft-order/src/admin/lib/utils/string-utils.ts create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/@create/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@billing-address/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@custom-items/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@email/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@items/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@metadata/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@promotions/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@sales-channel/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@shipping-address/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@shipping/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/@transfer-ownership/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/[id]/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/routes/draft-orders/page.tsx create mode 100644 packages/plugins/draft-order/src/admin/tsconfig.json create mode 100644 packages/plugins/draft-order/src/types/http/draft-orders/payloads.ts create mode 100644 packages/plugins/draft-order/src/types/http/draft-orders/responses.ts create mode 100644 packages/plugins/draft-order/src/types/http/orders/entity.ts create mode 100644 packages/plugins/draft-order/src/types/http/orders/requests.ts create mode 100644 packages/plugins/draft-order/tailwind.config.ts create mode 100644 packages/plugins/draft-order/tsconfig.json diff --git a/.changeset/purple-dolls-cheer.md b/.changeset/purple-dolls-cheer.md new file mode 100644 index 0000000000..cd66cb86e3 --- /dev/null +++ b/.changeset/purple-dolls-cheer.md @@ -0,0 +1,5 @@ +--- +"@medusajs/draft-order": patch +--- + +feat: Add Draft Order plugin diff --git a/package.json b/package.json index 3212d05d2a..7eacae9a9b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packages/medusa-test-utils", "packages/modules/*", "packages/modules/providers/*", + "packages/plugins/*", "packages/core/*", "packages/framework/*", "packages/cli/*", diff --git a/packages/plugins/draft-order/README.md b/packages/plugins/draft-order/README.md new file mode 100644 index 0000000000..3d2483278e --- /dev/null +++ b/packages/plugins/draft-order/README.md @@ -0,0 +1,86 @@ +

+ + + + + Medusa logo + + +

+

+ Draft Order Plugin +

+ +

+ Documentation | + Website +

+ +

+ Create and manage draft orders on behalf of customers in Medusa +

+

+ + PRs welcome! + + Product Hunt + + Discord Chat + + + Follow @medusajs + +

+ +## Overview + +The Draft Order Plugin enables admin users to create and manage orders on behalf of customers. This is particularly useful for customer support scenarios or when customers place orders offline. + +## Features + +- **Create draft orders** from the Medusa Admin dashboard +- **Manage items** in draft orders (add, update, remove) +- **Add shipping methods** to draft orders +- **Associate customers** with draft orders +- **Convert draft orders** to regular orders for purchase completion + + +## Installation + +1. Install the Draft Order plugin +``` +yarn add @medusajs/draft-order +``` +2. Configure the plugin in your medusa-config.ts +``` +module.exports = defineConfig({ + projectConfig: { + ... + }, + plugins: [ + { + resolve: "@medusajs/draft-order", + options: {}, + }, + ], +}) +``` +3. Start your server + +## Requirements +- Medusa application version >= 2.4.0 + +## Support + +## Community & Contributions + +The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas. + +Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members. + +## Other channels + +- [GitHub Issues](https://github.com/medusajs/medusa/issues) +- [Twitter](https://twitter.com/medusajs) +- [LinkedIn](https://www.linkedin.com/company/medusajs) +- [Medusa Blog](https://medusajs.com/blog/) diff --git a/packages/plugins/draft-order/package.json b/packages/plugins/draft-order/package.json new file mode 100644 index 0000000000..34d71ba683 --- /dev/null +++ b/packages/plugins/draft-order/package.json @@ -0,0 +1,101 @@ +{ + "name": "@medusajs/draft-order", + "version": "2.9.0", + "description": "A starter for Medusa plugins.", + "author": "Medusa (https://medusajs.com)", + "license": "MIT", + "files": [ + ".medusa/server" + ], + "exports": { + "./package.json": "./package.json", + "./workflows": "./.medusa/server/src/workflows/index.js", + "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js", + "./modules/*": "./.medusa/server/src/modules/*/index.js", + "./providers/*": "./.medusa/server/src/providers/*/index.js", + "./*": "./.medusa/server/src/*.js", + "./admin": { + "import": "./.medusa/server/src/admin/index.mjs", + "require": "./.medusa/server/src/admin/index.js", + "default": "./.medusa/server/src/admin/index.js" + } + }, + "keywords": [ + "medusa", + "plugin", + "medusa-plugin-other", + "medusa-plugin", + "medusa-v2" + ], + "scripts": { + "build": "medusa plugin:build", + "dev": "medusa plugin:develop", + "prepare": "cross-env NODE_ENV=production yarn run build", + "link:watch": "medusa plugin:publish && medusa plugin:develop" + }, + "dependencies": { + "@ariakit/react": "^0.4.15", + "@hookform/resolvers": "3.4.2", + "@medusajs/js-sdk": "2.9.0", + "@tanstack/react-query": "5.64.2", + "@uiw/react-json-view": "^2.0.0-alpha.17", + "date-fns": "^3.6.0", + "match-sorter": "^6.3.4", + "radix-ui": "1.1.2", + "react-hook-form": "7.49.1" + }, + "devDependencies": { + "@medusajs/admin-sdk": "2.9.0", + "@medusajs/cli": "2.9.0", + "@medusajs/framework": "2.9.0", + "@medusajs/icons": "2.9.0", + "@medusajs/medusa": "2.9.0", + "@medusajs/test-utils": "2.9.0", + "@medusajs/types": "2.9.0", + "@medusajs/ui": "4.0.19", + "@medusajs/ui-preset": "2.9.0", + "@mikro-orm/cli": "6.4.3", + "@mikro-orm/core": "6.4.3", + "@mikro-orm/knex": "6.4.3", + "@mikro-orm/migrations": "6.4.3", + "@mikro-orm/postgresql": "6.4.3", + "@swc/core": "1.5.7", + "@types/lodash": "^4.17.15", + "@types/node": "^20.0.0", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.2.25", + "awilix": "^8.0.1", + "lodash": "^4.17.21", + "pg": "^8.13.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "6.20.1", + "ts-node": "^10.9.2", + "tsup": "^8.4.0", + "typescript": "^5.6.2", + "vite": "^5.2.11", + "yalc": "^1.0.0-pre.53" + }, + "peerDependencies": { + "@medusajs/admin-sdk": "2.9.0", + "@medusajs/cli": "2.9.0", + "@medusajs/framework": "2.9.0", + "@medusajs/icons": "2.9.0", + "@medusajs/medusa": "2.9.0", + "@medusajs/test-utils": "2.9.0", + "@medusajs/ui": "4.0.19", + "@mikro-orm/cli": "6.4.3", + "@mikro-orm/core": "6.4.3", + "@mikro-orm/knex": "6.4.3", + "@mikro-orm/migrations": "6.4.3", + "@mikro-orm/postgresql": "6.4.3", + "awilix": "^8.0.1", + "lodash": "^4.17.21", + "pg": "^8.13.0", + "react-router-dom": "6.20.1" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "yarn@3.2.1" +} diff --git a/packages/plugins/draft-order/src/admin/components/common/action-menu.tsx b/packages/plugins/draft-order/src/admin/components/common/action-menu.tsx new file mode 100644 index 0000000000..549866f783 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/action-menu.tsx @@ -0,0 +1,123 @@ +import { DropdownMenu, IconButton, clx } from "@medusajs/ui" + +import { EllipsisHorizontal } from "@medusajs/icons" +import { PropsWithChildren, ReactNode } from "react" +import { Link } from "react-router-dom" +import { ConditionalTooltip } from "./conditional-tooltip" + +export type Action = { + icon: ReactNode + label: string + disabled?: boolean + /** + * Optional tooltip to display when a disabled action is hovered. + */ + disabledTooltip?: string | ReactNode +} & ( + | { + to: string + onClick?: never + } + | { + onClick: () => void + to?: never + } +) + +export type ActionGroup = { + actions: Action[] +} + +type ActionMenuProps = PropsWithChildren<{ + groups: ActionGroup[] + variant?: "transparent" | "primary" +}> + +export const ActionMenu = ({ + groups, + variant = "transparent", + children, +}: ActionMenuProps) => { + const inner = children ?? ( + + + + ) + + return ( + + {inner} + + {groups.map((group, index) => { + if (!group.actions.length) { + return null + } + + const isLast = index === groups.length - 1 + + return ( + + {group.actions.map((action, index) => { + const Wrapper = action.disabledTooltip + ? ({ children }: { children: ReactNode }) => ( + +
{children}
+
+ ) + : "div" + + if (action.onClick) { + return ( + + { + e.stopPropagation() + action.onClick() + }} + className={clx( + "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2", + { + "[&_svg]:text-ui-fg-disabled": action.disabled, + } + )} + > + {action.icon} + {action.label} + + + ) + } + + return ( + + + e.stopPropagation()}> + {action.icon} + {action.label} + + + + ) + })} + {!isLast && } +
+ ) + })} +
+
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/common/address-card.tsx b/packages/plugins/draft-order/src/admin/components/common/address-card.tsx new file mode 100644 index 0000000000..fe787260f4 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/address-card.tsx @@ -0,0 +1,84 @@ +import { XMark } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Badge, IconButton, Skeleton, Text } from "@medusajs/ui" +import { useCustomerAddress } from "../../hooks/api/customers" +import { getFormattedAddress } from "../../lib/utils/address-utils" + +interface AddressCardProps { + customerId: string + addressId: string + tag?: "shipping" | "billing" + onRemove?: () => void | Promise +} + +export const AddressCard = ({ + customerId, + addressId, + tag = "shipping", + onRemove, +}: AddressCardProps) => { + const { address, isPending, isError, error } = useCustomerAddress( + customerId, + addressId + ) + + if (isError) { + throw error + } + + const isReady = !isPending && !!address + + return ( +
+ {!isReady ? : } + + {tag === "shipping" ? "Shipping" : "Billing"} + + {onRemove && ( + + + + )} +
+ ) +} + +const LoadingState = () => { + return ( +
+ + + +
+ ) +} + +interface AddressInfoProps { + address: HttpTypes.AdminCustomerAddress +} + +const AddressInfo = ({ address }: AddressInfoProps) => { + const addressSegments = getFormattedAddress(address) + + return ( +
+ {address.address_name && ( + + {address.address_name} + + )} + {addressSegments.map((segment, idx) => ( + + {segment} + {idx < addressSegments.length - 1 && ", "} + + ))} +
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/common/conditional-tooltip.tsx b/packages/plugins/draft-order/src/admin/components/common/conditional-tooltip.tsx new file mode 100644 index 0000000000..829da904d6 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/conditional-tooltip.tsx @@ -0,0 +1,20 @@ +import { Tooltip } from "@medusajs/ui" +import { ComponentPropsWithoutRef, PropsWithChildren } from "react" + +type ConditionalTooltipProps = PropsWithChildren< + ComponentPropsWithoutRef & { + showTooltip?: boolean + } +> + +export const ConditionalTooltip = ({ + children, + showTooltip = false, + ...props +}: ConditionalTooltipProps) => { + if (showTooltip) { + return {children} + } + + return children +} diff --git a/packages/plugins/draft-order/src/admin/components/common/customer-card.tsx b/packages/plugins/draft-order/src/admin/components/common/customer-card.tsx new file mode 100644 index 0000000000..52cf74dd1b --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/customer-card.tsx @@ -0,0 +1,80 @@ +import { XMark } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Avatar, IconButton, Skeleton, Text } from "@medusajs/ui" +import { useCustomer } from "../../hooks/api/customers" + +interface CustomerCardProps { + customerId: string + onRemove?: () => void | Promise +} + +export const CustomerCard = ({ customerId, onRemove }: CustomerCardProps) => { + const { customer, isPending, isError, error } = useCustomer(customerId) + + if (isError) { + throw error + } + + const isReady = !isPending && !!customer + + return ( +
+ {!isReady ? : } + {onRemove && ( + + + + )} +
+ ) +} + +const LoadingState = () => { + return ( +
+ +
+ + +
+
+ ) +} + +const CustomerInfo = ({ customer }: { customer: HttpTypes.AdminCustomer }) => { + const name = [customer.first_name, customer.last_name] + .filter(Boolean) + .join(" ") + const fallback = name ? name[0] : customer.email[0] + + return ( +
+ +
+ {name && ( + + {name} + + )} + + {customer.email} + +
+
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/common/data-table.tsx b/packages/plugins/draft-order/src/admin/components/common/data-table.tsx new file mode 100644 index 0000000000..a31949ff90 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/data-table.tsx @@ -0,0 +1,391 @@ +import { + Button, + clx, + DataTableColumnDef, + DataTableCommand, + DataTableEmptyStateProps, + DataTableFilter, + DataTableFilteringState, + DataTablePaginationState, + DataTableRow, + DataTableRowSelectionState, + DataTableSortingState, + Heading, + DataTable as Primitive, + Text, + useDataTable, +} from "@medusajs/ui" +import React, { ReactNode, useCallback, useState } from "react" +import { Link, useNavigate, useSearchParams } from "react-router-dom" + +import { useQueryParams } from "../../hooks/common/use-query-params" +import { ActionMenu } from "./action-menu" + +type DataTableActionProps = { + label: string + disabled?: boolean +} & ( + | { + to: string + } + | { + onClick: () => void + } +) + +type DataTableActionMenuActionProps = { + label: string + icon: ReactNode + disabled?: boolean +} & ( + | { + to: string + } + | { + onClick: () => void + } +) + +type DataTableActionMenuGroupProps = { + actions: DataTableActionMenuActionProps[] +} + +type DataTableActionMenuProps = { + groups: DataTableActionMenuGroupProps[] +} + +interface DataTableProps { + data?: TData[] + columns: DataTableColumnDef[] + filters?: DataTableFilter[] + commands?: DataTableCommand[] + action?: DataTableActionProps + actionMenu?: DataTableActionMenuProps + rowCount?: number + getRowId: (row: TData) => string + enablePagination?: boolean + enableSearch?: boolean + autoFocusSearch?: boolean + rowHref?: (row: TData) => string + emptyState?: DataTableEmptyStateProps + heading?: string + subHeading?: string + prefix?: string + pageSize?: number + isLoading?: boolean + rowSelection?: { + state: DataTableRowSelectionState + onRowSelectionChange: (value: DataTableRowSelectionState) => void + enableRowSelection?: boolean | ((row: DataTableRow) => boolean) + } + layout?: "fill" | "auto" +} + +export const DataTable = ({ + data = [], + columns, + filters, + commands, + action, + actionMenu, + getRowId, + rowCount = 0, + enablePagination = true, + enableSearch = true, + autoFocusSearch = false, + rowHref, + heading, + subHeading, + prefix, + pageSize = 10, + emptyState, + rowSelection, + isLoading = false, + layout = "auto", +}: DataTableProps) => { + const enableFiltering = filters && filters.length > 0 + const enableCommands = commands && commands.length > 0 + const enableSorting = columns.some((column) => column.enableSorting) + + const filterIds = filters?.map((f) => f.id) ?? [] + const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix)) + + const { offset, order, q, ...filterParams } = useQueryParams( + [ + ...filterIds, + ...(enableSorting ? ["order"] : []), + ...(enableSearch ? ["q"] : []), + ...(enablePagination ? ["offset"] : []), + ], + prefix + ) + const [_, setSearchParams] = useSearchParams() + + const [search, setSearch] = useState(q ?? "") + const handleSearchChange = (value: string) => { + setSearch(value) + setSearchParams((prev) => { + if (value) { + prev.set(getQueryParamKey("q", prefix), value) + } else { + prev.delete(getQueryParamKey("q", prefix)) + } + + return prev + }) + } + + const [pagination, setPagination] = useState( + offset ? parsePaginationState(offset, pageSize) : { pageIndex: 0, pageSize } + ) + const handlePaginationChange = (value: DataTablePaginationState) => { + setPagination(value) + setSearchParams((prev) => { + if (value.pageIndex === 0) { + prev.delete(getQueryParamKey("offset", prefix)) + } else { + prev.set( + getQueryParamKey("offset", prefix), + transformPaginationState(value).toString() + ) + } + + return prev + }) + } + + const [filtering, setFiltering] = useState( + parseFilterState(filterIds, filterParams) + ) + + const handleFilteringChange = (value: DataTableFilteringState) => { + setFiltering(value) + + setSearchParams((prev) => { + Array.from(prev.keys()).forEach((key) => { + if (prefixedFilterIds.includes(key) && !(key in value)) { + prev.delete(key) + } + }) + + Object.entries(value).forEach(([key, filter]) => { + if ( + prefixedFilterIds.includes(getQueryParamKey(key, prefix)) && + filter + ) { + prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter)) + } + }) + + return prev + }) + } + + const [sorting, setSorting] = useState( + order ? parseSortingState(order) : null + ) + const handleSortingChange = (value: DataTableSortingState) => { + setSorting(value) + setSearchParams((prev) => { + if (value) { + const valueToStore = transformSortingState(value) + + prev.set(getQueryParamKey("order", prefix), valueToStore) + } else { + prev.delete(getQueryParamKey("order", prefix)) + } + + return prev + }) + } + + const navigate = useNavigate() + + const onRowClick = useCallback( + (event: React.MouseEvent, row: TData) => { + if (!rowHref) { + return + } + + const href = rowHref(row) + + if (event.metaKey || event.ctrlKey || event.button === 1) { + window.open(href, "_blank", "noreferrer") + return + } + + if (event.shiftKey) { + window.open(href, undefined, "noreferrer") + return + } + + navigate(href) + }, + [navigate, rowHref] + ) + + const instance = useDataTable({ + data, + columns, + filters, + commands, + rowCount, + getRowId, + onRowClick: rowHref ? onRowClick : undefined, + pagination: enablePagination + ? { + state: pagination, + onPaginationChange: handlePaginationChange, + } + : undefined, + filtering: enableFiltering + ? { + state: filtering, + onFilteringChange: handleFilteringChange, + } + : undefined, + sorting: enableSorting + ? { + state: sorting, + onSortingChange: handleSortingChange, + } + : undefined, + search: enableSearch + ? { + state: search, + onSearchChange: handleSearchChange, + } + : undefined, + rowSelection, + isLoading, + }) + + const shouldRenderHeading = heading || subHeading + + return ( + + +
+ {shouldRenderHeading && ( +
+ {heading && {heading}} + {subHeading && ( + + {subHeading} + + )} +
+ )} +
+ {enableFiltering && } + + {actionMenu && } + {action && } +
+
+
+ {enableSearch && ( +
+ +
+ )} +
+ {enableFiltering && } + + {actionMenu && } + {action && } +
+
+
+ + {enablePagination && } + {enableCommands && ( + `${count} selected`} /> + )} +
+ ) +} + +function transformSortingState(value: DataTableSortingState) { + return value.desc ? `-${value.id}` : value.id +} + +function parseSortingState(value: string) { + return value.startsWith("-") + ? { id: value.slice(1), desc: true } + : { id: value, desc: false } +} + +function transformPaginationState(value: DataTablePaginationState) { + return value.pageIndex * value.pageSize +} + +function parsePaginationState(value: string, pageSize: number) { + const offset = parseInt(value) + + return { + pageIndex: Math.floor(offset / pageSize), + pageSize, + } +} + +function parseFilterState( + filterIds: string[], + value: Record +) { + if (!value) { + return {} + } + + const filters: DataTableFilteringState = {} + + for (const id of filterIds) { + const filterValue = value[id] + + if (filterValue) { + filters[id] = JSON.parse(filterValue) + } + } + + return filters +} + +function getQueryParamKey(key: string, prefix?: string) { + return prefix ? `${prefix}_${key}` : key +} + +const DataTableAction = ({ + label, + disabled, + ...props +}: DataTableActionProps) => { + const buttonProps = { + size: "small" as const, + disabled: disabled ?? false, + type: "button" as const, + variant: "secondary" as const, + } + + if ("to" in props) { + return ( + + ) + } + + return ( + + ) +} diff --git a/packages/plugins/draft-order/src/admin/components/common/form.tsx b/packages/plugins/draft-order/src/admin/components/common/form.tsx new file mode 100644 index 0000000000..bd1fc12b51 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/form.tsx @@ -0,0 +1,238 @@ +import { InformationCircleSolid } from "@medusajs/icons" +import { + Hint as HintComponent, + Label as LabelComponent, + Text, + Tooltip, + clx, +} from "@medusajs/ui" +import { Label as RadixLabel, Slot } from "radix-ui" +import React, { + ReactNode, + createContext, + forwardRef, + useContext, + useId, +} 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 = FieldPath +> = { + name: TName +} + +const FormFieldContext = createContext( + {} as FormFieldContextValue +) + +const Field = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = createContext( + {} as FormItemContextValue +) + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext) + const itemContext = 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`, + formLabelId: `${id}-form-item-label`, + formDescriptionId: `${id}-form-item-description`, + formErrorMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +const Item = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId() + + return ( + +
+ + ) + } +) +Item.displayName = "Form.Item" + +const Label = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: "default" | "subtle" + optional?: boolean + tooltip?: ReactNode + icon?: ReactNode + } +>( + ( + { + className, + optional = false, + tooltip, + icon, + variant = "default", + ...props + }, + ref + ) => { + const { formLabelId, formItemId } = useFormField() + + return ( +
+ + {tooltip && ( + + + + )} + {icon} + {optional && ( + + (Optional) + + )} +
+ ) + } +) +Label.displayName = "Form.Label" + +const Control = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + const { + error, + formItemId, + formDescriptionId, + formErrorMessageId, + formLabelId, + } = useFormField() + + return ( + + ) +}) +Control.displayName = "Form.Control" + +const Hint = forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + + ) +}) +Hint.displayName = "Form.Hint" + +const ErrorMessage = forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error, formErrorMessageId } = useFormField() + const msg = error ? String(error?.message) : children + + if (!msg || msg === "undefined") { + return null + } + + return ( + + {msg} + + ) +}) +ErrorMessage.displayName = "Form.ErrorMessage" + +const Form = Object.assign(Provider, { + Item, + Label, + Control, + Hint, + ErrorMessage, + Field, +}) + +export { Form } diff --git a/packages/plugins/draft-order/src/admin/components/common/inline-tip.tsx b/packages/plugins/draft-order/src/admin/components/common/inline-tip.tsx new file mode 100644 index 0000000000..b4e6854ae4 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/inline-tip.tsx @@ -0,0 +1,57 @@ +import { clx } from "@medusajs/ui" +import { ComponentPropsWithoutRef, forwardRef } from "react" + +interface InlineTipProps extends ComponentPropsWithoutRef<"div"> { + /** + * The label to display in the tip. + */ + label?: string + /** + * The variant of the tip. + */ + variant?: "tip" | "warning" +} + +/** + * A component for rendering inline tips. Useful for providing additional information or context. + * + * @example + * ```tsx + * + * This is an info tip. + * + * ``` + * + * TODO: Move to `@medusajs/ui` package. + */ +export const InlineTip = forwardRef( + ({ variant = "tip", label, className, children, ...props }, ref) => { + const labelValue = label || (variant === "warning" ? "Warning" : "Tip") + + return ( +
+
+
+ + {labelValue}: + {" "} + {children} +
+
+ ) + } +) + +InlineTip.displayName = "InlineTip" diff --git a/packages/plugins/draft-order/src/admin/components/common/keybound-form.tsx b/packages/plugins/draft-order/src/admin/components/common/keybound-form.tsx new file mode 100644 index 0000000000..23a4d02495 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/keybound-form.tsx @@ -0,0 +1,42 @@ +import React from "react" + +/** + * A form that can only be submitted when using the meta or control key. + */ +export const KeyboundForm = React.forwardRef< + HTMLFormElement, + React.FormHTMLAttributes +>(({ onSubmit, onKeyDown, ...rest }, ref) => { + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + onSubmit?.(event) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + if ( + event.target instanceof HTMLTextAreaElement && + !(event.metaKey || event.ctrlKey) + ) { + return + } + + event.preventDefault() + + if (event.metaKey || event.ctrlKey) { + handleSubmit(event) + } + } + } + + return ( +
+ ) +}) + +KeyboundForm.displayName = "KeyboundForm" diff --git a/packages/plugins/draft-order/src/admin/components/common/page-skeleton.tsx b/packages/plugins/draft-order/src/admin/components/common/page-skeleton.tsx new file mode 100644 index 0000000000..9bc8187363 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/page-skeleton.tsx @@ -0,0 +1,66 @@ +import { clx, Skeleton } from "@medusajs/ui" + +interface PageSkeletonProps { + mainSections?: number + sidebarSections?: number + showJSON?: boolean + showMetadata?: boolean +} + +export const PageSkeleton = ({ + mainSections = 2, + sidebarSections = 1, + showJSON = false, + showMetadata = true, +}: PageSkeletonProps) => { + const showExtraData = showJSON || showMetadata + + return ( +
+
+
+ {Array.from({ length: mainSections }, (_, i) => i).map((section) => { + return ( + + ) + })} + {showExtraData && ( +
+ {showMetadata && ( + + )} + {showJSON && } +
+ )} +
+
+ {Array.from({ length: sidebarSections }, (_, i) => i).map( + (section) => { + return ( + + ) + } + )} + {showExtraData && ( +
+ {showMetadata && ( + + )} + {showJSON && } +
+ )} +
+
+
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/common/thumbnail.tsx b/packages/plugins/draft-order/src/admin/components/common/thumbnail.tsx new file mode 100644 index 0000000000..ec6895b6de --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/common/thumbnail.tsx @@ -0,0 +1,22 @@ +import { Photo } from "@medusajs/icons" + +interface ThumbnailProps { + thumbnail?: string | null + alt?: string | null +} + +export const Thumbnail = ({ thumbnail, alt = "" }: ThumbnailProps) => { + return ( +
+ {thumbnail ? ( + {alt + ) : ( + + )} +
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/active-order-changes.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/active-order-changes.tsx new file mode 100644 index 0000000000..7a323b759a --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/active-order-changes.tsx @@ -0,0 +1,85 @@ +import { ExclamationCircleSolid } from "@medusajs/icons" +import { Button, Container, Divider, Heading, Text, toast } from "@medusajs/ui" +import { useLocation } from "react-router-dom" +import { useDraftOrderCancelEdit } from "../../hooks/api/draft-orders" +import { useOrderPreview } from "../../hooks/api/orders" + +const DETAILS_PAGE_REGEX = /\/draft-orders\/[a-zA-Z0-9_-]+\/?$/ +interface ActiveOrderChangeProps { + orderId: string +} + +export const ActiveOrderChange = ({ orderId }: ActiveOrderChangeProps) => { + const { order: preview } = useOrderPreview(orderId) + const location = useLocation() + + const isPending = preview?.order_change?.status === "pending" + const isDetailsPage = DETAILS_PAGE_REGEX.test(location.pathname) + + const { mutateAsync, isPending: isMutating } = + useDraftOrderCancelEdit(orderId) + + if (!isPending || !isDetailsPage) { + return null + } + + const onCancel = async () => { + await mutateAsync(undefined, { + onError: (e) => { + toast.error(e.message) + }, + onSuccess: () => { + toast.success("Edit cancelled") + }, + }) + } + + const actions = preview.order_change.actions + const noActions = !actions || actions.length === 0 + + return ( +
+ +
+ + Edit pending +
+ +
+ + {noActions + ? "There is a pending edit on this draft order with no changes. Click below to cancel it, or open one of the menus to start making changes." + : "There is a pending edit on this draft order with changes. Click below to cancel it, or continue to complete the edit."} + +
+ +
+ {!noActions && ( + + )} + +
+
+
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/activity-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/activity-section.tsx new file mode 100644 index 0000000000..2692eb1193 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/activity-section.tsx @@ -0,0 +1,433 @@ +import { HttpTypes } from "@medusajs/types" +import { + Avatar, + clx, + Container, + Heading, + Skeleton, + Text, + Tooltip, +} from "@medusajs/ui" +import { Collapsible } from "radix-ui" +import { ReactNode, useMemo, useState } from "react" +import { Link } from "react-router-dom" +import { useUser } from "../../hooks/api/users" +import { getFullDate, getRelativeDate } from "../../lib/utils/date-utils" + +interface ActivitySectionProps { + order: HttpTypes.AdminOrder + changes: HttpTypes.AdminOrderChange[] +} + +export const ActivitySection = ({ order, changes }: ActivitySectionProps) => { + const activityItems = useMemo( + () => getActivityItems(order, changes), + [order, changes] + ) + + return ( + +
+ Activity +
+ +
+ ) +} + +interface ActivityItemListProps { + items: ActivityItem[] +} + +const ActivityItemList = ({ items }: ActivityItemListProps) => { + if (items.length <= 3) { + return ( +
+ {items.map((item, idx) => ( + + ))} +
+ ) + } + + const lastItems = items.slice(0, 2) + const collapsibleItems = items.slice(2, items.length - 1) + const firstItem = items[items.length - 1] + + return ( +
+ {lastItems.map((item, idx) => ( + + ))} + + +
+ ) +} + +interface CollapsibleActivityItemListProps { + items: ActivityItem[] +} + +const CollapsibleActivityItemList = ({ + items, +}: CollapsibleActivityItemListProps) => { + const [open, setOpen] = useState(false) + + return ( + + {!open && ( +
+
+
+
+ + + {`Show ${items.length} more ${ + items.length === 1 ? "activity" : "activities" + }`} + + +
+ )} + +
+ {items.map((item, idx) => { + return + })} +
+
+ + ) +} + +interface ActivityItem { + label: string + content?: ReactNode + timestamp: string + userId?: string | null +} + +interface ActivityItemProps { + item: ActivityItem + isFirst?: boolean +} + +const ActivityItem = ({ item, isFirst = false }: ActivityItemProps) => { + const { user, isPending, isError, error } = useUser(item.userId!, undefined, { + enabled: !!item.userId, + }) + + if (isError) { + throw error + } + + const isUserLoaded = !isPending && !!user && !!item.userId + + return ( +
+
+
+
+
+
+
+ {!isFirst && ( +
+
+
+ )} +
+
+
+ + {item.label} + + + + {getRelativeDate(item.timestamp)} + + +
+ {item.content && renderContent(item.content)} + {item.userId && ( +
+ {isUserLoaded ? ( + +
+ By + + + {user.first_name} {user.last_name} + +
+ + ) : ( +
+ By + + +
+ )} +
+ )} +
+
+ ) +} + +function renderContent(content: ReactNode) { + if (typeof content === "string") { + return ( + + {content} + + ) + } + + return content +} + +function getEditActivityItems( + change: HttpTypes.AdminOrderChange +): ActivityItem[] { + const activityItems: ActivityItem[] = [] + const counts = { + itemsAdded: 0, + itemsRemoved: 0, + shippingMethodsAdded: 0, + shippingMethodsRemoved: 0, + promotionsAdded: 0, + promotionsRemoved: 0, + } + + for (const action of change.actions) { + if (!action.details) { + continue + } + + switch (action.action) { + case "ITEM_ADD": + counts.itemsAdded += action.details.quantity as number + break + case "ITEM_UPDATE": + const diff = action.details.quantity_diff as number + diff > 0 ? (counts.itemsAdded += diff) : (counts.itemsRemoved += diff) + break + case "SHIPPING_ADD": + counts.shippingMethodsAdded += 1 + break + case "SHIPPING_REMOVE": + counts.shippingMethodsRemoved += 1 + break + case "PROMOTION_ADD": + counts.promotionsAdded += 1 + break + case "PROMOTION_REMOVE": + counts.promotionsRemoved += 1 + break + } + } + + const createActivityItem = ( + type: "items" | "shipping" | "promotions", + added: number, + removed: number + ) => { + if (added === 0 && removed === 0) return + + const getText = (count: number, singular: string, plural: string) => + count === 1 ? `${count} ${singular}` : `${count} ${plural}` + + const addedText = getText( + added, + type === "items" + ? "item" + : type === "shipping" + ? "shipping method" + : "promotion", + type === "items" + ? "items" + : type === "shipping" + ? "shipping methods" + : "promotions" + ) + const removedText = getText( + Math.abs(removed), + type === "items" + ? "item" + : type === "shipping" + ? "shipping method" + : "promotion", + type === "items" + ? "items" + : type === "shipping" + ? "shipping methods" + : "promotions" + ) + + const content = + added && removed + ? `Added ${addedText}, removed ${removedText}` + : added + ? `Added ${addedText}` + : `Removed ${removedText}` + + const label = + added && removed + ? `${ + type === "items" + ? "Items" + : type === "shipping" + ? "Shipping methods" + : "Promotions" + } updated` + : added + ? `${ + type === "items" + ? "Items" + : type === "shipping" + ? "Shipping methods" + : "Promotions" + } added` + : `${ + type === "items" + ? "Items" + : type === "shipping" + ? "Shipping methods" + : "Promotions" + } removed` + + activityItems.push({ + label, + content, + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + }) + } + + createActivityItem("items", counts.itemsAdded, counts.itemsRemoved) + createActivityItem( + "shipping", + counts.shippingMethodsAdded, + counts.shippingMethodsRemoved + ) + createActivityItem( + "promotions", + counts.promotionsAdded, + counts.promotionsRemoved + ) + + return activityItems +} + +function getTransferActivityItem(change: HttpTypes.AdminOrderChange) { + return { + label: "Transferred", + content: "Draft order transferred", + timestamp: new Date(change.created_at).toISOString(), + } +} + +function getUpdateOrderActivityItem(change: HttpTypes.AdminOrderChange) { + const { details } = change.actions?.[0] || {} + + if (!details) { + return null + } + + switch (details.type) { + case "customer_id": + return { + label: "Customer updated", + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + } + case "sales_channel_id": + return { + label: "Sales channel updated", + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + } + case "billing_address": + return { + label: "Billing address updated", + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + } + case "shipping_address": + return { + label: "Shipping address updated", + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + } + case "email": + return { + label: "Email updated", + timestamp: new Date(change.created_at).toISOString(), + userId: change.confirmed_by, + } + + default: + return null + } +} + +function getActivityItems( + order: HttpTypes.AdminOrder, + changes: HttpTypes.AdminOrderChange[] +) { + const items: ActivityItem[] = [] + + if (order.created_at) { + items.push({ + label: "Created", + content: "Draft order created", + timestamp: new Date(order.created_at).toISOString(), + }) + } + + changes.forEach((change) => { + if (!change.change_type || !change.confirmed_at) { + return + } + + switch (change.change_type) { + case "edit": { + items.push(...getEditActivityItems(change)) + break + } + case "transfer": + items.push(getTransferActivityItem(change)) + break + case "update_order": { + const item = getUpdateOrderActivityItem(change) + + if (item) { + items.push(item) + } + break + } + default: + break + } + }) + + return items.sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/customer-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/customer-section.tsx new file mode 100644 index 0000000000..eea2fcfeda --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/customer-section.tsx @@ -0,0 +1,211 @@ +import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Avatar, Container, Copy, Heading, Text } from "@medusajs/ui" +import { Link } from "react-router-dom" +import { + getFormattedAddress, + isSameAddress, +} from "../../lib/utils/address-utils" +import { getOrderCustomer } from "../../lib/utils/order-utils" +import { ActionMenu } from "../common/action-menu" + +interface CustomerSectionProps { + order: HttpTypes.AdminOrder +} + +export const CustomerSection = ({ order }: CustomerSectionProps) => { + return ( + +
+ + + + + ) +} + +const Header = () => { + return ( +
+ Customer + , + }, + ], + }, + { + actions: [ + { + label: "Edit shipping address", + to: "shipping-address", + icon: , + }, + { + label: "Edit billing address", + to: "billing-address", + icon: , + }, + ], + }, + { + actions: [ + { + label: "Edit email", + to: `email`, + icon: , + }, + ], + }, + ]} + /> +
+ ) +} + +const ID = ({ order }: CustomerSectionProps) => { + const id = order.customer_id + const name = getOrderCustomer(order) + const email = order.email + const fallback = (name || email || "").charAt(0).toUpperCase() + + return ( +
+ + ID + + +
+ +
+ + {name || email} + +
+
+ +
+ ) +} + +const Contact = ({ order }: CustomerSectionProps) => { + const phone = order.shipping_address?.phone || order.billing_address?.phone + const email = order.email || "" + + return ( +
+ + Contact + +
+
+ + {email} + + +
+ +
+
+ {phone && ( +
+ + {phone} + + +
+ +
+
+ )} +
+
+ ) +} + +const AddressPrint = ({ + address, + type, +}: { + address: + | HttpTypes.AdminOrder["shipping_address"] + | HttpTypes.AdminOrder["billing_address"] + type: "shipping" | "billing" +}) => { + return ( +
+ + {type === "shipping" ? "Shipping address" : "Billing address"} + + {address ? ( +
+ + {getFormattedAddress(address).map((line, i) => { + return ( + + {line} +
+
+ ) + })} +
+
+ +
+
+ ) : ( + + - + + )} +
+ ) +} + +const Addresses = ({ order }: CustomerSectionProps) => { + return ( +
+ + {!isSameAddress(order.shipping_address, order.billing_address) ? ( + + ) : ( +
+ + Billing address + + + Same as shipping address + +
+ )} +
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/general-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/general-section.tsx new file mode 100644 index 0000000000..7fd086ec6a --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/general-section.tsx @@ -0,0 +1,91 @@ +import { Channels, Trash } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { + Badge, + Container, + Copy, + Heading, + Skeleton, + Text, + toast, +} from "@medusajs/ui" +import { Link, useNavigate } from "react-router-dom" +import { useRegion } from "../../hooks/api/regions" +import { getFullDate } from "../../lib/utils/date-utils" +import { ActionMenu } from "../common/action-menu" +import { useDeleteDraftOrder } from "../../hooks/api/draft-orders" + +interface GeneralSectionProps { + order: HttpTypes.AdminOrder +} + +export const GeneralSection = ({ order }: GeneralSectionProps) => { + const navigate = useNavigate() + + const { mutateAsync: deleteDraftOrder, isPending: isDeleting } = + useDeleteDraftOrder() + + const { region, isPending, isError, error } = useRegion( + order.region_id!, + undefined, + { + enabled: !!order.region_id, + } + ) + + const isRegionLoaded = !!region && !isPending + + if (isError) { + throw error + } + + return ( + +
+
+ Draft Order #{order.display_id} + + {isRegionLoaded ? ( + + {region?.name} + + ) : ( + + )} +
+ + {`${getFullDate({ + date: order.created_at, + includeTime: true, + })} from ${order.sales_channel?.name}`} + +
+ , + to: "sales-channel", + }, + { + label: "Delete draft order", + icon: , + onClick: async () => { + try { + await deleteDraftOrder(order.id) + navigate("/draft-orders") + } catch (error: any) { + toast.error(error.message) + } + }, + disabled: isDeleting, + }, + ], + }, + ]} + /> +
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/json-view-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/json-view-section.tsx new file mode 100644 index 0000000000..492cb66a8d --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/json-view-section.tsx @@ -0,0 +1,193 @@ +import { + ArrowUpRightOnBox, + Check, + SquareTwoStack, + TriangleDownMini, + XMarkMini, +} from "@medusajs/icons" +import { + Badge, + Container, + Drawer, + Heading, + IconButton, + Kbd, +} from "@medusajs/ui" +import Primitive from "@uiw/react-json-view" +import { CSSProperties, MouseEvent, Suspense, useState } from "react" + +type JsonViewSectionProps = { + data: object + title?: string +} + +export const JsonViewSection = ({ data }: JsonViewSectionProps) => { + const numberOfKeys = Object.keys(data).length + + return ( + +
+ JSON + + {`${numberOfKeys} ${numberOfKeys === 1 ? "key" : "keys"}`} + +
+ + + + + + + +
+
+ + + JSON{" "} + + {numberOfKeys} + + + + + View the JSON representation of the draft order. + +
+
+ + esc + + + + + + +
+
+ +
+
} + > + + } /> + ( + null + )} + /> + ( + undefined + )} + /> + { + return ( + + {`${Object.keys(value as object).length} ${ + Object.keys(value as object).length === 1 + ? "key" + : "keys" + }`} + + ) + }} + /> + + + + + : + + { + return + }} + /> + + +
+ + + + + ) +} + +type CopiedProps = { + style?: CSSProperties + value: object | undefined +} + +const Copied = ({ style, value }: CopiedProps) => { + const [copied, setCopied] = useState(false) + + const handler = (e: MouseEvent) => { + e.stopPropagation() + setCopied(true) + + if (typeof value === "string") { + navigator.clipboard.writeText(value) + } else { + const json = JSON.stringify(value, null, 2) + navigator.clipboard.writeText(json) + } + + setTimeout(() => { + setCopied(false) + }, 2000) + } + + const styl = { whiteSpace: "nowrap", width: "20px" } + + if (copied) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/metadata-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/metadata-section.tsx new file mode 100644 index 0000000000..c6809490e3 --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/metadata-section.tsx @@ -0,0 +1,31 @@ +import { ArrowUpRightOnBox } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { Badge, Container, Heading, IconButton } from "@medusajs/ui" +import { Link } from "react-router-dom" + +interface MetadataSectionProps { + order: HttpTypes.AdminOrder +} + +export const MetadataSection = ({ order }: MetadataSectionProps) => { + return ( + +
+ Metadata + + {Object.keys(order.metadata || {}).length} keys + +
+ + + + + +
+ ) +} diff --git a/packages/plugins/draft-order/src/admin/components/draft-orders/shipping-section.tsx b/packages/plugins/draft-order/src/admin/components/draft-orders/shipping-section.tsx new file mode 100644 index 0000000000..34e326583c --- /dev/null +++ b/packages/plugins/draft-order/src/admin/components/draft-orders/shipping-section.tsx @@ -0,0 +1,400 @@ +import { + Buildings, + Shopping, + TriangleRightMini, + TruckFast, +} from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { + Badge, + Button, + clx, + Container, + Divider, + Heading, + IconButton, + StatusBadge, + Text, + Tooltip, +} from "@medusajs/ui" +import { Accordion } from "radix-ui" +import { Link } from "react-router-dom" + +import { useShippingOptions } from "../../hooks/api/shipping-options" +import { getUniqueShippingProfiles } from "../../lib/utils/order-utils" +import { pluralize } from "../../lib/utils/string-utils" +import { Thumbnail } from "../common/thumbnail" +import { useMemo } from "react" + +interface ShippingSectionProps { + order: HttpTypes.AdminOrder +} + +export const ShippingSection = ({ order }: ShippingSectionProps) => { + const orderHasShipping = order.shipping_methods.length > 0 + + const { + shipping_options = [], + isPending, + isError, + error, + } = useShippingOptions( + { + id: order.shipping_methods + .map((method) => method.shipping_option_id) + .filter(Boolean) as string[], + fields: + "+service_zone.*,+service_zone.fulfillment_set.*,+service_zone.fulfillment_set.location.*,shipping_profile.*", + }, + { + enabled: orderHasShipping, + } + ) + + if (isError) { + throw error + } + + const ready = !orderHasShipping ? true : shipping_options && !isPending + + const data = useMemo(() => { + const shippingProfilesData = getShippingProfileData(order.items) + + // shipping profiles of the items on the order + const profileIdMap = new Map() + shippingProfilesData.forEach((profile) => { + profileIdMap.set(profile.id, true) + }) + + // shipping profiles of the shipping methods on the order + const uniqueProfilesOfShippingMethods = Array.from( + new Set(shipping_options.map((option) => option.shipping_profile_id)) + ) + + // prepare data of shipping profiles that are handed by the order shipping methods but not on needed by the order items + const additionalShippingProfilesData = uniqueProfilesOfShippingMethods + .filter((id) => !profileIdMap.has(id)) + .map((id) => ({ + id, + name: + shipping_options.find((option) => option.shipping_profile_id === id) + ?.shipping_profile?.name || "", + items: [], + })) + + const shippingProfileDisplayData = [ + ...shippingProfilesData, + ...additionalShippingProfilesData, + ] + + return shippingProfileDisplayData.sort((a, b) => + a.name.localeCompare(b.name) + ) + }, [order.items, shipping_options]) + + const isSomeProfilesAssigned = data.some((profile) => + shipping_options?.find( + (option) => option.shipping_profile_id === profile.id + ) + ) + + return ( + +
+ Shipping +
+ + + {ready && + data.map((profile, idx) => ( +
+ {renderShippingProfile( + profile, + shipping_options, + idx === data.length - 1 + )} +
+ ))} +
+ + +