@laioutr/app-shopware 0.15.3 → 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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +7 -2
- package/dist/runtime/app/components/ShopwareEmbedFrame.d.vue.ts +13 -3
- package/dist/runtime/app/components/ShopwareEmbedFrame.vue +48 -5
- package/dist/runtime/app/components/ShopwareEmbedFrame.vue.d.ts +13 -3
- package/dist/runtime/app/composables/useShopwareEmbedBridge.d.ts +3 -1
- package/dist/runtime/app/composables/useShopwareEmbedBridge.js +7 -3
- package/dist/runtime/app/const/bridge.d.ts +20 -0
- package/dist/runtime/app/const/bridge.js +7 -0
- package/dist/runtime/app/lib/createBridgeHandler.d.ts +3 -0
- package/dist/runtime/app/lib/createBridgeHandler.js +7 -1
- package/dist/runtime/app/lib/createOrderHandoffRefresher.d.ts +24 -0
- package/dist/runtime/app/lib/createOrderHandoffRefresher.js +21 -0
- package/dist/runtime/app/lib/parseBridgeMessage.js +2 -1
- package/dist/runtime/app/sections/SectionShopwareCheckout.vue +42 -4
- package/dist/runtime/server/const/checkout.d.ts +1 -1
- package/dist/runtime/server/const/checkout.js +10 -2
- package/dist/runtime/server/orchestr/product/base.resolver.js +13 -10
- package/dist/runtime/server/orchestr/product/byCategoryId.query.js +1 -1
- package/dist/runtime/server/orchestr/product/byCategorySlug.query.js +1 -1
- package/dist/runtime/server/orchestr/product/search.query.js +1 -1
- package/dist/runtime/server/orchestr/product/variants.link.js +6 -4
- package/dist/runtime/server/orchestr-helper/requestedFields.d.ts +1 -3
- package/dist/runtime/server/orchestr-helper/requestedFields.js +39 -42
- package/dist/runtime/server/routes/checkout.js +12 -1
- package/dist/runtime/server/routes/order-handoff.post.d.ts +14 -0
- package/dist/runtime/server/routes/order-handoff.post.js +33 -0
- package/dist/runtime/server/shopware-helper/cartMapper.js +4 -1
- package/dist/runtime/server/shopware-helper/facetMapper.d.ts +4 -3
- package/dist/runtime/server/shopware-helper/facetMapper.js +4 -3
- package/dist/runtime/server/shopware-helper/fetchAllProductVariants.d.ts +2 -1
- package/dist/runtime/server/shopware-helper/fetchAllProductVariants.js +39 -11
- package/dist/runtime/server/shopware-helper/resolveCheckout.d.ts +2 -0
- package/dist/runtime/server/shopware-helper/resolveCheckout.js +4 -3
- package/dist/runtime/server/shopware-helper/sessionHandoff.d.ts +6 -0
- package/dist/runtime/server/shopware-helper/sessionHandoff.js +6 -1
- package/dist/runtime/shared/const/checkout.d.ts +33 -0
- package/dist/runtime/shared/const/checkout.js +5 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
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.
|
|
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
|
-
|
|
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<
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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="
|
|
88
|
+
:src="frameSrc"
|
|
46
89
|
title="Checkout"
|
|
47
90
|
class="shopware-embed-frame__iframe"
|
|
48
91
|
:style="frameStyle"
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
|
|
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<
|
|
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,
|
|
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 {
|
|
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 {
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2
|
-
export
|
|
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;
|
|
@@ -35,28 +35,31 @@ export default defineShopwareComponentResolver({
|
|
|
35
35
|
const parentIdToDefaultVariantId = passthrough.get(parentIdToDefaultVariantIdToken);
|
|
36
36
|
const variantIds = entityIds.map((id) => parentIdToDefaultVariantId?.[id] ?? id);
|
|
37
37
|
const loadedVariants = passthrough.get(productVariantsToken) ?? [];
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
38
|
+
const missingIds = [.../* @__PURE__ */ new Set([...variantIds, ...entityIds])].filter((id) => !loadedVariants.some((variant) => variant.id === id));
|
|
39
|
+
if (missingIds.length > 0) {
|
|
40
40
|
const response = await fetchAllProducts(context.storefrontClient, {
|
|
41
|
-
productIds:
|
|
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
|
}
|
|
47
|
-
const shopwareProducts =
|
|
48
|
-
const rawVariant = loadedVariants.find((variant) => variant.id ===
|
|
48
|
+
const shopwareProducts = entityIds.map((entityId) => {
|
|
49
|
+
const rawVariant = loadedVariants.find((variant) => variant.id === (parentIdToDefaultVariantId?.[entityId] ?? entityId));
|
|
49
50
|
if (!rawVariant) return void 0;
|
|
50
51
|
return {
|
|
52
|
+
entityId,
|
|
51
53
|
rawVariant,
|
|
52
|
-
rawProduct: loadedVariants.find((variant) => variant.id ===
|
|
54
|
+
rawProduct: loadedVariants.find((variant) => variant.id === entityId) ?? rawVariant
|
|
53
55
|
};
|
|
54
56
|
}).filter((product) => !!product);
|
|
55
|
-
const entities = shopwareProducts.map(({ rawProduct, rawVariant }) => {
|
|
57
|
+
const entities = shopwareProducts.map(({ entityId, rawProduct, rawVariant }) => {
|
|
56
58
|
const mappedCover = rawProduct.cover?.media ? mapMedia(rawVariant.cover?.media ?? rawProduct.cover.media) : FALLBACK_IMAGE;
|
|
57
59
|
return $entity({
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
+
// Answer under the id that was asked for: the queries hand over parent-ids, while the data
|
|
61
|
+
// below is read off the variant they selected.
|
|
62
|
+
id: entityId,
|
|
60
63
|
base: {
|
|
61
64
|
name: swTranslated(rawProduct, "name"),
|
|
62
65
|
slug: entitySlug(rawProduct)
|
|
@@ -29,7 +29,7 @@ export default defineShopwareQuery(
|
|
|
29
29
|
"shipping-free": swBuiltInFilters?.["shipping-free"]
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
|
-
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations);
|
|
32
|
+
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations, context.swCurrency);
|
|
33
33
|
const availableSortings = mapShopwareSortingToOrchestr(response.data.availableSortings);
|
|
34
34
|
const parentIdToDefaultVariantId = Object.fromEntries(
|
|
35
35
|
response.data.elements.map((product) => [product.parentId ?? product.id, product.id])
|
|
@@ -36,7 +36,7 @@ export default defineShopwareQuery(
|
|
|
36
36
|
"shipping-free": swBuiltInFilters?.["shipping-free"]
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
|
-
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations);
|
|
39
|
+
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations, context.swCurrency);
|
|
40
40
|
const availableSortings = mapShopwareSortingToOrchestr(response.data.availableSortings);
|
|
41
41
|
const parentIdToDefaultVariantId = Object.fromEntries(
|
|
42
42
|
response.data.elements.map((product) => [product.parentId ?? product.id, product.id])
|
|
@@ -26,7 +26,7 @@ export default defineShopwareQuery(ProductSearchQuery, async ({ context, input,
|
|
|
26
26
|
"shipping-free": swBuiltInFilters?.["shipping-free"]
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
|
-
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations);
|
|
29
|
+
const availableFilters = mapShopwareAggregationToAvailableFilters(response.data.aggregations, context.swCurrency);
|
|
30
30
|
const availableSortings = mapShopwareSortingToOrchestr(response.data.availableSortings ?? []);
|
|
31
31
|
const parentIdToDefaultVariantId = Object.fromEntries(
|
|
32
32
|
response.data.elements.map((product) => [product.parentId ?? product.id, product.id])
|
|
@@ -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
|
|
15
|
+
const variants = allVariants.filter((variant) => variant.parentId === productId);
|
|
15
16
|
return {
|
|
16
17
|
sourceId: productId,
|
|
17
|
-
// A
|
|
18
|
-
|
|
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 {
|
|
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
|
+
});
|
|
@@ -40,6 +40,7 @@ export const mapCartItem = (lineItem, currency) => {
|
|
|
40
40
|
const listPrice = price?.listPrice?.price;
|
|
41
41
|
const parentId = payload.parentId ?? lineItem.referencedId ?? lineItem.id;
|
|
42
42
|
const slug = getEntitySeoSlug(payload) ?? createFallbackSlug(lineItem.label ?? "", lineItem.referencedId ?? lineItem.id);
|
|
43
|
+
const cover = lineItem.cover;
|
|
43
44
|
const availableStock = payload.availableStock;
|
|
44
45
|
const inStock = availableStock === void 0 ? payload.available !== false : availableStock > 0;
|
|
45
46
|
return {
|
|
@@ -48,8 +49,10 @@ export const mapCartItem = (lineItem, currency) => {
|
|
|
48
49
|
quantity: lineItem.quantity,
|
|
49
50
|
title: lineItem.label ?? "",
|
|
50
51
|
code: payload.productNumber,
|
|
52
|
+
// The generated schema types `cover` as ProductMedia, but the store-api serializes
|
|
53
|
+
// the media entity directly (core sets `LineItem::$cover` to the ProductMedia's media).
|
|
51
54
|
// Cart covers are product images (never video), so the narrowing cast is safe.
|
|
52
|
-
cover:
|
|
55
|
+
cover: cover ? mapMedia(cover) : void 0,
|
|
53
56
|
link: {
|
|
54
57
|
type: "reference",
|
|
55
58
|
reference: { type: "Product", id: parentId, slug }
|
|
@@ -2,15 +2,16 @@ import { AvailableFilter, QueryWireRequestFilter } from '#orchestr/types';
|
|
|
2
2
|
export type ShopwareAggregationKey = 'price' | 'shipping-free' | 'manufacturer';
|
|
3
3
|
export interface ShopwareAggregation {
|
|
4
4
|
name: string;
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
/** A decimal, serialized as a string on some instances and as a number on others. */
|
|
6
|
+
min?: number | string;
|
|
7
|
+
max?: number | string;
|
|
7
8
|
entities?: Array<{
|
|
8
9
|
id: string;
|
|
9
10
|
name: string;
|
|
10
11
|
}>;
|
|
11
12
|
}
|
|
12
13
|
export type ShopwareAggregations = Record<ShopwareAggregationKey, ShopwareAggregation>;
|
|
13
|
-
export declare const mapShopwareAggregationToAvailableFilters: (facets: ShopwareAggregations) => AvailableFilter[];
|
|
14
|
+
export declare const mapShopwareAggregationToAvailableFilters: (facets: ShopwareAggregations, currency: string) => AvailableFilter[];
|
|
14
15
|
export declare const mapSelectedFiltersToShopwareFilters: (filters: QueryWireRequestFilter, currency: string) => {
|
|
15
16
|
swFilters: ({
|
|
16
17
|
field: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isRangeFilter } from "#orchestr/types";
|
|
2
2
|
import { Money } from "@screeny05/ts-money";
|
|
3
|
-
|
|
3
|
+
const toMoney = (decimal, currency) => Money.fromDecimal(Number(decimal ?? 0), currency, Math.round);
|
|
4
|
+
export const mapShopwareAggregationToAvailableFilters = (facets, currency) => {
|
|
4
5
|
const filters = [];
|
|
5
6
|
const listFacets = ["manufacturer"];
|
|
6
7
|
const rangeFacets = ["price"];
|
|
@@ -21,8 +22,8 @@ export const mapShopwareAggregationToAvailableFilters = (facets) => {
|
|
|
21
22
|
id: facetObj.name,
|
|
22
23
|
label: facetObj.name,
|
|
23
24
|
type: "range",
|
|
24
|
-
min:
|
|
25
|
-
max: facetObj.max
|
|
25
|
+
min: toMoney(facetObj.min, currency),
|
|
26
|
+
max: toMoney(facetObj.max, currency)
|
|
26
27
|
});
|
|
27
28
|
} else if (booleanFacets.includes(facetKey)) {
|
|
28
29
|
filters.push({
|
|
@@ -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
|
-
|
|
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(
|
|
8
|
-
const response = await
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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";
|