diff --git a/packages/composables/src/index.ts b/packages/composables/src/index.ts index 83b575163..4021b78d7 100644 --- a/packages/composables/src/index.ts +++ b/packages/composables/src/index.ts @@ -65,6 +65,8 @@ export * from "./useWishlist/useWishlist"; export * from "./useB2bQuoteManagement/useB2bQuoteManagement"; export * from "./useCartNotification/useCartNotification"; export * from "./useCartErrorParamsResolver/useCartErrorParamsResolver"; +export * from "./useOrder/useOrder"; +export * from "./useOrderDataProvider/useOrderDataProvider"; export function resolveCmsComponent( content: Schemas["CmsSection"] | Schemas["CmsBlock"] | Schemas["CmsSlot"], diff --git a/packages/composables/src/useOrder/useOrder.test.ts b/packages/composables/src/useOrder/useOrder.test.ts new file mode 100644 index 000000000..c9654e85c --- /dev/null +++ b/packages/composables/src/useOrder/useOrder.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { useOrder } from "./useOrder"; +import { useSetup } from "../_test"; +import { useOrderDataProvider } from "../useOrderDataProvider/useOrderDataProvider"; +import type { Schemas } from "#shopware"; +import Order from "../mocks/Order"; + +vi.mock("../useOrderDataProvider/useOrderDataProvider.ts"); + +describe("useOrder", () => { + vi.mocked(useOrderDataProvider).mockReturnValue({ + loadOrderDetails: async () => Order as unknown as Schemas["Order"], + } as unknown as ReturnType); + it("init details", async () => { + const { vm } = useSetup(() => + useOrder(Order.orders.elements[0] as unknown as Schemas["Order"]), + ); + + expect(vm.personalDetails).toEqual({ + email: Order.orders.elements[0].orderCustomer.email, + firstName: Order.orders.elements[0].orderCustomer.firstName, + lastName: Order.orders.elements[0].orderCustomer.lastName, + }); + + expect(vm.billingAddress).toEqual( + Order.orders.elements[0].addresses.find( + ({ id }: { id: string }) => + id === Order.orders.elements[0].billingAddressId, + ), + ); + }); + + it("should handle setting the order payment", async () => { + const { vm, injections } = useSetup(() => + useOrder(Order.orders.elements[0] as unknown as Schemas["Order"]), + ); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + await vm.handlePayment(); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("handlePaymentMethod"), + expect.objectContaining({ + body: { + errorUrl: undefined, + finishUrl: undefined, + orderId: Order.orders.elements[0].id, + }, + }), + ); + }); + + it("should cancel the order", async () => { + const { vm, injections } = useSetup(() => + useOrder(Order.orders.elements[0] as unknown as Schemas["Order"]), + ); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + await vm.cancel(); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("cancelOrder"), + expect.objectContaining({ + body: { + orderId: Order.orders.elements[0].id, + }, + }), + ); + }); + + it("changePaymentMethod", async () => { + const { vm, injections } = useSetup(() => + useOrder(Order.orders.elements[0] as unknown as Schemas["Order"]), + ); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + await vm.changePaymentMethod("test"); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("orderSetPayment"), + expect.objectContaining({ + body: { + orderId: Order.orders.elements[0].id, + paymentMethodId: "test", + }, + }), + ); + }); + + it("should set order data", async () => { + const { vm } = useSetup(() => useOrder()); + vm.asyncSetData(Order.orders.elements[0] as unknown as Schemas["Order"]); + + expect(vm.order).toEqual(Order.orders.elements[0]); + }); +}); diff --git a/packages/composables/src/useOrder/useOrder.ts b/packages/composables/src/useOrder/useOrder.ts new file mode 100644 index 000000000..1b0e40977 --- /dev/null +++ b/packages/composables/src/useOrder/useOrder.ts @@ -0,0 +1,230 @@ +import { computed, ref, inject, provide } from "vue"; +import type { ComputedRef, Ref } from "vue"; +import { useShopwareContext } from "#imports"; +import type { Schemas } from "#shopware"; +import { useOrderDataProvider } from "../useOrderDataProvider/useOrderDataProvider"; + +export type UseOrderReturn = { + /** + * {@link Schemas['Order']} object + */ + order: ComputedRef; + /** + * Order status (e.g. 'Open', 'Cancelled') + */ + status: ComputedRef; + /** + * Order status technical name (e.g. 'open', 'cancelled') + */ + statusTechnicalName: ComputedRef; + /** + * Order total price + */ + total: ComputedRef; + /** + * Order subtotal price for all items + */ + subtotal: ComputedRef; + /** + * Order shipping costs + */ + shippingCosts: ComputedRef; + /** + * Shipping address + */ + shippingAddress: ComputedRef; + /** + * Billing address + */ + billingAddress: ComputedRef; + /** + * Basic personal details + */ + personalDetails: ComputedRef<{ + email: string | undefined; + firstName: string | undefined; + lastName: string | undefined; + }>; + /** + * Payment URL for external payment methods (e.g. async payment in external payment gateway) + */ + paymentUrl: Ref; + /** + * Selected shipping method + */ + shippingMethod: ComputedRef; + /** + * Selected payment method + */ + paymentMethod: ComputedRef; + + /** + * Handle payment for existing error. + * + * Pass custom success and error URLs (optionally). + */ + handlePayment( + successUrl?: string, + errorUrl?: string, + paymentDetails?: unknown, + ): void; + /** + * Cancel an order. + * + * Action cannot be reverted. + */ + cancel(): Promise; + /** + * Changes the payment method for current cart. + * @param paymentMethodId - ID of the payment method to be set + * @returns + */ + changePaymentMethod( + paymentMethodId: string, + ): Promise; + /** + * Check if order has documents + */ + hasDocuments: ComputedRef; + /** + * Get order documents + */ + documents: ComputedRef; + + // paymentChangeable: ComputedRef; + asyncSetData(order: Schemas["Order"]): void; +}; + +/** + * Composable for managing an existing order. + * @public + * @category Customer & Account + */ +export function useOrder(order?: Schemas["Order"]): UseOrderReturn { + const { apiClient } = useShopwareContext(); + const { loadOrderDetails } = useOrderDataProvider(); + + // const paymentChangeableList: Ref<{ [key: string]: boolean }> = ref({}); + const _sharedOrder = inject>( + "swOrderDetails", + ref(order ?? null), + ); + provide("swOrderDetails", _sharedOrder); + + const asyncSetData = (order: Schemas["Order"]) => { + _sharedOrder.value = order; + }; + const orderId = computed(() => _sharedOrder.value?.id ?? ""); + const paymentMethod = computed( + () => _sharedOrder.value?.transactions?.[0]?.paymentMethod, + ); + const shippingMethod = computed( + () => _sharedOrder.value?.deliveries?.[0]?.shippingMethod, + ); + const paymentUrl = ref(); + + const personalDetails = computed(() => ({ + email: _sharedOrder.value?.orderCustomer?.email, + firstName: _sharedOrder.value?.orderCustomer?.firstName, + lastName: _sharedOrder.value?.orderCustomer?.lastName, + })); + + const billingAddress = computed(() => + _sharedOrder.value?.addresses?.find( + ({ id }: { id: string }) => id === _sharedOrder.value?.billingAddressId, + ), + ); + + const shippingAddress = computed( + () => _sharedOrder.value?.deliveries?.[0]?.shippingOrderAddress, + ); + + const shippingCosts = computed(() => _sharedOrder.value?.shippingTotal); + const subtotal = computed(() => _sharedOrder.value?.price?.positionPrice); + const total = computed(() => _sharedOrder.value?.price?.totalPrice); + const status = computed( + () => _sharedOrder.value?.stateMachineState?.translated.name, + ); + const statusTechnicalName = computed( + () => _sharedOrder.value?.stateMachineState?.technicalName, + ); + + async function handlePayment(finishUrl?: string, errorUrl?: string) { + const resp = await apiClient.invoke( + "handlePaymentMethod post /handle-payment", + { + body: { + orderId: orderId.value, + finishUrl, + errorUrl, + }, + }, + ); + + paymentUrl.value = resp.data.redirectUrl; + } + + async function cancel() { + const resp = await apiClient.invoke( + "cancelOrder post /order/state/cancel", + { + body: { + orderId: orderId.value, + }, + }, + ); + + await updateOrder(); + + return resp.data; + } + + async function changePaymentMethod(paymentMethodId: string) { + const response = await apiClient.invoke( + "orderSetPayment post /order/payment", + { + body: { + orderId: orderId.value, + paymentMethodId: paymentMethodId, + }, + }, + ); + + await updateOrder(); + return response.data; + } + + const hasDocuments = computed(() => !!_sharedOrder.value?.documents.length); + const documents = computed(() => _sharedOrder.value?.documents || []); + + const updateOrder = async () => { + const order = await loadOrderDetails({ + keyValue: orderId.value, + }); + + if (order) { + _sharedOrder.value = order; + } + }; + + return { + order: computed(() => _sharedOrder.value), + status, + statusTechnicalName, + total, + subtotal, + shippingCosts, + shippingAddress, + billingAddress, + personalDetails, + paymentUrl, + shippingMethod, + paymentMethod, + hasDocuments, + documents, + handlePayment, + cancel, + changePaymentMethod, + asyncSetData, + }; +} diff --git a/packages/composables/src/useOrderDataProvider/useOrderDataProvider.test.ts b/packages/composables/src/useOrderDataProvider/useOrderDataProvider.test.ts new file mode 100644 index 000000000..bb5cc91e9 --- /dev/null +++ b/packages/composables/src/useOrderDataProvider/useOrderDataProvider.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { useOrderDataProvider } from "./useOrderDataProvider"; +import { useDefaultOrderAssociations } from "../useDefaultOrderAssociations/useDefaultOrderAssociations"; +import { useSetup } from "../_test"; +import Order from "../mocks/Order"; + +describe("useOrderDataProvider", () => { + const orderAssociations = useDefaultOrderAssociations(); + it("should load order details", async () => { + const { vm, injections } = useSetup(() => useOrderDataProvider()); + injections.apiClient.invoke.mockResolvedValue({ + data: Order, + }); + vm.loadOrderDetails({ keyValue: "123" }); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("readOrder"), + expect.objectContaining({ + body: { + ...orderAssociations, + filter: [{ field: "id", type: "equals", value: "123" }], + }, + }), + ); + }); + + it("should load order details with custom associations", async () => { + const { vm, injections } = useSetup(() => useOrderDataProvider()); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + vm.loadOrderDetails({ keyValue: "123", field: "deepCode" }, { custom: {} }); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("readOrder"), + expect.objectContaining({ + body: { + ...orderAssociations, + filter: [{ field: "deepCode", type: "equals", value: "123" }], + }, + }), + ); + }); + + it("should get the order document file", async () => { + const { vm, injections } = useSetup(() => useOrderDataProvider()); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + await vm.getDocumentFile("file-123", "code-123"); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("download"), + expect.objectContaining({ + pathParams: { + documentId: "file-123", + deepLinkCode: "code-123", + }, + }), + ); + }); + + it("getMediaFile", async () => { + const { vm, injections } = useSetup(() => useOrderDataProvider()); + injections.apiClient.invoke.mockResolvedValue({ data: {} }); + await vm.getMediaFile(Order.orders.elements[0].id, "file-123"); + + expect(injections.apiClient.invoke).toHaveBeenCalledWith( + expect.stringContaining("orderDownloadFile"), + expect.objectContaining({ + accept: "application/octet-stream", + pathParams: { + orderId: Order.orders.elements[0].id, + downloadId: "file-123", + }, + }), + ); + }); +}); diff --git a/packages/composables/src/useOrderDataProvider/useOrderDataProvider.ts b/packages/composables/src/useOrderDataProvider/useOrderDataProvider.ts new file mode 100644 index 000000000..73614b100 --- /dev/null +++ b/packages/composables/src/useOrderDataProvider/useOrderDataProvider.ts @@ -0,0 +1,99 @@ +import { defu } from "defu"; +import { useDefaultOrderAssociations, useShopwareContext } from "#imports"; + +import type { Schemas } from "#shopware"; + +export type UseOrderDataProviderReturn = { + /** + * Get order object including additional associations. + * useDefaults describes what order object should look like. + */ + loadOrderDetails( + orderSearchData: { keyValue: string; field?: string }, + associations?: Schemas["Criteria"]["associations"], + ): Promise; + + /** + * Get media content + * + * @param {string} downloadId + * @returns {Blob} + */ + getMediaFile: (orderId: string, downloadId: string) => Promise; + + /** + * Get order documents + * @param {string} documentId + * @param {string} deepLinkCode + * @returns + */ + getDocumentFile: ( + documentId: string, + deepLinkCode: string, + ) => Promise; +}; + +export function useOrderDataProvider(): UseOrderDataProviderReturn { + const { apiClient } = useShopwareContext(); + const orderAssociations = useDefaultOrderAssociations(); + + async function loadOrderDetails( + orderSearchData: { keyValue: string; field?: string }, + associations?: Schemas["Criteria"]["associations"], + ) { + const mergedAssociations = defu( + orderAssociations, + associations ? associations : {}, + ); + const params = { + filter: [ + { + type: "equals", + field: orderSearchData.field ?? "id", + value: orderSearchData.keyValue, + }, + ], + associations: mergedAssociations.associations, + checkPromotion: true, + } as Schemas["Criteria"]; + + const orderDetailsResponse = await apiClient.invoke( + "readOrder post /order", + { + body: params, + }, + ); + return orderDetailsResponse.data.orders?.elements?.[0] ?? null; + } + + async function getMediaFile(orderId: string, downloadId: string) { + const response = await apiClient.invoke( + "orderDownloadFile get /order/download/{orderId}/{downloadId}", + { + accept: "application/octet-stream", + pathParams: { + orderId, + downloadId, + }, + }, + ); + + return response.data; + } + + async function getDocumentFile(documentId: string, deepLinkCode: string) { + const response = await apiClient.invoke( + "download post /document/download/{documentId}/{deepLinkCode}", + { + pathParams: { + documentId, + deepLinkCode, + }, + }, + ); + + return response.data; + } + + return { loadOrderDetails, getMediaFile, getDocumentFile }; +} diff --git a/packages/composables/src/useOrderDetails/useOrderDetails.ts b/packages/composables/src/useOrderDetails/useOrderDetails.ts index c6ff12f97..313c0db94 100644 --- a/packages/composables/src/useOrderDetails/useOrderDetails.ts +++ b/packages/composables/src/useOrderDetails/useOrderDetails.ts @@ -120,6 +120,8 @@ export type UseOrderDetailsReturn = { }; /** + * @deprecated - use {@link useOrder} and {@link useOrderDataProvider} instead. + * * Composable for managing an existing order. * @public * @category Customer & Account diff --git a/templates/vue-demo-store/components/account/AccountOrderDetails.vue b/templates/vue-demo-store/components/account/AccountOrderDetails.vue index 14fa12ab6..d78cb0cb5 100644 --- a/templates/vue-demo-store/components/account/AccountOrderDetails.vue +++ b/templates/vue-demo-store/components/account/AccountOrderDetails.vue @@ -12,21 +12,30 @@ const isLoading = ref(false); const { getErrorsCodes } = useCartNotification(); const { pushSuccess, pushError } = useNotifications(); const { t } = useI18n(); + const { - loadOrderDetails, order, hasDocuments, documents, paymentMethod, - paymentChangeable, - getPaymentMethods, + // paymentChangeable, changePaymentMethod, statusTechnicalName, -} = await useOrderDetails(props.orderId); + asyncSetData, +} = await useOrder(); + +const { paymentMethods, getPaymentMethods } = useCheckout(); + +const { loadOrderDetails } = useOrderDataProvider(); + const { addProducts, count } = useCart(); const addingProducts = ref(false); -onMounted(() => { - loadOrderDetails(); +onMounted(async () => { + getPaymentMethods(); + const order = await loadOrderDetails({ + keyValue: props.orderId, + }); + if (order) asyncSetData(order); }); const lineItems = computed>( @@ -50,7 +59,6 @@ const selectedPaymentMethod = computed({ } }, }); -const paymentMethods = await getPaymentMethods(); const handleReorder = async () => { if (!order.value?.lineItems) { @@ -95,7 +103,7 @@ const handleReorder = async () => {