@shopbite-de/storefront 1.18.5 → 1.20.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/.github/workflows/ci.yaml +48 -48
- package/app/components/Category/Listing.vue +41 -95
- package/app/components/Product/Configurator.vue +4 -9
- package/app/components/Product/CrossSelling.vue +7 -50
- package/app/components/Product/Detail.vue +7 -119
- package/app/composables/useCategory.ts +6 -34
- package/app/composables/useCategoryListing.ts +131 -0
- package/app/composables/useProductConfigurator.ts +12 -52
- package/app/composables/useProductCrossSelling.ts +45 -0
- package/app/composables/useProductDetail.ts +62 -0
- package/app/pages/bestellung/bestaetigen.vue +9 -0
- package/app/pages/bestellung/warenkorb.vue +9 -0
- package/app/pages/bestellung/zahlung-versand.vue +9 -0
- package/nuxt.config.ts +9 -4
- package/package.json +1 -1
- package/server/api/category/[categoryId].get.ts +26 -0
- package/server/api/listing/[categoryId].get.ts +107 -0
- package/server/api/product/[productId]/cross-selling.get.ts +31 -0
- package/server/api/product/[productId].get.ts +68 -0
- package/server/api/product/variant.get.ts +99 -0
- package/test/nuxt/useProductConfigurator.test.ts +14 -17
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getListingFilters } from "@shopware/helpers";
|
|
2
|
+
import type { Schemas, operations } from "#shopware";
|
|
3
|
+
import type { NitroFetchRequest } from "nitropack";
|
|
4
|
+
|
|
5
|
+
export type CategoryListingCriteria = NonNullable<
|
|
6
|
+
operations["readProductListingGet get /product-listing/{categoryId}"]["query"]
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
export type ShortcutFilterParam = {
|
|
10
|
+
code: string;
|
|
11
|
+
value: string | string[] | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CRITERIA: CategoryListingCriteria = {
|
|
15
|
+
includes: {
|
|
16
|
+
product: [
|
|
17
|
+
"id",
|
|
18
|
+
"productNumber",
|
|
19
|
+
"name",
|
|
20
|
+
"description",
|
|
21
|
+
"calculatedPrice",
|
|
22
|
+
"translated",
|
|
23
|
+
"properties",
|
|
24
|
+
"propertyIds",
|
|
25
|
+
"sortedProperties",
|
|
26
|
+
"cover",
|
|
27
|
+
],
|
|
28
|
+
property: ["id", "name", "translated", "options"],
|
|
29
|
+
property_group_option: ["id", "name", "translated", "group"],
|
|
30
|
+
product_option: ["id", "groupId", "name", "translated", "group"],
|
|
31
|
+
},
|
|
32
|
+
associations: {
|
|
33
|
+
cover: {
|
|
34
|
+
associations: {
|
|
35
|
+
media: {},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
properties: {
|
|
39
|
+
associations: {
|
|
40
|
+
group: {},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
limit: 100,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function useCategoryListing(
|
|
48
|
+
categoryId: string,
|
|
49
|
+
_defaultCriteria: CategoryListingCriteria = DEFAULT_CRITERIA,
|
|
50
|
+
) {
|
|
51
|
+
const nuxtApp = useNuxtApp();
|
|
52
|
+
|
|
53
|
+
async function fetchListing(
|
|
54
|
+
criteria: CategoryListingCriteria,
|
|
55
|
+
): Promise<Schemas["ProductListingResult"]> {
|
|
56
|
+
// Send only the allowlisted filter params; the server merges them with the
|
|
57
|
+
// fixed includes/associations/limit so clients cannot influence projections.
|
|
58
|
+
const c = criteria as Record<string, unknown>;
|
|
59
|
+
return await $fetch(`/api/listing/${categoryId}` as NitroFetchRequest, {
|
|
60
|
+
query: {
|
|
61
|
+
order: c.order,
|
|
62
|
+
properties: c.properties,
|
|
63
|
+
manufacturer: c.manufacturer,
|
|
64
|
+
query: c.query,
|
|
65
|
+
p: c.p,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const loading = ref(false);
|
|
71
|
+
|
|
72
|
+
const { data: listing, pending } = useLazyAsyncData(
|
|
73
|
+
`listing-${categoryId}`,
|
|
74
|
+
() => fetchListing({}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Suppress skeleton during SSR hydration to prevent hydration mismatch.
|
|
78
|
+
const showSkeleton = computed(() => pending.value && !nuxtApp.isHydrating);
|
|
79
|
+
|
|
80
|
+
const elements = computed(() => listing.value?.elements ?? []);
|
|
81
|
+
const sortingOrders = computed(() => listing.value?.availableSortings);
|
|
82
|
+
const currentSortingOrder = computed(() => listing.value?.sorting);
|
|
83
|
+
const availableFilters = computed(() =>
|
|
84
|
+
getListingFilters(listing.value?.aggregations),
|
|
85
|
+
);
|
|
86
|
+
const currentFilters = computed(() => listing.value?.currentFilters);
|
|
87
|
+
|
|
88
|
+
async function applySearch(criteria: CategoryListingCriteria) {
|
|
89
|
+
loading.value = true;
|
|
90
|
+
try {
|
|
91
|
+
listing.value = await fetchListing(criteria);
|
|
92
|
+
} finally {
|
|
93
|
+
loading.value = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function changeSorting(order: string, query?: CategoryListingCriteria) {
|
|
98
|
+
await applySearch({ ...query, order } as CategoryListingCriteria);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function setFilters(filters: ShortcutFilterParam[]) {
|
|
102
|
+
const filterObj: Record<string, unknown> = {};
|
|
103
|
+
for (const f of filters) {
|
|
104
|
+
filterObj[f.code] = f.value;
|
|
105
|
+
}
|
|
106
|
+
const appliedFilters = {
|
|
107
|
+
query: currentFilters.value?.search,
|
|
108
|
+
manufacturer: currentFilters.value?.manufacturer?.join("|"),
|
|
109
|
+
properties: currentFilters.value?.properties?.join("|"),
|
|
110
|
+
...filterObj,
|
|
111
|
+
};
|
|
112
|
+
await applySearch(appliedFilters as CategoryListingCriteria);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function resetFilters() {
|
|
116
|
+
await applySearch({});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
showSkeleton,
|
|
121
|
+
loading,
|
|
122
|
+
elements,
|
|
123
|
+
sortingOrders,
|
|
124
|
+
currentSortingOrder,
|
|
125
|
+
availableFilters,
|
|
126
|
+
currentFilters,
|
|
127
|
+
changeSorting,
|
|
128
|
+
setFilters,
|
|
129
|
+
resetFilters,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { computed, ref } from "vue";
|
|
2
2
|
import type { Schemas } from "#shopware";
|
|
3
3
|
import {
|
|
4
|
-
useShopwareContext,
|
|
5
4
|
useProductConfigurator as useProductConfiguratorOriginal,
|
|
6
5
|
useProduct,
|
|
7
6
|
} from "@shopware/composables";
|
|
8
7
|
import { getTranslatedProperty } from "@shopware/helpers";
|
|
9
8
|
|
|
10
9
|
export function useProductConfigurator() {
|
|
11
|
-
const { apiClient } = useShopwareContext();
|
|
12
|
-
|
|
13
10
|
const original = useProductConfiguratorOriginal();
|
|
14
11
|
|
|
15
12
|
const { configurator, product } = useProduct();
|
|
@@ -18,7 +15,9 @@ export function useProductConfigurator() {
|
|
|
18
15
|
[key: string]: string;
|
|
19
16
|
}>({});
|
|
20
17
|
const isLoadingOptions = ref(!!product.value.options?.length);
|
|
21
|
-
const parentProductId = computed(
|
|
18
|
+
const parentProductId = computed(
|
|
19
|
+
() => product.value?.parentId ?? product.value?.id,
|
|
20
|
+
);
|
|
22
21
|
const getOptionGroups = computed<Schemas["PropertyGroup"][]>(() => {
|
|
23
22
|
return configurator.value || [];
|
|
24
23
|
});
|
|
@@ -45,57 +44,18 @@ export function useProductConfigurator() {
|
|
|
45
44
|
async function findVariantForSelectedOptions(options?: {
|
|
46
45
|
[code: string]: string;
|
|
47
46
|
}): Promise<Schemas["Product"] | undefined> {
|
|
48
|
-
|
|
49
|
-
{
|
|
50
|
-
type: "equals",
|
|
51
|
-
field: "parentId",
|
|
52
|
-
value: parentProductId.value as string,
|
|
53
|
-
},
|
|
54
|
-
...Object.values(options || selected.value).map(
|
|
55
|
-
(id) =>
|
|
56
|
-
({
|
|
57
|
-
type: "equals",
|
|
58
|
-
field: "optionIds",
|
|
59
|
-
value: id,
|
|
60
|
-
}) as Schemas["EqualsFilter"],
|
|
61
|
-
),
|
|
62
|
-
];
|
|
47
|
+
if (!parentProductId.value) return undefined;
|
|
63
48
|
try {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"id",
|
|
71
|
-
"name",
|
|
72
|
-
"description",
|
|
73
|
-
"translated",
|
|
74
|
-
"productNumber",
|
|
75
|
-
"options",
|
|
76
|
-
"properties",
|
|
77
|
-
"calculatedPrice",
|
|
78
|
-
"translated",
|
|
79
|
-
],
|
|
80
|
-
product_option: ["id", "groupId", "name", "translated", "group"],
|
|
81
|
-
property: ["id", "name", "translated", "options"],
|
|
82
|
-
property_group_option: ["id", "name", "translated", "group"],
|
|
83
|
-
},
|
|
84
|
-
associations: {
|
|
85
|
-
options: {
|
|
86
|
-
associations: {
|
|
87
|
-
group: {},
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
properties: {
|
|
91
|
-
associations: {
|
|
92
|
-
group: {},
|
|
93
|
-
},
|
|
94
|
-
},
|
|
49
|
+
const result = await $fetch<Schemas["Product"] | null>(
|
|
50
|
+
"/api/product/variant",
|
|
51
|
+
{
|
|
52
|
+
query: {
|
|
53
|
+
parentId: parentProductId.value,
|
|
54
|
+
optionIds: Object.values(options || selected.value),
|
|
95
55
|
},
|
|
96
56
|
},
|
|
97
|
-
|
|
98
|
-
return
|
|
57
|
+
);
|
|
58
|
+
return result ?? undefined;
|
|
99
59
|
} catch (e) {
|
|
100
60
|
console.error("SwProductDetails:findVariantForSelectedOptions", e);
|
|
101
61
|
return undefined;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Schemas } from "#shopware";
|
|
2
|
+
import type { AssociationItem } from "~/types/Association";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CURRENCY = "EUR";
|
|
5
|
+
const DEFAULT_LOCALE = "de-DE";
|
|
6
|
+
const DEFAULT_ICON = "i-lucide-plus";
|
|
7
|
+
|
|
8
|
+
function mapAssociationToItems(
|
|
9
|
+
associations: Schemas["CrossSellingElement"][],
|
|
10
|
+
getFormattedPrice: (price: number) => string,
|
|
11
|
+
): AssociationItem[] {
|
|
12
|
+
return associations.map((crossSellingElement) => ({
|
|
13
|
+
label: crossSellingElement.crossSelling.name,
|
|
14
|
+
products: crossSellingElement.products.map(
|
|
15
|
+
(product: Schemas["Product"]) => ({
|
|
16
|
+
label: product.name,
|
|
17
|
+
value: product.id,
|
|
18
|
+
price: getFormattedPrice(product.calculatedPrice.unitPrice),
|
|
19
|
+
icon: DEFAULT_ICON,
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useProductCrossSelling(getProductId: () => string) {
|
|
26
|
+
const { getFormattedPrice } = usePrice({
|
|
27
|
+
currencyCode: DEFAULT_CURRENCY,
|
|
28
|
+
localeCode: DEFAULT_LOCALE,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { data, pending: isAssociationsLoading } = useFetch<
|
|
32
|
+
Schemas["CrossSellingElement"][]
|
|
33
|
+
>(() => `/api/product/${getProductId()}/cross-selling`, {
|
|
34
|
+
key: () => `cross-selling-${getProductId()}`,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const associationItems = computed(() =>
|
|
38
|
+
mapAssociationToItems(data.value ?? [], getFormattedPrice),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
associationItems,
|
|
43
|
+
isAssociationsLoading,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Schemas } from "#shopware";
|
|
2
|
+
import type { AssociationItemProduct } from "~/types/Association";
|
|
3
|
+
|
|
4
|
+
export function useProductDetail(getProductId: () => string) {
|
|
5
|
+
const { trackProductView } = useTrackEvent();
|
|
6
|
+
|
|
7
|
+
const { data: productDetails, pending } = useFetch<{
|
|
8
|
+
product: Schemas["Product"];
|
|
9
|
+
configurator?: Schemas["PropertyGroup"][];
|
|
10
|
+
}>(() => `/api/product/${getProductId()}`, {
|
|
11
|
+
key: () => `product-${getProductId() ?? "none"}`,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
selectedProduct,
|
|
16
|
+
selectedQuantity,
|
|
17
|
+
isLoading,
|
|
18
|
+
addToCart,
|
|
19
|
+
setSelectedProduct,
|
|
20
|
+
setSelectedExtras,
|
|
21
|
+
setDeselectedIngredients,
|
|
22
|
+
} = useAddToCart();
|
|
23
|
+
|
|
24
|
+
watch(
|
|
25
|
+
() => productDetails.value?.product,
|
|
26
|
+
(product) => {
|
|
27
|
+
if (product) {
|
|
28
|
+
setSelectedProduct(product);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{ immediate: true },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
watch(
|
|
35
|
+
productDetails,
|
|
36
|
+
() => {
|
|
37
|
+
if (!productDetails.value) return;
|
|
38
|
+
trackProductView(productDetails.value.product);
|
|
39
|
+
},
|
|
40
|
+
{ immediate: true },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const onExtrasSelected = (extras: AssociationItemProduct[]) => {
|
|
44
|
+
setSelectedExtras(extras);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const onIngredientsDeselected = (deselected: string[]) => {
|
|
48
|
+
setDeselectedIngredients(deselected);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
productDetails,
|
|
53
|
+
pending,
|
|
54
|
+
selectedProduct,
|
|
55
|
+
selectedQuantity,
|
|
56
|
+
isLoading,
|
|
57
|
+
addToCart,
|
|
58
|
+
setSelectedProduct,
|
|
59
|
+
onExtrasSelected,
|
|
60
|
+
onIngredientsDeselected,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const {
|
|
3
|
+
public: { site },
|
|
4
|
+
} = useRuntimeConfig();
|
|
5
|
+
|
|
6
|
+
useSeoMeta({
|
|
7
|
+
title: `Prüfen & Bestellen | ${site.name}`,
|
|
8
|
+
robots: "noindex, nofollow",
|
|
9
|
+
});
|
|
10
|
+
|
|
2
11
|
const { isLoggedIn, isGuestSession } = useUser();
|
|
3
12
|
const { setStep } = useCheckoutStore();
|
|
4
13
|
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const {
|
|
3
|
+
public: { site },
|
|
4
|
+
} = useRuntimeConfig();
|
|
5
|
+
|
|
6
|
+
useSeoMeta({
|
|
7
|
+
title: `Warenkorb | ${site.name}`,
|
|
8
|
+
robots: "noindex, nofollow",
|
|
9
|
+
});
|
|
10
|
+
|
|
2
11
|
const { isLoggedIn, isGuestSession } = useUser();
|
|
3
12
|
const { isEmpty } = useCart();
|
|
4
13
|
const { setStep } = useCheckoutStore();
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const {
|
|
3
|
+
public: { site },
|
|
4
|
+
} = useRuntimeConfig();
|
|
5
|
+
|
|
6
|
+
useSeoMeta({
|
|
7
|
+
title: `Zahlung & Versand | ${site.name}`,
|
|
8
|
+
robots: "noindex, nofollow",
|
|
9
|
+
});
|
|
10
|
+
|
|
2
11
|
const { isLoggedIn, isGuestSession } = useUser();
|
|
3
12
|
const { setStep } = useCheckoutStore();
|
|
4
13
|
|
package/nuxt.config.ts
CHANGED
|
@@ -55,6 +55,13 @@ export default defineNuxtConfig({
|
|
|
55
55
|
geoapifyApiKey: "",
|
|
56
56
|
public: {
|
|
57
57
|
shopBite: {
|
|
58
|
+
cacheTtl: {
|
|
59
|
+
product: 86400,
|
|
60
|
+
crossSelling: 86400,
|
|
61
|
+
listing: 86400,
|
|
62
|
+
variant: 3600,
|
|
63
|
+
category: 86400,
|
|
64
|
+
},
|
|
58
65
|
menuCategoryId: "",
|
|
59
66
|
feature: {
|
|
60
67
|
multiChannel: false,
|
|
@@ -110,10 +117,6 @@ export default defineNuxtConfig({
|
|
|
110
117
|
experimental: { sqliteConnector: "native" },
|
|
111
118
|
},
|
|
112
119
|
|
|
113
|
-
vitalizer: {
|
|
114
|
-
disablePrefetchLinks: true,
|
|
115
|
-
},
|
|
116
|
-
|
|
117
120
|
pwa: {
|
|
118
121
|
strategies: sw ? "injectManifest" : "generateSW",
|
|
119
122
|
srcDir: sw ? "service-worker" : undefined,
|
|
@@ -123,6 +126,8 @@ export default defineNuxtConfig({
|
|
|
123
126
|
name: storeName,
|
|
124
127
|
short_name: storeName,
|
|
125
128
|
theme_color: "#ff5b00",
|
|
129
|
+
display: "standalone",
|
|
130
|
+
start_url: "/",
|
|
126
131
|
icons: [
|
|
127
132
|
{
|
|
128
133
|
src: "logo-192.png",
|
package/package.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
|
+
import type { components } from "~~/api-types/storeApiTypes";
|
|
3
|
+
type Schemas = components["schemas"];
|
|
4
|
+
|
|
5
|
+
const criteria = encodeForQuery({
|
|
6
|
+
includes: {
|
|
7
|
+
category: ["name", "translated", "seoUrl", "externalLink", "customFields"],
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default defineCachedEventHandler(
|
|
12
|
+
async (event): Promise<Schemas["Category"]> => {
|
|
13
|
+
const categoryId = getRouterParam(event, "categoryId")!;
|
|
14
|
+
const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
|
|
15
|
+
|
|
16
|
+
return await $fetch(`${endpoint}/category/${categoryId}`, {
|
|
17
|
+
headers: { "sw-access-key": accessToken },
|
|
18
|
+
query: { _criteria: criteria },
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
maxAge: useRuntimeConfig().public.shopBite.cacheTtl.category,
|
|
23
|
+
name: "category",
|
|
24
|
+
getKey: (event) => `category-${getRouterParam(event, "categoryId")}`,
|
|
25
|
+
},
|
|
26
|
+
);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fixed projections applied to every listing request. Defined server-side so
|
|
5
|
+
* clients cannot override includes/associations/limit via query params.
|
|
6
|
+
*/
|
|
7
|
+
const BASE_CRITERIA = {
|
|
8
|
+
includes: {
|
|
9
|
+
product: [
|
|
10
|
+
"id",
|
|
11
|
+
"productNumber",
|
|
12
|
+
"name",
|
|
13
|
+
"description",
|
|
14
|
+
"calculatedPrice",
|
|
15
|
+
"translated",
|
|
16
|
+
"properties",
|
|
17
|
+
"propertyIds",
|
|
18
|
+
"sortedProperties",
|
|
19
|
+
"cover",
|
|
20
|
+
],
|
|
21
|
+
property: ["id", "name", "translated", "options"],
|
|
22
|
+
property_group_option: ["id", "name", "translated", "group"],
|
|
23
|
+
product_option: ["id", "groupId", "name", "translated", "group"],
|
|
24
|
+
},
|
|
25
|
+
associations: {
|
|
26
|
+
cover: {
|
|
27
|
+
associations: {
|
|
28
|
+
media: {},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
properties: {
|
|
32
|
+
associations: {
|
|
33
|
+
group: {},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
limit: 100,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Validates and returns a non-empty string up to maxLength, or undefined. */
|
|
41
|
+
function sanitizeString(value: unknown, maxLength: number): string | undefined {
|
|
42
|
+
if (typeof value !== "string" || value === "") return undefined;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length <= maxLength ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Validates page number: positive integer in [1, 1000]. */
|
|
48
|
+
function sanitizePage(value: unknown): number | undefined {
|
|
49
|
+
const n = Number(value);
|
|
50
|
+
return Number.isInteger(n) && n >= 1 && n <= 1000 ? n : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveAllowedParams(query: Record<string, unknown>) {
|
|
54
|
+
return {
|
|
55
|
+
order: sanitizeString(query.order, 64),
|
|
56
|
+
properties: sanitizeString(query.properties, 2048),
|
|
57
|
+
manufacturer: sanitizeString(query.manufacturer, 2048),
|
|
58
|
+
query: sanitizeString(query.query, 256),
|
|
59
|
+
p: sanitizePage(query.p),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default defineCachedEventHandler(
|
|
64
|
+
async (event) => {
|
|
65
|
+
const categoryId = getRouterParam(event, "categoryId")!;
|
|
66
|
+
const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
|
|
67
|
+
|
|
68
|
+
const { order, properties, manufacturer, query, p } = resolveAllowedParams(
|
|
69
|
+
getQuery(event) as Record<string, unknown>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const criteria = {
|
|
73
|
+
...BASE_CRITERIA,
|
|
74
|
+
...(order !== undefined && { order }),
|
|
75
|
+
...(properties !== undefined && { properties }),
|
|
76
|
+
...(manufacturer !== undefined && { manufacturer }),
|
|
77
|
+
...(query !== undefined && { query }),
|
|
78
|
+
...(p !== undefined && { p }),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return await $fetch(`${endpoint}/product-listing/${categoryId}`, {
|
|
82
|
+
headers: {
|
|
83
|
+
"sw-access-key": accessToken,
|
|
84
|
+
"sw-include-seo-urls": "true",
|
|
85
|
+
},
|
|
86
|
+
query: { _criteria: encodeForQuery(criteria) },
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
maxAge: useRuntimeConfig().public.shopBite.cacheTtl.listing,
|
|
91
|
+
name: "listing",
|
|
92
|
+
getKey: (event) => {
|
|
93
|
+
const categoryId = getRouterParam(event, "categoryId") ?? "";
|
|
94
|
+
const q = getQuery(event) as Record<string, unknown>;
|
|
95
|
+
const { order, properties, manufacturer, query, p } =
|
|
96
|
+
resolveAllowedParams(q);
|
|
97
|
+
return [
|
|
98
|
+
categoryId,
|
|
99
|
+
order ?? "",
|
|
100
|
+
properties ?? "",
|
|
101
|
+
manufacturer ?? "",
|
|
102
|
+
query ?? "",
|
|
103
|
+
p ?? 1,
|
|
104
|
+
].join("|");
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
|
+
import type { components } from "~~/api-types/storeApiTypes";
|
|
3
|
+
|
|
4
|
+
type Schemas = components["schemas"];
|
|
5
|
+
|
|
6
|
+
const criteria = encodeForQuery({
|
|
7
|
+
includes: {
|
|
8
|
+
cross_selling_element: ["crossSelling", "products"],
|
|
9
|
+
cross_selling: ["name"],
|
|
10
|
+
product: ["id", "name", "calculatedPrice", "translated"],
|
|
11
|
+
calculated_price: ["unitPrice"],
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default defineCachedEventHandler(
|
|
16
|
+
async (event): Promise<Schemas["CrossSellingElement"][]> => {
|
|
17
|
+
const productId = getRouterParam(event, "productId")!;
|
|
18
|
+
const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
|
|
19
|
+
|
|
20
|
+
return await $fetch(`${endpoint}/product/${productId}/cross-selling`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "sw-access-key": accessToken },
|
|
23
|
+
query: { _criteria: criteria },
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
maxAge: useRuntimeConfig().public.shopBite.cacheTtl.crossSelling,
|
|
28
|
+
name: "cross-selling",
|
|
29
|
+
getKey: (event) => `cross-selling-${getRouterParam(event, "productId")}`,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { encodeForQuery } from "@shopware/api-client/helpers";
|
|
2
|
+
import type { components } from "~~/api-types/storeApiTypes";
|
|
3
|
+
|
|
4
|
+
type Schemas = components["schemas"];
|
|
5
|
+
|
|
6
|
+
const criteria = encodeForQuery({
|
|
7
|
+
includes: {
|
|
8
|
+
product: [
|
|
9
|
+
"id",
|
|
10
|
+
"productNumber",
|
|
11
|
+
"name",
|
|
12
|
+
"description",
|
|
13
|
+
"calculatedPrice",
|
|
14
|
+
"translated",
|
|
15
|
+
"properties",
|
|
16
|
+
"propertyIds",
|
|
17
|
+
"options",
|
|
18
|
+
"optionIds",
|
|
19
|
+
"seoCategory",
|
|
20
|
+
"configuratorSettings",
|
|
21
|
+
"children",
|
|
22
|
+
"parentId",
|
|
23
|
+
"sortedProperties",
|
|
24
|
+
"cover",
|
|
25
|
+
],
|
|
26
|
+
property: ["id", "name", "translated", "options"],
|
|
27
|
+
property_group_option: ["id", "name", "translated", "group"],
|
|
28
|
+
product_configurator_setting: ["id", "optionId", "option", "productId"],
|
|
29
|
+
product_option: ["id", "groupId", "name", "translated", "group"],
|
|
30
|
+
category: ["id", "name", "translated"],
|
|
31
|
+
},
|
|
32
|
+
associations: {
|
|
33
|
+
cover: { associations: { media: {} } },
|
|
34
|
+
properties: { associations: { group: {} } },
|
|
35
|
+
options: { associations: { group: {} } },
|
|
36
|
+
configuratorSettings: {
|
|
37
|
+
associations: { option: { associations: { group: {} } } },
|
|
38
|
+
},
|
|
39
|
+
children: {
|
|
40
|
+
associations: {
|
|
41
|
+
properties: { associations: { group: {} } },
|
|
42
|
+
options: { associations: { group: {} } },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export default defineCachedEventHandler(
|
|
49
|
+
async (
|
|
50
|
+
event,
|
|
51
|
+
): Promise<{
|
|
52
|
+
product: Schemas["Product"];
|
|
53
|
+
configurator?: Schemas["PropertyGroup"][];
|
|
54
|
+
}> => {
|
|
55
|
+
const productId = getRouterParam(event, "productId")!;
|
|
56
|
+
const { endpoint, accessToken } = useRuntimeConfig().public.shopware;
|
|
57
|
+
|
|
58
|
+
return await $fetch(`${endpoint}/product/${productId}`, {
|
|
59
|
+
headers: { "sw-access-key": accessToken },
|
|
60
|
+
query: { _criteria: criteria },
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
maxAge: useRuntimeConfig().public.shopBite.cacheTtl.product,
|
|
65
|
+
name: "product",
|
|
66
|
+
getKey: (event) => `product-${getRouterParam(event, "productId")}`,
|
|
67
|
+
},
|
|
68
|
+
);
|