@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.
@@ -121,51 +121,51 @@ jobs:
121
121
  - name: Unit tests
122
122
  run: pnpm test:unit
123
123
 
124
- e2e-test:
125
- name: E2E tests
126
- environment: test
127
- needs: [setup]
128
- timeout-minutes: 15
129
- runs-on: ubuntu-latest
130
- steps:
131
- - uses: actions/checkout@v6
132
-
133
- - uses: pnpm/action-setup@v5
134
- with:
135
- version: 10.33.0
136
-
137
- - uses: actions/setup-node@v6
138
- with:
139
- node-version: '24'
140
-
141
- - name: Restore workspace from cache
142
- uses: actions/cache/restore@v5
143
- with:
144
- path: |
145
- ~/.local/share/pnpm/store
146
- node_modules
147
- .nuxt
148
- .output
149
- key: workspace-${{ runner.os }}-${{ github.sha }}
150
-
151
- - name: Cache Playwright browsers
152
- uses: actions/cache@v5
153
- with:
154
- path: ~/.cache/ms-playwright
155
- key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
156
-
157
- - name: Install Playwright browsers
158
- run: pnpm exec playwright install --with-deps chromium
159
-
160
- - name: E2E tests
161
- env:
162
- TEST_USER: ${{ secrets.TEST_USER }}
163
- TEST_USER_PASS: ${{ secrets.TEST_USER_PASS }}
164
- run: pnpm test:e2e
165
-
166
- - uses: actions/upload-artifact@v7
167
- if: ${{ !cancelled() }}
168
- with:
169
- name: playwright-report
170
- path: playwright-report/
171
- retention-days: 30
124
+ # e2e-test:
125
+ # name: E2E tests
126
+ # environment: test
127
+ # needs: [setup]
128
+ # timeout-minutes: 15
129
+ # runs-on: ubuntu-latest
130
+ # steps:
131
+ # - uses: actions/checkout@v6
132
+ #
133
+ # - uses: pnpm/action-setup@v5
134
+ # with:
135
+ # version: 10.33.0
136
+ #
137
+ # - uses: actions/setup-node@v6
138
+ # with:
139
+ # node-version: '24'
140
+ #
141
+ # - name: Restore workspace from cache
142
+ # uses: actions/cache/restore@v5
143
+ # with:
144
+ # path: |
145
+ # ~/.local/share/pnpm/store
146
+ # node_modules
147
+ # .nuxt
148
+ # .output
149
+ # key: workspace-${{ runner.os }}-${{ github.sha }}
150
+ #
151
+ # - name: Cache Playwright browsers
152
+ # uses: actions/cache@v5
153
+ # with:
154
+ # path: ~/.cache/ms-playwright
155
+ # key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
156
+ #
157
+ # - name: Install Playwright browsers
158
+ # run: pnpm exec playwright install --with-deps chromium
159
+ #
160
+ # - name: E2E tests
161
+ # env:
162
+ # TEST_USER: ${{ secrets.TEST_USER }}
163
+ # TEST_USER_PASS: ${{ secrets.TEST_USER_PASS }}
164
+ # run: pnpm test:e2e
165
+ #
166
+ # - uses: actions/upload-artifact@v7
167
+ # if: ${{ !cancelled() }}
168
+ # with:
169
+ # name: playwright-report
170
+ # path: playwright-report/
171
+ # retention-days: 30
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { operations, Schemas } from "#shopware";
2
+ import type { Schemas } from "#shopware";
3
3
  import Breadcrumb from "~/components/Category/Breadcrumb.vue";
4
4
 
5
5
  const props = defineProps<{
@@ -8,72 +8,43 @@ const props = defineProps<{
8
8
 
9
9
  const { id: categoryId } = toRefs(props);
10
10
 
11
- const searchCriteria = {
12
- includes: {
13
- product: [
14
- "id",
15
- "productNumber",
16
- "name",
17
- "description",
18
- "calculatedPrice",
19
- "translated",
20
- "properties",
21
- "propertyIds",
22
- "sortedProperties",
23
- "cover",
24
- ],
25
- property: ["id", "name", "translated", "options"],
26
- property_group_option: ["id", "name", "translated", "group"],
27
- product_option: ["id", "groupId", "name", "translated", "group"],
28
- },
29
- associations: {
30
- cover: {
31
- associations: {
32
- media: {},
33
- },
34
- },
35
- properties: {
36
- associations: {
37
- group: {},
38
- },
39
- },
40
- },
41
- limit: 100,
42
- } as operations["searchPage post /search"]["body"];
11
+ const { category } = await useCategory(categoryId);
43
12
 
44
13
  const {
45
- resetFilters,
14
+ showSkeleton,
46
15
  loading,
47
- search,
48
- getElements,
49
- getCurrentListing,
50
- getCurrentSortingOrder,
51
- getSortingOrders,
52
- changeCurrentSortingOrder,
53
- getAvailableFilters,
54
- getCurrentFilters,
55
- setCurrentFilters,
56
- setInitialListing,
57
- } = useListing({
58
- listingType: "categoryListing",
59
- categoryId: props.id,
60
- defaultSearchCriteria: searchCriteria,
61
- });
62
-
63
- const { category } = await useCategory(categoryId);
16
+ elements,
17
+ sortingOrders,
18
+ currentSortingOrder,
19
+ availableFilters,
20
+ currentFilters,
21
+ changeSorting,
22
+ setFilters,
23
+ resetFilters,
24
+ } = useCategoryListing(props.id);
64
25
 
65
26
  useCategorySeo(category);
66
27
 
67
- const currentSorting = ref(getCurrentSortingOrder.value ?? "Sortieren");
28
+ const currentSorting = ref(currentSortingOrder.value ?? "Sortieren");
29
+
30
+ // Sync currentSorting when listing data arrives during client-side navigation
31
+ // (currentSortingOrder.value is null at setup time in that case).
32
+ watch(
33
+ currentSortingOrder,
34
+ (val) => {
35
+ if (val) currentSorting.value = val;
36
+ },
37
+ { once: true },
38
+ );
68
39
 
69
40
  const propertyFilters = computed<Schemas["PropertyGroup"][]>(
70
41
  () =>
71
- (getAvailableFilters.value?.filter(
42
+ (availableFilters.value?.filter(
72
43
  (availableFilter) => availableFilter.code === "properties",
73
44
  ) ?? []) as unknown as Schemas["PropertyGroup"][],
74
45
  );
75
46
 
76
- const selectedPropertyFilters = ref(getCurrentFilters.value?.properties ?? []);
47
+ const selectedPropertyFilters = ref(currentFilters.value?.properties ?? []);
77
48
  const selectedPropertyFiltersString = computed(() =>
78
49
  selectedPropertyFilters.value?.join("|"),
79
50
  );
@@ -87,48 +58,26 @@ const selectedListingFilters = computed<ShortcutFilterParam[]>(() => {
87
58
  ];
88
59
  });
89
60
 
90
- const nuxtApp = useNuxtApp();
91
- const { data: listingPayload, pending } = await useAsyncData(
92
- `listing${categoryId.value}`,
93
- async () => {
94
- await search(searchCriteria);
95
- // Return the result so it gets serialized into the SSR payload.
96
- // On the client, useAsyncData will restore this without re-running search().
97
- return getCurrentListing.value;
98
- },
99
- );
100
-
101
- // Populate useListing state from the SSR payload on the client.
102
- // useListing uses plain refs (not useState), so its state is not automatically
103
- // hydrated — we restore it via setInitialListing.
104
- if (listingPayload.value) {
105
- await setInitialListing(listingPayload.value);
106
- }
107
-
108
- // During SSR hydration, pending may briefly be true before the payload cache is applied.
109
- // Suppress the skeleton in that window to prevent a hydration mismatch.
110
- const showSkeleton = computed(() => pending.value && !nuxtApp.isHydrating);
61
+ let filterChain = Promise.resolve();
111
62
 
112
63
  watch(selectedListingFilters, (newFilters, oldFilters) => {
113
- if (newFilters[0]?.value === oldFilters?.[0]?.value) {
114
- return;
115
- }
116
- setCurrentFilters(newFilters);
64
+ if (newFilters[0]?.value === oldFilters?.[0]?.value) return;
117
65
  currentSorting.value = "Sortieren";
66
+ filterChain = filterChain
67
+ .catch(() => {})
68
+ .then(() => setFilters(newFilters))
69
+ .catch(() => {});
118
70
  });
119
71
 
120
- watch(currentSorting, async () => {
72
+ watch(currentSorting, async (val) => {
73
+ if (val === currentSortingOrder.value) return;
121
74
  const sortingQuery = {
122
- query: getCurrentFilters.value?.search,
123
- properties: getCurrentFilters.value?.properties?.join("|"),
75
+ query: currentFilters.value?.search,
76
+ properties: currentFilters.value?.properties?.join("|"),
124
77
  };
125
- await changeCurrentSortingOrder(currentSorting.value as string, sortingQuery);
78
+ await changeSorting(val as string, sortingQuery);
126
79
  });
127
80
 
128
- async function handleFilterRest() {
129
- await resetFilters();
130
- }
131
-
132
81
  const moreThanOneFilterAndOption = computed<boolean>(
133
82
  () => propertyFilters.value.length > 0,
134
83
  );
@@ -148,15 +97,12 @@ const moreThanOneFilterAndOption = computed<boolean>(
148
97
  <Breadcrumb :category-id="category?.id" />
149
98
  <CategoryHeader v-if="category" :category="category" />
150
99
  <div class="flex flex-row justify-between gap-4 mb-4">
151
- <UBadge
152
- variant="subtle"
153
- :label="`${getElements.length} Produkte`"
154
- />
100
+ <UBadge variant="subtle" :label="`${elements.length} Produkte`" />
155
101
  <USelect
156
102
  v-model="currentSorting"
157
103
  icon="i-lucide-arrow-down-wide-narrow"
158
104
  value-key="key"
159
- :items="getSortingOrders"
105
+ :items="sortingOrders"
160
106
  placeholder="Sortierung"
161
107
  />
162
108
  <ClientOnly v-if="moreThanOneFilterAndOption">
@@ -205,7 +151,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
205
151
  label="Zurücksetzen"
206
152
  variant="outline"
207
153
  block
208
- @click="handleFilterRest"
154
+ @click="resetFilters"
209
155
  />
210
156
  </div>
211
157
  </template>
@@ -231,7 +177,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
231
177
  :class="{ 'opacity-40 pointer-events-none': loading }"
232
178
  >
233
179
  <ProductCard
234
- v-for="product in getElements"
180
+ v-for="product in elements"
235
181
  :key="product.id"
236
182
  :product="product"
237
183
  :with-favorite-button="true"
@@ -281,7 +227,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
281
227
  label="Zurücksetzen"
282
228
  variant="outline"
283
229
  block
284
- @click="handleFilterRest"
230
+ @click="resetFilters"
285
231
  />
286
232
  </div>
287
233
  <template #fallback>
@@ -14,17 +14,12 @@ const { variants: selectableOptions } = useProductVariantsZwei(configurator);
14
14
 
15
15
  const selectedOptions = ref<Record<string, string>>({});
16
16
 
17
- function initialOptions(variant: Ref<Schemas["Product"]>) {
18
- const options = variant.value.options as Schemas["PropertyGroupOption"][];
19
- for (const option of options) {
20
- if (option.group && option.id) {
21
- selectedOptions.value[option.group.id] = option.id;
22
- }
17
+ const options = product.value.options as Schemas["PropertyGroupOption"][];
18
+ for (const option of options ?? []) {
19
+ if (option.group && option.id) {
20
+ selectedOptions.value[option.group.id] = option.id;
23
21
  }
24
22
  }
25
- onMounted(() => {
26
- initialOptions(product);
27
- });
28
23
 
29
24
  watch(
30
25
  selectedOptions.value,
@@ -1,69 +1,26 @@
1
1
  <script setup lang="ts">
2
2
  import type { Schemas } from "#shopware";
3
- import type {
4
- AssociationItemProduct,
5
- AssociationItem,
6
- } from "~/types/Association";
7
- import { useShopwareContext } from "#imports";
3
+ import type { AssociationItemProduct } from "~/types/Association";
8
4
 
9
- const DEFAULT_CURRENCY = "EUR";
10
- const DEFAULT_LOCALE = "de-DE";
11
5
  const LOADING_ICON = "i-lucide-loader";
12
- const DEFAULT_ICON = "i-lucide-plus";
13
6
 
14
7
  const props = defineProps<{
15
8
  product: Schemas["Product"];
16
9
  }>();
17
10
 
18
- const product = computed(() => props.product);
19
- const selectedExtras = ref<AssociationItemProduct[]>([]);
20
-
21
- const { getFormattedPrice } = usePrice({
22
- currencyCode: DEFAULT_CURRENCY,
23
- localeCode: DEFAULT_LOCALE,
24
- });
25
-
26
- function mapAssociationToItems(
27
- associations: Schemas["CrossSellingElement"][],
28
- ): AssociationItem[] {
29
- return associations.map((crossSellingElement) => ({
30
- label: crossSellingElement.crossSelling.name,
31
- products: crossSellingElement.products.map(
32
- (product: Schemas["Product"]) => ({
33
- label: product.name,
34
- value: product.id,
35
- price: getFormattedPrice(product.calculatedPrice.unitPrice),
36
- icon: DEFAULT_ICON,
37
- }),
38
- ),
39
- }));
40
- }
41
-
42
- const { apiClient } = useShopwareContext();
43
-
44
- const { data: productAssociations, pending: isAssociationsLoading } =
45
- useAsyncData(`cross-selling-${product.value.id}`, async () => {
46
- const response = await apiClient.invoke(
47
- "readProductCrossSellings post /product/{productId}/cross-selling",
48
- {
49
- pathParams: { productId: product.value.id },
50
- },
51
- );
11
+ const emit = defineEmits<{
12
+ "extras-selected": [selectedExtras: AssociationItemProduct[]];
13
+ }>();
52
14
 
53
- return response.data;
54
- });
15
+ const selectedExtras = ref<AssociationItemProduct[]>([]);
55
16
 
56
- const associationItems = computed(() =>
57
- mapAssociationToItems(productAssociations.value ?? []),
17
+ const { associationItems, isAssociationsLoading } = useProductCrossSelling(
18
+ () => props.product.id,
58
19
  );
59
20
 
60
21
  watch(selectedExtras, () => emit("extras-selected", selectedExtras.value), {
61
22
  deep: true,
62
23
  });
63
-
64
- const emit = defineEmits<{
65
- "extras-selected": [selectedExtras: AssociationItemProduct[]];
66
- }>();
67
24
  </script>
68
25
 
69
26
  <template>
@@ -1,142 +1,30 @@
1
1
  <script setup lang="ts">
2
- import type { operations, Schemas } from "#shopware";
3
- import type { AssociationItemProduct } from "~/types/Association";
4
- import { useTrackEvent } from "~/composables/useTrackEvent";
2
+ import type { Schemas } from "#shopware";
5
3
 
6
4
  const props = defineProps<{
7
5
  productId: string;
8
6
  }>();
9
7
 
10
- const { apiClient } = useShopwareContext();
11
- const { trackProductView } = useTrackEvent();
12
-
13
- const productId = toRef(props.productId);
14
-
15
- const searchCriteria = {
16
- includes: {
17
- product: [
18
- "id",
19
- "productNumber",
20
- "name",
21
- "description",
22
- "calculatedPrice",
23
- "translated",
24
- "properties",
25
- "propertyIds",
26
- "options",
27
- "optionIds",
28
- "seoCategory",
29
- "configuratorSettings",
30
- "children",
31
- "parentId",
32
- "sortedProperties",
33
- "cover",
34
- ],
35
- property: ["id", "name", "translated", "options"],
36
- property_group_option: ["id", "name", "translated", "group"],
37
- product_configurator_setting: ["id", "optionId", "option", "productId"],
38
- product_option: ["id", "groupId", "name", "translated", "group"],
39
- category: ["id", "name", "translated"],
40
- },
41
- associations: {
42
- cover: {
43
- associations: {
44
- media: {},
45
- },
46
- },
47
- properties: {
48
- associations: {
49
- group: {},
50
- },
51
- },
52
- options: {
53
- associations: {
54
- group: {},
55
- },
56
- },
57
- configuratorSettings: {
58
- associations: {
59
- option: {
60
- associations: {
61
- group: {},
62
- },
63
- },
64
- },
65
- },
66
- children: {
67
- associations: {
68
- properties: {
69
- associations: {
70
- group: {},
71
- },
72
- },
73
- options: {
74
- associations: {
75
- group: {},
76
- },
77
- },
78
- },
79
- },
80
- },
81
- } as operations["searchPage post /search"]["body"];
82
-
83
- const { data: productDetails, pending } = useAsyncData(
84
- () => `product-${productId.value ?? "none"}`,
85
- async () => {
86
- if (!productId.value) return null;
87
- const response = await apiClient.invoke(
88
- "readProductDetail post /product/{productId}",
89
- {
90
- pathParams: { productId: productId.value },
91
- body: searchCriteria,
92
- },
93
- );
94
- return response.data;
95
- },
96
- );
8
+ const emit = defineEmits(["product-added", "variant-selected"]);
97
9
 
98
10
  const {
11
+ productDetails,
12
+ pending,
99
13
  selectedProduct,
100
14
  selectedQuantity,
101
15
  isLoading,
102
16
  addToCart,
103
17
  setSelectedProduct,
104
- setSelectedExtras,
105
- setDeselectedIngredients,
106
- } = useAddToCart();
107
-
108
- // Initialize when productDetails is loaded
109
- watch(
110
- () => productDetails.value?.product,
111
- (product) => {
112
- if (product) {
113
- setSelectedProduct(product);
114
- }
115
- },
116
- { immediate: true },
117
- );
18
+ onExtrasSelected,
19
+ onIngredientsDeselected,
20
+ } = useProductDetail(() => props.productId);
118
21
 
119
22
  const onVariantSwitched = (variant: Schemas["Product"]) => {
120
23
  setSelectedProduct(variant);
121
24
  emit("variant-selected", variant);
122
25
  };
123
26
 
124
- const onExtrasSelected = (extras: AssociationItemProduct[]) => {
125
- setSelectedExtras(extras);
126
- };
127
-
128
- const onIngredientsDeselected = (deselected: string[]) => {
129
- setDeselectedIngredients(deselected);
130
- };
131
-
132
27
  const onAddToCart = () => emit("product-added");
133
-
134
- const emit = defineEmits(["product-added", "variant-selected"]);
135
-
136
- watch(productDetails, () => {
137
- if (!productDetails.value) return;
138
- trackProductView(productDetails.value.product);
139
- });
140
28
  </script>
141
29
 
142
30
  <template>
@@ -1,38 +1,10 @@
1
- import { encodeForQuery } from "@shopware/api-client/helpers";
1
+ import type { Schemas } from "#shopware";
2
+ import type { NitroFetchRequest } from "nitropack";
2
3
 
3
4
  export async function useCategory(categoryId: Ref<string>) {
4
- const { apiClient } = useShopwareContext();
5
+ const { data: category } = await useFetch<Schemas["Category"]>(
6
+ `/api/category/${categoryId.value}` as NitroFetchRequest,
7
+ );
5
8
 
6
- const criteria = encodeForQuery({
7
- includes: {
8
- category: [
9
- "name",
10
- "translated",
11
- "seoUrl",
12
- "externalLink",
13
- "customFields",
14
- ],
15
- },
16
- });
17
-
18
- const cacheKey = computed(() => `category-${categoryId.value}`);
19
-
20
- const { data } = await useAsyncData(cacheKey, async () => {
21
- const response = await apiClient.invoke(
22
- "readCategoryGet get /category/{navigationId}",
23
- {
24
- // @ts-expect-error: _criteria is not in the type definition
25
- query: { _criteria: criteria },
26
- pathParams: {
27
- navigationId: categoryId.value,
28
- },
29
- },
30
- );
31
-
32
- return response.data;
33
- });
34
-
35
- return {
36
- category: data,
37
- };
9
+ return { category };
38
10
  }