From 817db3633ff3fe17fce01bdd4a7cbc51a0e81ea5 Mon Sep 17 00:00:00 2001 From: Marc Peternell Date: Sun, 25 Feb 2024 11:12:22 +0100 Subject: [PATCH] feat(demo): add payment form --- .changeset/seven-tomatoes-leave.md | 6 + ...utPaymentUnzerCreditCardPaymentHandler.vue | 202 ++++++++++++++++++ .../components/UnzerTestDataTable.vue | 159 ++++++++++++++ .../unzer-payment/composables/useUnzer.ts | 70 ++++++ examples/unzer-payment/index.ts | 27 +++ .../unzer-payment/runtime/handle-payment.ts | 23 ++ packages/composables/src/useOrderPayment.ts | 9 +- .../checkout/CheckoutPaymentForm.vue | 154 +++++++++++++ .../vue-demo-store/composables/useEventBus.ts | 61 ++++++ .../vue-demo-store/i18n/de-DE/checkout.json | 3 +- .../vue-demo-store/i18n/en-GB/checkout.json | 3 +- .../vue-demo-store/i18n/pl-PL/checkout.json | 3 +- .../vue-demo-store/pages/checkout/index.vue | 100 ++------- .../pages/checkout/success/[id]/index.vue | 26 ++- .../pages/checkout/success/[id]/paid.vue | 6 + .../pages/checkout/success/[id]/unpaid.vue | 4 + 16 files changed, 760 insertions(+), 96 deletions(-) create mode 100644 .changeset/seven-tomatoes-leave.md create mode 100644 examples/unzer-payment/components/CheckoutPaymentUnzerCreditCardPaymentHandler.vue create mode 100644 examples/unzer-payment/components/UnzerTestDataTable.vue create mode 100644 examples/unzer-payment/composables/useUnzer.ts create mode 100644 examples/unzer-payment/index.ts create mode 100644 examples/unzer-payment/runtime/handle-payment.ts create mode 100644 templates/vue-demo-store/components/checkout/CheckoutPaymentForm.vue create mode 100644 templates/vue-demo-store/composables/useEventBus.ts diff --git a/.changeset/seven-tomatoes-leave.md b/.changeset/seven-tomatoes-leave.md new file mode 100644 index 000000000..381844b7c --- /dev/null +++ b/.changeset/seven-tomatoes-leave.md @@ -0,0 +1,6 @@ +--- +"vue-demo-store": minor +"@shopware-pwa/composables-next": minor +--- + +add payment form diff --git a/examples/unzer-payment/components/CheckoutPaymentUnzerCreditCardPaymentHandler.vue b/examples/unzer-payment/components/CheckoutPaymentUnzerCreditCardPaymentHandler.vue new file mode 100644 index 000000000..afe18506a --- /dev/null +++ b/examples/unzer-payment/components/CheckoutPaymentUnzerCreditCardPaymentHandler.vue @@ -0,0 +1,202 @@ + + + diff --git a/examples/unzer-payment/components/UnzerTestDataTable.vue b/examples/unzer-payment/components/UnzerTestDataTable.vue new file mode 100644 index 000000000..457a768f3 --- /dev/null +++ b/examples/unzer-payment/components/UnzerTestDataTable.vue @@ -0,0 +1,159 @@ + + + diff --git a/examples/unzer-payment/composables/useUnzer.ts b/examples/unzer-payment/composables/useUnzer.ts new file mode 100644 index 000000000..88744f353 --- /dev/null +++ b/examples/unzer-payment/composables/useUnzer.ts @@ -0,0 +1,70 @@ +import type { Schemas } from "#shopware"; +import type { Ref } from "vue"; + +declare let unzer: UnzerSdkConstructor; + +export interface UnzerSdk {} + +export interface UnzerSdkConstructor { + new ( + publicKey: string, + unzerInstanceOptions: { locale: string } | null, + ): UnzerSdk; +} + +/** + * Composable for Unzer payments + * + * @public + */ +export function useUnzer(): { + isUnzerPaymentMethod: (paymentMethod: Schemas["PaymentMethod"]) => boolean; + unzerInstance: Ref; +} { + const isUnzerPaymentMethod = (paymentMethod: Schemas["PaymentMethod"]) => + typeof paymentMethod?.shortName === "string" && + paymentMethod.shortName.includes("unzer"); + + const unzerInstance = ref(null); + + function createUnzerInstance() { + if ( + unzerInstance.value === null || + typeof unzerInstance.value === "undefined" + ) { + const runtimeConfig = useRuntimeConfig(); + const publicKey = runtimeConfig.public.unzer?.publicKey; + + if (!publicKey) { + throw new Error("[useUnzer] public key is not defined"); + } + + const { $i18n } = useNuxtApp(); + unzerInstance.value = new unzer(publicKey, { + locale: $i18n.locale.value, + }); + } + } + + onBeforeMount(() => { + useHead({ + link: [ + { rel: "stylesheet", href: "https://static.unzer.com/v1/unzer.css" }, + ], + script: [ + { + src: "https://static.unzer.com/v1/unzer.js", + onload: () => { + // Unzer script is loaded now + createUnzerInstance(); + }, + }, + ], + }); + }); + + return { + isUnzerPaymentMethod, + unzerInstance, + }; +} diff --git a/examples/unzer-payment/index.ts b/examples/unzer-payment/index.ts new file mode 100644 index 000000000..f840f4a3d --- /dev/null +++ b/examples/unzer-payment/index.ts @@ -0,0 +1,27 @@ +import { + defineNuxtModule, + addImports, + createResolver, + addPlugin, + addComponentsDir, +} from "@nuxt/kit"; + +export interface ModuleOptions {} + +export default defineNuxtModule({ + setup() { + const resolver = createResolver(import.meta.url); + addComponentsDir({ + path: resolver.resolve("components"), + }); + + addImports([ + { + name: "useUnzer", + from: resolver.resolve("./composables/useUnzer"), + }, + ]); + + addPlugin(resolver.resolve("./runtime/handle-payment")); + }, +}); diff --git a/examples/unzer-payment/runtime/handle-payment.ts b/examples/unzer-payment/runtime/handle-payment.ts new file mode 100644 index 000000000..595e8e994 --- /dev/null +++ b/examples/unzer-payment/runtime/handle-payment.ts @@ -0,0 +1,23 @@ +export default defineNuxtPlugin({ + name: "unzer-handle-payment", + async setup() { + const { listen } = useEventBus(); + const { isUnzerPaymentMethod } = useUnzer(); + + listen( + "order:handle-payment", + async ({ paymentDetails, paymentMethod }) => { + if (!isUnzerPaymentMethod(paymentMethod)) return; + console.debug("[unzer] order:handle-payment event received"); + + paymentDetails.unzerResourceId = useLocalStorage("unzerId").value; + }, + ); + + listen("order:reset-payment", async () => { + console.debug("[unzer] order:reset-payment event received"); + const store = useLocalStorage("unzerId"); + store.value = null; + }); + }, +}); diff --git a/packages/composables/src/useOrderPayment.ts b/packages/composables/src/useOrderPayment.ts index df0ec81d4..e412cef13 100644 --- a/packages/composables/src/useOrderPayment.ts +++ b/packages/composables/src/useOrderPayment.ts @@ -54,9 +54,8 @@ export function useOrderPayment( order: ComputedRef, ): UseOrderPaymentReturn { const { apiClient } = useShopwareContext(); - const activeTransaction = computed( - () => - order.value?.transactions?.find((t) => t.paymentMethod?.active === true), + const activeTransaction = computed(() => + order.value?.transactions?.find((t) => t.paymentMethod?.active === true), ); const paymentMethod = computed(() => activeTransaction.value?.paymentMethod); const paymentUrl = ref(); @@ -70,7 +69,7 @@ export function useOrderPayment( async function handlePayment( finishUrl?: string, errorUrl?: string, - // paymentDetails?: unknown, // TODO: check if it's needed + paymentDetails?: unknown, ): Promise { if (!order.value) { return; @@ -81,7 +80,7 @@ export function useOrderPayment( orderId: order.value.id, errorUrl, finishUrl, - // paymentDetails, + ...(paymentDetails as Record), }, ); diff --git a/templates/vue-demo-store/components/checkout/CheckoutPaymentForm.vue b/templates/vue-demo-store/components/checkout/CheckoutPaymentForm.vue new file mode 100644 index 000000000..5920d8e08 --- /dev/null +++ b/templates/vue-demo-store/components/checkout/CheckoutPaymentForm.vue @@ -0,0 +1,154 @@ + + diff --git a/templates/vue-demo-store/composables/useEventBus.ts b/templates/vue-demo-store/composables/useEventBus.ts new file mode 100644 index 000000000..df8b9da92 --- /dev/null +++ b/templates/vue-demo-store/composables/useEventBus.ts @@ -0,0 +1,61 @@ +import type { Schemas } from "#shopware"; + +export interface EventPayloads { + "order:placed": Schemas["PaymentMethod"]; + "order:retry-payment": Schemas["PaymentMethod"]; + "order:handle-payment": { + paymentDetails: { + [key: string]: + | string + | string[] + | boolean + | Record + | null; + }; + paymentMethod: Schemas["PaymentMethod"]; + }; + "order:reset-payment": null; +} + +type Emitter = { + emit( + event: K, + payload?: EventPayloads[K], + ): Promise; + listen( + events: K | K[], + callback: (payload: EventPayloads[K], event?: K) => Promise | void, + ): void; +}; + +export function useEventBus(): Emitter { + const hooks = useNuxtApp().hooks; + const listeners: Record< + string, + ((payload: never, eventName: string) => Promise | void)[] + > = {}; + + return { + emit: async (event, payload) => { + if (listeners[event]) { + for (const listener of listeners[event]) { + await listener(payload as unknown as never, event); + } + } + // @ts-expect-error todo: add proper types + await hooks.callHook(event as never, payload); + }, + listen: (events, callback) => { + const eventArray = Array.isArray(events) ? events : [events]; + for (const event of eventArray) { + if (!listeners[event]) { + listeners[event] = []; + } + const wrappedCallback = (payload: never) => callback(payload, event); + listeners[event].push(wrappedCallback); + // @ts-expect-error todo: add proper types + hooks.hook(event, wrappedCallback); + } + }, + }; +} diff --git a/templates/vue-demo-store/i18n/de-DE/checkout.json b/templates/vue-demo-store/i18n/de-DE/checkout.json index afdf7385b..6d492e0b7 100644 --- a/templates/vue-demo-store/i18n/de-DE/checkout.json +++ b/templates/vue-demo-store/i18n/de-DE/checkout.json @@ -63,6 +63,7 @@ }, "messages": { "checkoutSignInSuccess": "Vielen Dank für Ihre Anmeldung! Sie erhalten in Kürze eine Bestätigungs-E-Mail. Klicken Sie auf den Link darin, um die Anmeldung abzuschließen." - } + }, + "switchNotAllowed": "Auf diese Zahlungsmethode kann nicht gewechselt werden." } } diff --git a/templates/vue-demo-store/i18n/en-GB/checkout.json b/templates/vue-demo-store/i18n/en-GB/checkout.json index ca915d3f9..f40ff72a2 100644 --- a/templates/vue-demo-store/i18n/en-GB/checkout.json +++ b/templates/vue-demo-store/i18n/en-GB/checkout.json @@ -63,6 +63,7 @@ }, "messages": { "checkoutSignInSuccess": "Thank you for signing up! You will receive a confirmation email shortly. Click on the link in it to complete the sign-up." - } + }, + "switchNotAllowed": "It is not possible to switch to this payment method." } } diff --git a/templates/vue-demo-store/i18n/pl-PL/checkout.json b/templates/vue-demo-store/i18n/pl-PL/checkout.json index 126c0ffc5..3e75f6803 100644 --- a/templates/vue-demo-store/i18n/pl-PL/checkout.json +++ b/templates/vue-demo-store/i18n/pl-PL/checkout.json @@ -64,6 +64,7 @@ }, "messages": { "checkoutSignInSuccess": "Dziękujemy za rejestrację! Wkrótce otrzymasz e-mail z potwierdzeniem. Kliknij w link w e-mailu, aby ukończyć rejestrację." - } + }, + "switchNotAllowed": "Nie ma możliwości przejścia na tę metodę płatności." } } diff --git a/templates/vue-demo-store/pages/checkout/index.vue b/templates/vue-demo-store/pages/checkout/index.vue index 1a79ba664..e76719f8e 100644 --- a/templates/vue-demo-store/pages/checkout/index.vue +++ b/templates/vue-demo-store/pages/checkout/index.vue @@ -7,7 +7,7 @@ export default { import { useVuelidate } from "@vuelidate/core"; import { getShippingMethodDeliveryTime } from "@shopware-pwa/helpers-next"; import { customValidators } from "@/i18n/utils/i18n-validators"; -import type { RequestParameters } from "#shopware"; +import type { RequestParameters, Schemas } from "#shopware"; import { ApiClientError, type ApiError } from "@shopware/api-client"; const { required, minLength, requiredIf, email } = customValidators(); @@ -23,20 +23,13 @@ const { pushInfo } = useNotifications(); const { t } = useI18n(); const localePath = useLocalePath(); const { formatLink } = useInternationalization(localePath); -const { - paymentMethods, - shippingMethods, - getPaymentMethods, - getShippingMethods, - createOrder, -} = useCheckout(); +const { shippingMethods, getShippingMethods, createOrder } = useCheckout(); const { register, logout, isLoggedIn, isGuestSession, user } = useUser(); const { refreshSessionContext, selectedShippingMethod: shippingMethod, selectedPaymentMethod: paymentMethod, setShippingMethod, - setPaymentMethod, activeShippingAddress, setActiveShippingAddress, activeBillingAddress, @@ -70,16 +63,6 @@ const selectedShippingMethod = computed({ isLoading[shippingMethodId] = false; }, }); -const selectedPaymentMethod = computed({ - get(): string { - return paymentMethod.value?.id || ""; - }, - async set(paymentMethodId: string) { - isLoading[paymentMethodId] = true; - await setPaymentMethod({ id: paymentMethodId }); - isLoading[paymentMethodId] = false; - }, -}); const selectedShippingAddress = computed({ get(): string { @@ -107,6 +90,8 @@ const selectedBillingAddress = computed({ }, }); +const { emit } = useEventBus(); + const isCartLoading = computed(() => { return !cart.value; }); @@ -199,6 +184,14 @@ const placeOrder = async () => { } isLoading["placeOrder"] = true; + + try { + await emit("order:placed", paymentMethod.value as Schemas["PaymentMethod"]); + } catch (e) { + isLoading["placeOrder"] = false; + return; + } + const order = await createOrder(); isLoading["placeOrder"] = false; await push("/checkout/success/" + order.id); @@ -214,15 +207,12 @@ onMounted(async () => { await refreshSessionContext(); isLoading["shippingMethods"] = true; - isLoading["paymentMethods"] = true; Promise.any([ loadCustomerAddresses(), !isVirtualCart.value ? getShippingMethods() : null, - getPaymentMethods(), ]).finally(() => { isLoading["shippingMethods"] = false; - isLoading["paymentMethods"] = false; }); }); @@ -660,69 +650,9 @@ const addAddressModalController = useModal(); -
- -

- {{ $t("checkout.paymentMethodLabel") }} -

-
- {{ $t("checkout.selectPaymentMethod") }} -
-
-
-
-
-
-
-
-
-
-
-
- - -
-
+ + +

diff --git a/templates/vue-demo-store/pages/checkout/success/[id]/index.vue b/templates/vue-demo-store/pages/checkout/success/[id]/index.vue index ac0641a8e..629c26ab9 100644 --- a/templates/vue-demo-store/pages/checkout/success/[id]/index.vue +++ b/templates/vue-demo-store/pages/checkout/success/[id]/index.vue @@ -7,6 +7,7 @@ export default {