@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.
@@ -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(() => product.value?.parentId);
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
- const filter: Schemas["Filters"] = [
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 response = await apiClient.invoke("readProduct post /product", {
65
- body: {
66
- filter,
67
- limit: 1,
68
- includes: {
69
- product: [
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 response.data.elements?.[0]; // return first matching product
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.18.5",
3
+ "version": "1.20.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -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
+ );