@laioutr/app-shopware 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +7 -2
  3. package/dist/runtime/app/components/ShopwareEmbedFrame.d.vue.ts +13 -3
  4. package/dist/runtime/app/components/ShopwareEmbedFrame.vue +48 -5
  5. package/dist/runtime/app/components/ShopwareEmbedFrame.vue.d.ts +13 -3
  6. package/dist/runtime/app/composables/useShopwareEmbedBridge.d.ts +3 -1
  7. package/dist/runtime/app/composables/useShopwareEmbedBridge.js +7 -3
  8. package/dist/runtime/app/const/bridge.d.ts +20 -0
  9. package/dist/runtime/app/const/bridge.js +7 -0
  10. package/dist/runtime/app/lib/createBridgeHandler.d.ts +3 -0
  11. package/dist/runtime/app/lib/createBridgeHandler.js +7 -1
  12. package/dist/runtime/app/lib/createOrderHandoffRefresher.d.ts +24 -0
  13. package/dist/runtime/app/lib/createOrderHandoffRefresher.js +21 -0
  14. package/dist/runtime/app/lib/parseBridgeMessage.js +2 -1
  15. package/dist/runtime/app/sections/SectionShopwareCheckout.vue +42 -4
  16. package/dist/runtime/server/const/checkout.d.ts +1 -1
  17. package/dist/runtime/server/const/checkout.js +10 -2
  18. package/dist/runtime/server/orchestr/product/base.resolver.js +2 -1
  19. package/dist/runtime/server/orchestr/product/variants.link.js +6 -4
  20. package/dist/runtime/server/orchestr-helper/requestedFields.d.ts +1 -3
  21. package/dist/runtime/server/orchestr-helper/requestedFields.js +39 -42
  22. package/dist/runtime/server/routes/checkout.js +12 -1
  23. package/dist/runtime/server/routes/order-handoff.post.d.ts +14 -0
  24. package/dist/runtime/server/routes/order-handoff.post.js +33 -0
  25. package/dist/runtime/server/shopware-helper/fetchAllProductVariants.d.ts +2 -1
  26. package/dist/runtime/server/shopware-helper/fetchAllProductVariants.js +39 -11
  27. package/dist/runtime/server/shopware-helper/resolveCheckout.d.ts +2 -0
  28. package/dist/runtime/server/shopware-helper/resolveCheckout.js +4 -3
  29. package/dist/runtime/server/shopware-helper/sessionHandoff.d.ts +6 -0
  30. package/dist/runtime/server/shopware-helper/sessionHandoff.js +6 -1
  31. package/dist/runtime/shared/const/checkout.d.ts +33 -0
  32. package/dist/runtime/shared/const/checkout.js +5 -0
  33. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laioutr/app-shopware",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "configKey": "@laioutr/app-shopware",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.1",
package/dist/module.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { defineNuxtModule, createResolver, addServerHandler, addPlugin, installModule } from '@nuxt/kit';
2
2
  import { defu } from 'defu';
3
- import { CHECKOUT_ENDPOINT_PATH, ADOPT_SESSION_ENDPOINT_PATH } from '../dist/runtime/server/const/checkout.js';
3
+ import { CHECKOUT_ENDPOINT_PATH, ADOPT_SESSION_ENDPOINT_PATH, ORDER_HANDOFF_ENDPOINT_PATH } from '../dist/runtime/server/const/checkout.js';
4
4
  import { registerLaioutrApp } from '@laioutr-core/kit';
5
5
 
6
6
  const name = "@laioutr/app-shopware";
7
- const version = "0.15.4";
7
+ const version = "0.16.0";
8
8
 
9
9
  const module$1 = defineNuxtModule({
10
10
  meta: {
@@ -36,6 +36,11 @@ const module$1 = defineNuxtModule({
36
36
  method: "post",
37
37
  handler: resolveRuntimeModule("./server/routes/adopt-session.post")
38
38
  });
39
+ addServerHandler({
40
+ route: ORDER_HANDOFF_ENDPOINT_PATH,
41
+ method: "post",
42
+ handler: resolveRuntimeModule("./server/routes/order-handoff.post")
43
+ });
39
44
  await registerLaioutrApp({
40
45
  name,
41
46
  version,
@@ -1,14 +1,24 @@
1
- declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
1
+ type __VLS_Props = {
2
+ /** Absolute URL of the page a completed order lands on. */
3
+ finishUrl?: string;
4
+ /** Absolute URL of this checkout page, used to re-frame a failed payment's retry. */
5
+ checkoutUrl?: string;
6
+ /** Order whose payment failed, re-framed on the storefront's order-edit page. */
7
+ retryOrderId?: string;
8
+ };
9
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {
10
+ refreshHandoff: () => void;
11
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
2
12
  "checkout-finish": (orderId: string) => any;
3
13
  "auth-changed": (payload: {
4
14
  from: string;
5
15
  code?: string;
6
16
  }) => any;
7
- }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
18
  "onCheckout-finish"?: ((orderId: string) => any) | undefined;
9
19
  "onAuth-changed"?: ((payload: {
10
20
  from: string;
11
21
  code?: string;
12
22
  }) => any) | undefined;
13
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
23
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
24
  export default _default;
@@ -1,21 +1,56 @@
1
1
  <script setup>
2
- import { computed, ref, useRuntimeConfig } from "#imports";
3
- import { CHECKOUT_ENDPOINT_PATH } from "../../shared/const/checkout";
2
+ import { computed, onBeforeUnmount, ref, useRuntimeConfig } from "#imports";
3
+ import {
4
+ CHECKOUT_CONFIRM_ROUTE,
5
+ CHECKOUT_ENDPOINT_PATH,
6
+ CHECKOUT_RETRY_ROUTE,
7
+ ORDER_HANDOFF_ENDPOINT_PATH,
8
+ RETRY_ORDER_QUERY_KEY
9
+ } from "../../shared/const/checkout";
4
10
  import { useShopwareEmbedBridge } from "../composables/useShopwareEmbedBridge";
11
+ import { createOrderHandoffRefresher } from "../lib/createOrderHandoffRefresher";
5
12
  const emit = defineEmits(["checkout-finish", "auth-changed"]);
13
+ const props = defineProps({
14
+ finishUrl: { type: String, required: false },
15
+ checkoutUrl: { type: String, required: false },
16
+ retryOrderId: { type: String, required: false }
17
+ });
6
18
  const storefrontOrigin = computed(() => useRuntimeConfig().public["@laioutr/app-shopware"]?.storefrontOrigin ?? "");
7
19
  const frameRef = ref(null);
8
20
  const height = ref();
9
21
  const loaded = ref(false);
22
+ const handoff = createOrderHandoffRefresher({
23
+ mint: async () => {
24
+ try {
25
+ const response = await $fetch(ORDER_HANDOFF_ENDPOINT_PATH, {
26
+ method: "POST",
27
+ body: { finishUrl: props.finishUrl, checkoutUrl: props.checkoutUrl }
28
+ });
29
+ return response.code;
30
+ } catch {
31
+ return null;
32
+ }
33
+ },
34
+ deliver: (code) => sendOrderHandoff(code)
35
+ });
10
36
  let hasShownFramePage = false;
11
- const onFramePageLoaded = () => {
37
+ let handoffActive = false;
38
+ const onFramePageLoaded = (payload) => {
12
39
  loaded.value = true;
40
+ const canReturn = Boolean(props.finishUrl) || payload.returnFallback;
41
+ const submitsAnOrder = payload.route === CHECKOUT_CONFIRM_ROUTE || payload.route === CHECKOUT_RETRY_ROUTE;
42
+ handoffActive = submitsAnOrder && canReturn;
43
+ if (handoffActive) {
44
+ handoff.start();
45
+ } else {
46
+ handoff.stop();
47
+ }
13
48
  if (hasShownFramePage) {
14
49
  window.scrollTo({ top: 0, left: 0 });
15
50
  }
16
51
  hasShownFramePage = true;
17
52
  };
18
- const { sendInit } = useShopwareEmbedBridge(frameRef, {
53
+ const { sendInit, sendOrderHandoff } = useShopwareEmbedBridge(frameRef, {
19
54
  storefrontOrigin: storefrontOrigin.value,
20
55
  onResize: (value) => height.value = value,
21
56
  onPageLoaded: onFramePageLoaded,
@@ -23,10 +58,18 @@ const { sendInit } = useShopwareEmbedBridge(frameRef, {
23
58
  onAuthChanged: (payload) => emit("auth-changed", payload)
24
59
  // laioutr:pw-recovery is received but unused in v1.
25
60
  });
61
+ const refreshHandoff = () => {
62
+ if (handoffActive) handoff.start();
63
+ };
64
+ defineExpose({ refreshHandoff });
65
+ onBeforeUnmount(() => handoff.stop());
26
66
  const onFrameLoad = () => {
27
67
  loaded.value = true;
28
68
  sendInit();
29
69
  };
70
+ const frameSrc = computed(
71
+ () => props.retryOrderId ? `${CHECKOUT_ENDPOINT_PATH}?${RETRY_ORDER_QUERY_KEY}=${encodeURIComponent(props.retryOrderId)}` : CHECKOUT_ENDPOINT_PATH
72
+ );
30
73
  const frameStyle = computed(() => ({ height: height.value ? `${height.value}px` : "600px" }));
31
74
  </script>
32
75
 
@@ -42,7 +85,7 @@ const frameStyle = computed(() => ({ height: height.value ? `${height.value}px`
42
85
  </div>
43
86
  <iframe
44
87
  ref="frameRef"
45
- :src="CHECKOUT_ENDPOINT_PATH"
88
+ :src="frameSrc"
46
89
  title="Checkout"
47
90
  class="shopware-embed-frame__iframe"
48
91
  :style="frameStyle"
@@ -1,14 +1,24 @@
1
- declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
1
+ type __VLS_Props = {
2
+ /** Absolute URL of the page a completed order lands on. */
3
+ finishUrl?: string;
4
+ /** Absolute URL of this checkout page, used to re-frame a failed payment's retry. */
5
+ checkoutUrl?: string;
6
+ /** Order whose payment failed, re-framed on the storefront's order-edit page. */
7
+ retryOrderId?: string;
8
+ };
9
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {
10
+ refreshHandoff: () => void;
11
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
2
12
  "checkout-finish": (orderId: string) => any;
3
13
  "auth-changed": (payload: {
4
14
  from: string;
5
15
  code?: string;
6
16
  }) => any;
7
- }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
18
  "onCheckout-finish"?: ((orderId: string) => any) | undefined;
9
19
  "onAuth-changed"?: ((payload: {
10
20
  from: string;
11
21
  code?: string;
12
22
  }) => any) | undefined;
13
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
23
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
24
  export default _default;
@@ -17,8 +17,10 @@ export type UseShopwareEmbedBridgeOptions = {
17
17
  *
18
18
  * Returns `sendInit` — call it on the iframe's `load` event to proactively complete the
19
19
  * handshake, since the plugin's one-shot `ready` can fire before this listener attaches
20
- * (the SSR iframe loads before Vue hydrates).
20
+ * (the SSR iframe loads before Vue hydrates) — and `sendOrderHandoff`, which hands the
21
+ * storefront a freshly minted code for its top-level order submit.
21
22
  */
22
23
  export declare const useShopwareEmbedBridge: (frameRef: Ref<HTMLIFrameElement | null>, options: UseShopwareEmbedBridgeOptions) => {
23
24
  sendInit: () => void;
25
+ sendOrderHandoff: (code: string) => void;
24
26
  };
@@ -1,11 +1,15 @@
1
1
  import { onBeforeUnmount, onMounted } from "vue";
2
- import { buildInitMessage } from "../const/bridge.js";
2
+ import {
3
+ buildInitMessage,
4
+ buildOrderHandoffMessage
5
+ } from "../const/bridge.js";
3
6
  import { createBridgeHandler } from "../lib/createBridgeHandler.js";
4
7
  export const useShopwareEmbedBridge = (frameRef, options) => {
5
- const { handleMessage, sendInit } = createBridgeHandler({
8
+ const { handleMessage, sendInit, sendOrderHandoff } = createBridgeHandler({
6
9
  getFrameWindow: () => frameRef.value?.contentWindow ?? null,
7
10
  storefrontOrigin: options.storefrontOrigin,
8
11
  postInit: (frameWindow, targetOrigin) => frameWindow.postMessage(buildInitMessage(), targetOrigin),
12
+ postOrderHandoff: (frameWindow, targetOrigin, code) => frameWindow.postMessage(buildOrderHandoffMessage(code), targetOrigin),
9
13
  onResize: options.onResize,
10
14
  onPageLoaded: options.onPageLoaded,
11
15
  onCheckoutFinish: options.onCheckoutFinish,
@@ -18,5 +22,5 @@ export const useShopwareEmbedBridge = (frameRef, options) => {
18
22
  sendInit();
19
23
  });
20
24
  onBeforeUnmount(() => window.removeEventListener("message", listener));
21
- return { sendInit };
25
+ return { sendInit, sendOrderHandoff };
22
26
  };
@@ -10,6 +10,15 @@ export declare const BRIDGE_SOURCE = "laioutr-shopware";
10
10
  export declare const BRIDGE_VERSION = 1;
11
11
  /** Handshake reply this (parent) frame posts to pin the storefront origin. */
12
12
  export declare const BRIDGE_INIT_TYPE = "laioutr:init";
13
+ /**
14
+ * Message this (parent) frame posts to hand the storefront a fresh single-use handoff code.
15
+ *
16
+ * The storefront's confirm form submits into the top-level window, which carries no storefront
17
+ * session of its own when the two sit on different registrable domains. The code travels in the
18
+ * form so the storefront can install one before the order is placed. Codes expire in about a
19
+ * minute, so this is posted repeatedly while the frame sits on the confirm page.
20
+ */
21
+ export declare const BRIDGE_ORDER_HANDOFF_TYPE = "laioutr:order-handoff";
13
22
  /** Messages the storefront bridge sends to this (parent) frame. */
14
23
  export type BridgeInboundMessage = {
15
24
  type: 'laioutr:ready';
@@ -26,6 +35,8 @@ export type BridgeInboundMessage = {
26
35
  route: string | null;
27
36
  navigationId: string | null;
28
37
  salesChannelId: string | null;
38
+ /** Whether the storefront has a configured return URL of its own to fall back on. */
39
+ returnFallback: boolean;
29
40
  };
30
41
  } | {
31
42
  type: 'laioutr:checkout-finish';
@@ -61,3 +72,12 @@ export declare const buildInitMessage: () => {
61
72
  type: string;
62
73
  payload: {};
63
74
  };
75
+ /** A freshly minted handoff code, posted to the pinned storefront origin. */
76
+ export declare const buildOrderHandoffMessage: (code: string) => {
77
+ source: string;
78
+ version: number;
79
+ type: string;
80
+ payload: {
81
+ code: string;
82
+ };
83
+ };
@@ -1,9 +1,16 @@
1
1
  export const BRIDGE_SOURCE = "laioutr-shopware";
2
2
  export const BRIDGE_VERSION = 1;
3
3
  export const BRIDGE_INIT_TYPE = "laioutr:init";
4
+ export const BRIDGE_ORDER_HANDOFF_TYPE = "laioutr:order-handoff";
4
5
  export const buildInitMessage = () => ({
5
6
  source: BRIDGE_SOURCE,
6
7
  version: BRIDGE_VERSION,
7
8
  type: BRIDGE_INIT_TYPE,
8
9
  payload: {}
9
10
  });
11
+ export const buildOrderHandoffMessage = (code) => ({
12
+ source: BRIDGE_SOURCE,
13
+ version: BRIDGE_VERSION,
14
+ type: BRIDGE_ORDER_HANDOFF_TYPE,
15
+ payload: { code }
16
+ });
@@ -12,6 +12,8 @@ export type CreateBridgeHandlerOptions = {
12
12
  storefrontOrigin: string;
13
13
  /** Post the contentless handshake reply to the (validated) origin. */
14
14
  postInit: (frameWindow: Window, targetOrigin: string) => void;
15
+ /** Post a freshly minted order-handoff code to the (validated) origin. */
16
+ postOrderHandoff: (frameWindow: Window, targetOrigin: string, code: string) => void;
15
17
  onResize?: (height: number) => void;
16
18
  onPageLoaded?: (payload: BridgePageLoadedPayload) => void;
17
19
  onCheckoutFinish?: (orderId: string) => void;
@@ -41,4 +43,5 @@ export type CreateBridgeHandlerOptions = {
41
43
  export declare const createBridgeHandler: (options: CreateBridgeHandlerOptions) => {
42
44
  handleMessage: (event: BridgeMessageEvent) => void;
43
45
  sendInit: () => void;
46
+ sendOrderHandoff: (code: string) => void;
44
47
  };
@@ -7,6 +7,12 @@ export const createBridgeHandler = (options) => {
7
7
  options.postInit(frameWindow, options.storefrontOrigin);
8
8
  }
9
9
  };
10
+ const sendOrderHandoff = (code) => {
11
+ const frameWindow = options.getFrameWindow();
12
+ if (frameWindow && options.storefrontOrigin) {
13
+ options.postOrderHandoff(frameWindow, options.storefrontOrigin, code);
14
+ }
15
+ };
10
16
  const handleMessage = (event) => {
11
17
  const frameWindow = options.getFrameWindow();
12
18
  if (!frameWindow || event.source !== frameWindow) return;
@@ -36,5 +42,5 @@ export const createBridgeHandler = (options) => {
36
42
  message;
37
43
  }
38
44
  };
39
- return { handleMessage, sendInit };
45
+ return { handleMessage, sendInit, sendOrderHandoff };
40
46
  };
@@ -0,0 +1,24 @@
1
+ export type CreateOrderHandoffRefresherOptions = {
2
+ /** Mint a fresh single-use code, or resolve `null` when the mint failed. */
3
+ mint: () => Promise<string | null>;
4
+ /** Hand a freshly minted code to the storefront frame. */
5
+ deliver: (code: string) => void;
6
+ /** Overrides the re-mint cadence; must stay under the storefront's code lifetime. */
7
+ intervalMs?: number;
8
+ };
9
+ /**
10
+ * Keeps a fresh order-handoff code in the storefront frame for as long as the shopper sits on
11
+ * the confirm page.
12
+ *
13
+ * A code cannot be minted at submit time: minting is a round trip, and awaiting it inside the
14
+ * click handler outlives the transient user activation the top-level form submit depends on.
15
+ * Minting once on page load does not work either, since a shopper reading the terms outlives
16
+ * the code. So mint on a cadence and let the frame hold the newest one.
17
+ *
18
+ * Failures are swallowed by design — the frame keeps the previous code, and leaves the form
19
+ * submitting in-frame if it never received one.
20
+ */
21
+ export declare const createOrderHandoffRefresher: (options: CreateOrderHandoffRefresherOptions) => {
22
+ start: () => void;
23
+ stop: () => void;
24
+ };
@@ -0,0 +1,21 @@
1
+ const DEFAULT_INTERVAL_MS = 4e4;
2
+ export const createOrderHandoffRefresher = (options) => {
3
+ let timer = null;
4
+ const tick = () => {
5
+ options.mint().then((code) => {
6
+ if (code) options.deliver(code);
7
+ }).catch(() => {
8
+ });
9
+ };
10
+ const stop = () => {
11
+ if (!timer) return;
12
+ clearInterval(timer);
13
+ timer = null;
14
+ };
15
+ const start = () => {
16
+ stop();
17
+ tick();
18
+ timer = setInterval(tick, options.intervalMs ?? DEFAULT_INTERVAL_MS);
19
+ };
20
+ return { start, stop };
21
+ };
@@ -20,7 +20,8 @@ export const parseBridgeMessage = (data) => {
20
20
  path: payload.path,
21
21
  route: typeof payload.route === "string" ? payload.route : null,
22
22
  navigationId: typeof payload.navigationId === "string" ? payload.navigationId : null,
23
- salesChannelId: typeof payload.salesChannelId === "string" ? payload.salesChannelId : null
23
+ salesChannelId: typeof payload.salesChannelId === "string" ? payload.salesChannelId : null,
24
+ returnFallback: payload.returnFallback === true
24
25
  }
25
26
  } : null;
26
27
  case "laioutr:auth-changed":
@@ -16,6 +16,12 @@ export const definition = defineSection({
16
16
  name: "finishLink",
17
17
  label: "Order Confirmation Page",
18
18
  description: "If set, the customer is redirected here after completing an order, with the order id appended as ?order=. If left empty, Shopware\u2019s own order confirmation page is shown inside the checkout frame instead."
19
+ },
20
+ {
21
+ type: "link",
22
+ name: "checkoutLink",
23
+ label: "Checkout Page",
24
+ description: "The page this section sits on. Used to bring the customer back here when a payment fails or is cancelled, so they can choose another method without leaving the site."
19
25
  }
20
26
  ]
21
27
  }
@@ -24,21 +30,53 @@ export const definition = defineSection({
24
30
  </script>
25
31
 
26
32
  <script setup>
27
- import { defineSection, definitionToProps, linkResolver, navigateTo, refreshNuxtData } from "#imports";
28
- import { ADOPT_SESSION_ENDPOINT_PATH } from "../../shared/const/checkout";
33
+ import {
34
+ computed,
35
+ defineSection,
36
+ definitionToProps,
37
+ linkResolver,
38
+ navigateTo,
39
+ ref,
40
+ refreshNuxtData,
41
+ useRoute
42
+ } from "#imports";
43
+ import { ADOPT_SESSION_ENDPOINT_PATH, RETRY_ORDER_QUERY_KEY } from "../../shared/const/checkout";
29
44
  import ShopwareEmbedFrame from "../components/ShopwareEmbedFrame.vue";
30
45
  const props = defineProps(definitionToProps(definition));
46
+ const route = useRoute();
47
+ const frame = ref(null);
48
+ const toAbsolute = (path) => import.meta.client ? new URL(path, window.location.origin).href : path;
49
+ const finishUrl = computed(() => props.finishLink ? toAbsolute(linkResolver.resolve(props.finishLink)) : void 0);
50
+ const checkoutUrl = computed(
51
+ () => props.checkoutLink ? toAbsolute(linkResolver.resolve(props.checkoutLink)) : void 0
52
+ );
53
+ const retryOrderId = computed(() => {
54
+ const value = route.query[RETRY_ORDER_QUERY_KEY];
55
+ return typeof value === "string" ? value : void 0;
56
+ });
31
57
  const onCheckoutFinish = async (orderId) => {
32
58
  if (!props.finishLink) return;
33
59
  const path = linkResolver.resolve(props.finishLink);
34
60
  await navigateTo({ path, query: { order: orderId } });
35
61
  };
36
62
  const onAuthChanged = async (payload) => {
37
- await $fetch(ADOPT_SESSION_ENDPOINT_PATH, { method: "POST", body: { code: payload.code } });
63
+ try {
64
+ await $fetch(ADOPT_SESSION_ENDPOINT_PATH, { method: "POST", body: { code: payload.code } });
65
+ } catch (cause) {
66
+ console.warn("[app-shopware] could not adopt the storefront session", cause);
67
+ }
38
68
  await refreshNuxtData();
69
+ frame.value?.refreshHandoff();
39
70
  };
40
71
  </script>
41
72
 
42
73
  <template>
43
- <ShopwareEmbedFrame @checkout-finish="onCheckoutFinish" @auth-changed="onAuthChanged" />
74
+ <ShopwareEmbedFrame
75
+ ref="frame"
76
+ :finish-url="finishUrl"
77
+ :checkout-url="checkoutUrl"
78
+ :retry-order-id="retryOrderId"
79
+ @checkout-finish="onCheckoutFinish"
80
+ @auth-changed="onAuthChanged"
81
+ />
44
82
  </template>
@@ -1,4 +1,4 @@
1
- export { ADOPT_SESSION_ENDPOINT_PATH, CHECKOUT_ENDPOINT_PATH } from '../../shared/const/checkout.js';
1
+ export { ADOPT_SESSION_ENDPOINT_PATH, CHECKOUT_ENDPOINT_PATH, CHECKOUT_RETRY_ROUTE, ORDER_HANDOFF_ENDPOINT_PATH, RETRY_FRAME_MARKER_KEY, RETRY_ORDER_QUERY_KEY, } from '../../shared/const/checkout.js';
2
2
  /**
3
3
  * Internal Shopware route the handoff lands on after the storefront session adopts
4
4
  * the cart context. Resolved server-side by the `LaioutrConnector` plugin's
@@ -1,2 +1,10 @@
1
- export { ADOPT_SESSION_ENDPOINT_PATH, CHECKOUT_ENDPOINT_PATH } from "../../shared/const/checkout.js";
2
- export const CHECKOUT_REDIRECT_ROUTE = "frontend.checkout.confirm.page";
1
+ import { CHECKOUT_CONFIRM_ROUTE } from "../../shared/const/checkout.js";
2
+ export {
3
+ ADOPT_SESSION_ENDPOINT_PATH,
4
+ CHECKOUT_ENDPOINT_PATH,
5
+ CHECKOUT_RETRY_ROUTE,
6
+ ORDER_HANDOFF_ENDPOINT_PATH,
7
+ RETRY_FRAME_MARKER_KEY,
8
+ RETRY_ORDER_QUERY_KEY
9
+ } from "../../shared/const/checkout.js";
10
+ export const CHECKOUT_REDIRECT_ROUTE = CHECKOUT_CONFIRM_ROUTE;
@@ -40,7 +40,8 @@ export default defineShopwareComponentResolver({
40
40
  const response = await fetchAllProducts(context.storefrontClient, {
41
41
  productIds: missingIds,
42
42
  loadVariants: false,
43
- resolveCriteria: context.resolveCriteria
43
+ resolveCriteria: context.resolveCriteria,
44
+ maxLimit: context.settings.maxLimit
44
45
  });
45
46
  loadedVariants.push(...response);
46
47
  }
@@ -6,16 +6,18 @@ export default defineShopwareLink(ProductVariantsLink, async ({ entityIds, conte
6
6
  const allVariants = passthrough.get(productVariantsToken) ?? await fetchAllProducts(context.storefrontClient, {
7
7
  productIds: entityIds,
8
8
  loadVariants: true,
9
- resolveCriteria: context.resolveCriteria
9
+ resolveCriteria: context.resolveCriteria,
10
+ maxLimit: context.settings.maxLimit
10
11
  });
11
12
  passthrough.set(productVariantsToken, allVariants);
12
13
  return {
13
14
  links: entityIds.map((productId) => {
14
- const product = allVariants.find((variant) => variant.id === productId);
15
+ const variants = allVariants.filter((variant) => variant.parentId === productId);
15
16
  return {
16
17
  sourceId: productId,
17
- // A products children are its variants. If it has no children, it is a single variant product.
18
- targetIds: product?.children && product?.children.length > 0 ? product?.children.map((child) => child.id) : [productId]
18
+ // A product's variants are the children this sales channel sells. With none of its own, the
19
+ // product is its single variant.
20
+ targetIds: variants.length > 0 ? variants.map((variant) => variant.id) : [productId]
19
21
  };
20
22
  })
21
23
  };
@@ -1,5 +1,3 @@
1
1
  import type { ResolveCriteria, ShopwareCriteria } from '../types/criteria.js';
2
- export declare const resolveProductFields: ({ loadVariants }: {
3
- loadVariants: boolean;
4
- }, resolveCriteria: ResolveCriteria) => Promise<ShopwareCriteria>;
5
2
  export declare const resolveProductVariantFields: (resolveCriteria: ResolveCriteria) => Promise<ShopwareCriteria>;
3
+ export declare const resolveProductFields: (resolveCriteria: ResolveCriteria) => Promise<ShopwareCriteria>;
@@ -1,47 +1,5 @@
1
1
  import { MediaIncludes } from "../const/includes.js";
2
2
  import { mergeIncludes } from "../shopware-helper/criteria.js";
3
- const addAssociation = (name, add, association = {}) => add ? { [name]: association } : {};
4
- export const resolveProductFields = async ({ loadVariants }, resolveCriteria) => {
5
- const variantFields = loadVariants ? await resolveProductVariantFields(resolveCriteria) : void 0;
6
- return resolveCriteria("product", {
7
- associations: {
8
- cover: { associations: { media: {} } },
9
- media: { associations: { media: {} } },
10
- ...addAssociation("children", loadVariants, variantFields ? { associations: variantFields.associations } : {})
11
- },
12
- includes: mergeIncludes(
13
- {
14
- product: [
15
- "id",
16
- "parentId",
17
- "name",
18
- "seoUrls",
19
- "productNumber",
20
- "ean",
21
- "translated",
22
- "manufacturer",
23
- "description",
24
- "cover",
25
- "metaTitle",
26
- "metaDescription",
27
- "cover",
28
- "media",
29
- "minPurchase",
30
- "purchaseSteps",
31
- "maxPurchase",
32
- "calculatedPrice",
33
- "calculatedPrices",
34
- "children",
35
- "ratingAverage",
36
- "productReviews"
37
- ],
38
- product_media: ["id", "mediaId", "media"],
39
- media: MediaIncludes
40
- },
41
- variantFields?.includes ?? {}
42
- )
43
- });
44
- };
45
3
  export const resolveProductVariantFields = async (resolveCriteria) => resolveCriteria("product-variant", {
46
4
  associations: {
47
5
  cover: { associations: { media: {} } },
@@ -86,3 +44,42 @@ export const resolveProductVariantFields = async (resolveCriteria) => resolveCri
86
44
  property_group: ["id", "name", "translated"]
87
45
  }
88
46
  });
47
+ export const resolveProductFields = async (resolveCriteria) => {
48
+ const variantFields = await resolveProductVariantFields(resolveCriteria);
49
+ return resolveCriteria("product", {
50
+ associations: {
51
+ cover: { associations: { media: {} } },
52
+ media: { associations: { media: {} } }
53
+ },
54
+ includes: mergeIncludes(
55
+ {
56
+ product: [
57
+ "id",
58
+ "parentId",
59
+ "name",
60
+ "seoUrls",
61
+ "productNumber",
62
+ "ean",
63
+ "translated",
64
+ "manufacturer",
65
+ "description",
66
+ "cover",
67
+ "metaTitle",
68
+ "metaDescription",
69
+ "cover",
70
+ "media",
71
+ "minPurchase",
72
+ "purchaseSteps",
73
+ "maxPurchase",
74
+ "calculatedPrice",
75
+ "calculatedPrices",
76
+ "ratingAverage",
77
+ "productReviews"
78
+ ],
79
+ product_media: ["id", "mediaId", "media"],
80
+ media: MediaIncludes
81
+ },
82
+ variantFields.includes
83
+ )
84
+ });
85
+ };
@@ -1,5 +1,14 @@
1
1
  import { consola } from "consola";
2
- import { createError, defineEventHandler, getCookie, getRequestURL, sendRedirect, useRuntimeConfig } from "#imports";
2
+ import {
3
+ createError,
4
+ defineEventHandler,
5
+ getCookie,
6
+ getQuery,
7
+ getRequestURL,
8
+ sendRedirect,
9
+ useRuntimeConfig
10
+ } from "#imports";
11
+ import { RETRY_ORDER_QUERY_KEY } from "../const/checkout.js";
3
12
  import { CONTEXT_TOKEN_COOKIE } from "../const/cookieKeys.js";
4
13
  import { bootstrapGuestContextToken } from "../shopware-helper/bootstrapContextToken.js";
5
14
  import { persistContextToken } from "../shopware-helper/persistContextToken.js";
@@ -17,10 +26,12 @@ export default defineEventHandler(async (event) => {
17
26
  log.error("Failed to bootstrap a guest context token", cause);
18
27
  }
19
28
  }
29
+ const retryOrderId = getQuery(event)[RETRY_ORDER_QUERY_KEY];
20
30
  const plan = await resolveCheckout({
21
31
  config,
22
32
  contextToken,
23
33
  origin: getRequestURL(event).origin,
34
+ retryOrderId: typeof retryOrderId === "string" ? retryOrderId : null,
24
35
  mint: mintSessionHandoffCode
25
36
  });
26
37
  if (plan.kind === "error") {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mint a single-use session-handoff code for the embedded checkout's top-level order submit.
3
+ *
4
+ * The confirm form targets `_top` so redirect-based payment providers are never framed, and
5
+ * the storefront route it posts to redeems this code to install a session in the top-level
6
+ * cookie jar before forwarding to Shopware's order endpoint. Codes expire in about a minute,
7
+ * so the section re-mints while the shopper sits on the confirm page rather than once on load.
8
+ *
9
+ * The shopper reaches confirm through the checkout handoff, which bootstraps a context token
10
+ * when none exists — so a missing cookie here means the session was lost, not that the
11
+ * shopper is new. Fail rather than mint against a fresh guest cart the order would not match.
12
+ */
13
+ declare const _default: any;
14
+ export default _default;
@@ -0,0 +1,33 @@
1
+ import { consola } from "consola";
2
+ import { createError, defineEventHandler, getCookie, getRequestURL, readBody, useRuntimeConfig } from "#imports";
3
+ import { CHECKOUT_REDIRECT_ROUTE } from "../const/checkout.js";
4
+ import { CONTEXT_TOKEN_COOKIE } from "../const/cookieKeys.js";
5
+ import { mintSessionHandoffCode } from "../shopware-helper/sessionHandoff.js";
6
+ const log = consola.withTag("shopware/order-handoff");
7
+ export default defineEventHandler(async (event) => {
8
+ const config = useRuntimeConfig()["@laioutr/app-shopware"];
9
+ const contextToken = getCookie(event, CONTEXT_TOKEN_COOKIE);
10
+ if (!contextToken) {
11
+ throw createError({ statusCode: 409, statusMessage: "No cart context to hand off" });
12
+ }
13
+ const origin = getRequestURL(event).origin;
14
+ const body = await readBody(event).catch(() => ({}));
15
+ const finishUrl = typeof body?.finishUrl === "string" ? body.finishUrl : void 0;
16
+ const checkoutUrl = typeof body?.checkoutUrl === "string" ? body.checkoutUrl : void 0;
17
+ try {
18
+ const code = await mintSessionHandoffCode({
19
+ endpoint: config.endpoint,
20
+ accessToken: config.accessToken,
21
+ contextToken,
22
+ loginSuccessCallback: config.checkoutLoginCallbackUrl ?? origin,
23
+ logoutSuccessCallback: config.checkoutLogoutCallbackUrl ?? origin,
24
+ redirectRoute: CHECKOUT_REDIRECT_ROUTE,
25
+ finishSuccessCallback: finishUrl,
26
+ checkoutCallback: checkoutUrl
27
+ });
28
+ return { code };
29
+ } catch (cause) {
30
+ log.error("Failed to mint an order handoff code", cause);
31
+ throw createError({ statusCode: 502, statusMessage: "Order handoff mint failed" });
32
+ }
33
+ });
@@ -1,9 +1,10 @@
1
1
  import type { ResolveCriteria } from '../types/criteria.js';
2
2
  import { StorefrontClient } from '../types/shopware.js';
3
- export declare const fetchAllProducts: (storefrontClient: StorefrontClient, { productIds, loadVariants, resolveCriteria }: {
3
+ export declare const fetchAllProducts: (storefrontClient: StorefrontClient, { productIds, loadVariants, resolveCriteria, maxLimit, }: {
4
4
  productIds: string[];
5
5
  loadVariants: boolean;
6
6
  resolveCriteria: ResolveCriteria;
7
+ maxLimit: number;
7
8
  }) => Promise<{
8
9
  active?: boolean;
9
10
  apiAlias: "product";
@@ -1,17 +1,45 @@
1
1
  import { toRequestCriteria } from "./criteria.js";
2
- import { resolveProductFields } from "../orchestr-helper/requestedFields.js";
3
- export const fetchAllProducts = async (storefrontClient, { productIds, loadVariants, resolveCriteria }) => {
2
+ import { resolveProductFields, resolveProductVariantFields } from "../orchestr-helper/requestedFields.js";
3
+ const readVariants = async (storefrontClient, { parentIds, resolveCriteria, maxLimit }) => {
4
+ const criteria = await resolveProductVariantFields(resolveCriteria);
5
+ const variants = [];
6
+ const seen = /* @__PURE__ */ new Set();
7
+ for (let page = 1; ; page++) {
8
+ const response = await storefrontClient.invoke("readProduct post /product", {
9
+ body: {
10
+ page,
11
+ limit: maxLimit,
12
+ filter: [{ type: "equalsAny", field: "parentId", value: parentIds }],
13
+ ...toRequestCriteria(criteria)
14
+ }
15
+ });
16
+ const elements = response.data.elements ?? [];
17
+ const fresh = elements.filter((element) => element.id && !seen.has(element.id));
18
+ for (const element of fresh) seen.add(element.id);
19
+ variants.push(...fresh);
20
+ if (elements.length < maxLimit || fresh.length === 0) break;
21
+ }
22
+ return variants;
23
+ };
24
+ export const fetchAllProducts = async (storefrontClient, {
25
+ productIds,
26
+ loadVariants,
27
+ resolveCriteria,
28
+ maxLimit
29
+ }) => {
4
30
  if (productIds.length === 0) {
5
31
  return [];
6
32
  }
7
- const criteria = await resolveProductFields({ loadVariants }, resolveCriteria);
8
- const response = await storefrontClient.invoke("readProduct post /product", {
9
- body: {
10
- ids: productIds,
11
- ...toRequestCriteria(criteria)
12
- }
13
- });
33
+ const criteria = await resolveProductFields(resolveCriteria);
34
+ const [response, variants] = await Promise.all([
35
+ storefrontClient.invoke("readProduct post /product", {
36
+ body: {
37
+ ids: productIds,
38
+ ...toRequestCriteria(criteria)
39
+ }
40
+ }),
41
+ loadVariants ? readVariants(storefrontClient, { parentIds: productIds, resolveCriteria, maxLimit }) : []
42
+ ]);
14
43
  const products = response.data.elements ?? [];
15
- const all = [...products.flatMap((product) => product.children ?? []), ...products];
16
- return all;
44
+ return [...variants, ...products];
17
45
  };
@@ -21,6 +21,8 @@ export interface ResolveCheckoutDeps {
21
21
  contextToken: string | null | undefined;
22
22
  /** Request origin used for the login/logout success callbacks. */
23
23
  origin: string;
24
+ /** Order to retry payment for, from the storefront's `retry-order` bounce. */
25
+ retryOrderId?: string | null;
24
26
  /** Injected minter (real: {@link mintSessionHandoffCode}) — keeps this decision logic pure/testable. */
25
27
  mint: (params: MintSessionHandoffParams) => Promise<string>;
26
28
  }
@@ -1,7 +1,7 @@
1
1
  import { buildConnectSessionUrl } from "./checkoutUrl.js";
2
- import { CHECKOUT_REDIRECT_ROUTE } from "../const/checkout.js";
2
+ import { CHECKOUT_REDIRECT_ROUTE, CHECKOUT_RETRY_ROUTE, RETRY_FRAME_MARKER_KEY } from "../const/checkout.js";
3
3
  export const resolveCheckout = async (deps) => {
4
- const { config, contextToken, origin, mint } = deps;
4
+ const { config, contextToken, origin, retryOrderId, mint } = deps;
5
5
  if (!contextToken) {
6
6
  return { kind: "redirect", url: "/" };
7
7
  }
@@ -15,7 +15,8 @@ export const resolveCheckout = async (deps) => {
15
15
  contextToken,
16
16
  loginSuccessCallback: config.checkoutLoginCallbackUrl ?? origin,
17
17
  logoutSuccessCallback: config.checkoutLogoutCallbackUrl ?? origin,
18
- redirectRoute: CHECKOUT_REDIRECT_ROUTE
18
+ redirectRoute: retryOrderId ? CHECKOUT_RETRY_ROUTE : CHECKOUT_REDIRECT_ROUTE,
19
+ ...retryOrderId ? { redirectRouteParams: { orderId: retryOrderId, [RETRY_FRAME_MARKER_KEY]: "1" } } : {}
19
20
  });
20
21
  return { kind: "redirect", url: buildConnectSessionUrl({ storefrontUrl: config.storefrontUrl, code }) };
21
22
  } catch (cause) {
@@ -11,6 +11,12 @@ export interface MintSessionHandoffParams {
11
11
  logoutSuccessCallback: string;
12
12
  /** Internal Shopware route the handoff lands on after redeem. */
13
13
  redirectRoute: string;
14
+ /** Absolute URL of the laioutr page shown after a completed order. */
15
+ finishSuccessCallback?: string;
16
+ /** Absolute URL of the laioutr checkout page, used to re-frame a payment retry. */
17
+ checkoutCallback?: string;
18
+ /** Parameters for `redirectRoute`; the retry route is keyed by `orderId`. */
19
+ redirectRouteParams?: Record<string, string>;
14
20
  }
15
21
  /**
16
22
  * Mint a single-use session-handoff code against the `LaioutrConnector` plugin's
@@ -13,10 +13,15 @@ export const mintSessionHandoffCode = async (params) => {
13
13
  "sw-access-key": params.accessToken,
14
14
  "sw-context-token": params.contextToken
15
15
  },
16
+ // Undefined keys are omitted rather than sent as null, so a plugin build that predates
17
+ // the return trip still accepts the call.
16
18
  body: {
17
19
  "login-success-callback": params.loginSuccessCallback,
18
20
  "logout-success-callback": params.logoutSuccessCallback,
19
- "redirect-route": params.redirectRoute
21
+ "redirect-route": params.redirectRoute,
22
+ ...params.finishSuccessCallback ? { "finish-success-callback": params.finishSuccessCallback } : {},
23
+ ...params.checkoutCallback ? { "checkout-callback": params.checkoutCallback } : {},
24
+ ...params.redirectRouteParams ? { "redirect-route-params": params.redirectRouteParams } : {}
20
25
  }
21
26
  });
22
27
  } catch (err) {
@@ -16,3 +16,36 @@ export declare const CHECKOUT_ENDPOINT_PATH = "/app-shopware/checkout";
16
16
  * `/api/laioutr/*` namespace.
17
17
  */
18
18
  export declare const ADOPT_SESSION_ENDPOINT_PATH = "/app-shopware/adopt-session";
19
+ /**
20
+ * Same-origin route the embedded checkout section POSTs to for a fresh single-use handoff
21
+ * code, which it then hands to the storefront frame.
22
+ *
23
+ * The confirm form submits into the top-level window so redirect-based payment providers are
24
+ * never framed. When the storefront sits on a different registrable domain than laioutr, that
25
+ * top-level request lands in a cookie jar with no storefront session, so the code travels in
26
+ * the form and installs one before the order is placed. Minting stays server-side: the raw
27
+ * `sw-context-token` never reaches the browser.
28
+ */
29
+ export declare const ORDER_HANDOFF_ENDPOINT_PATH = "/app-shopware/order-handoff";
30
+ /**
31
+ * Storefront route name of the checkout confirm page.
32
+ *
33
+ * The section only keeps a handoff code minted while the frame sits on this page: it is the
34
+ * only page whose submit needs one, and a code outlives its mint by about a minute.
35
+ */
36
+ export declare const CHECKOUT_CONFIRM_ROUTE = "frontend.checkout.confirm.page";
37
+ /**
38
+ * Storefront route that can still take payment for an order whose cart is gone. A cancelled or
39
+ * declined payment lands here, and the frame is pointed at it so the retry happens in place.
40
+ */
41
+ export declare const CHECKOUT_RETRY_ROUTE = "frontend.account.edit-order.page";
42
+ /** Query key the storefront uses to hand a failed order back for a retry. */
43
+ export declare const RETRY_ORDER_QUERY_KEY = "retry-order";
44
+ /**
45
+ * Marks the frame's own navigation to the retry route, so the storefront leaves it in place.
46
+ *
47
+ * Without it the storefront cannot tell this apart from a shopper the payment provider bounced
48
+ * to the same route at top level, and would send the frame back to Laioutr — which points the
49
+ * frame at the retry again, forever.
50
+ */
51
+ export declare const RETRY_FRAME_MARKER_KEY = "laioutr-retry";
@@ -1,2 +1,7 @@
1
1
  export const CHECKOUT_ENDPOINT_PATH = "/app-shopware/checkout";
2
2
  export const ADOPT_SESSION_ENDPOINT_PATH = "/app-shopware/adopt-session";
3
+ export const ORDER_HANDOFF_ENDPOINT_PATH = "/app-shopware/order-handoff";
4
+ export const CHECKOUT_CONFIRM_ROUTE = "frontend.checkout.confirm.page";
5
+ export const CHECKOUT_RETRY_ROUTE = "frontend.account.edit-order.page";
6
+ export const RETRY_ORDER_QUERY_KEY = "retry-order";
7
+ export const RETRY_FRAME_MARKER_KEY = "laioutr-retry";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laioutr/app-shopware",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Laioutr integration with Shopware 6",
5
5
  "repository": "github:laioutr/app-shopware",
6
6
  "license": "MIT",