@shopbite-de/storefront 1.10.0 → 1.11.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,74 @@
1
+ <script setup lang="ts">
2
+ import type { Schemas } from "#shopware";
3
+ import type { BreadcrumbItem } from "#ui/components/Breadcrumb.vue";
4
+
5
+ const props = defineProps<{
6
+ categoryId: string | undefined;
7
+ }>();
8
+
9
+ const { categoryId } = toRefs(props);
10
+
11
+ const { apiClient } = useShopwareContext();
12
+
13
+ const breadcrumbJsonLd = ref<object | null>(null);
14
+
15
+ useHead(() => {
16
+ if (!breadcrumbJsonLd.value) return {};
17
+ return {
18
+ script: [
19
+ {
20
+ key: "jsonld-breadcrumb",
21
+ type: "application/ld+json",
22
+ children: JSON.stringify(breadcrumbJsonLd.value),
23
+ },
24
+ ],
25
+ };
26
+ });
27
+
28
+ const cacheKey = computed(() => `breadcrumb-${categoryId.value}`);
29
+
30
+ const { data } = await useAsyncData(cacheKey, async () => {
31
+ if (!categoryId.value) return [];
32
+ const response = await apiClient.invoke(
33
+ "readBreadcrumb get /breadcrumb/{id}",
34
+ {
35
+ pathParams: { id: categoryId.value },
36
+ query: { type: "category" },
37
+ },
38
+ );
39
+ return response.data;
40
+ });
41
+
42
+ const items = computed<BreadcrumbItem[]>(() => {
43
+ if (!data.value) return [];
44
+ return data.value?.map((item: Schemas["Breadcrumb"]) => {
45
+ return {
46
+ label: item.name,
47
+ to: "/" + item.path,
48
+ };
49
+ });
50
+ });
51
+
52
+ watchEffect(() => {
53
+ const list = items.value ?? [];
54
+ if (!list.length) {
55
+ breadcrumbJsonLd.value = null;
56
+ return;
57
+ }
58
+
59
+ breadcrumbJsonLd.value = {
60
+ "@context": "https://schema.org",
61
+ "@type": "BreadcrumbList",
62
+ itemListElement: list.map((it, index) => ({
63
+ "@type": "ListItem",
64
+ position: index + 1,
65
+ name: it.label,
66
+ item: new URL(String(it.to ?? "/"), useRequestURL().origin).toString(),
67
+ })),
68
+ };
69
+ });
70
+ </script>
71
+
72
+ <template>
73
+ <UBreadcrumb :items="items" />
74
+ </template>
@@ -1,60 +1,42 @@
1
1
  <script setup lang="ts">
2
2
  import type { Schemas } from "#shopware";
3
3
 
4
- defineProps<{
4
+ const props = defineProps<{
5
5
  category: Schemas["Category"];
6
6
  }>();
7
+
8
+ const { category: categoryRef } = toRefs(props);
9
+
10
+ const categoryCover = computed(
11
+ () => categoryRef.value.media?.url ?? "/category-placeholder.webp",
12
+ );
7
13
  </script>
8
14
 
9
15
  <template>
10
16
  <div
11
- v-if="category.media?.url"
12
- class="relative mb-4 mt-8 h-40 w-full overflow-hidden rounded-lg"
17
+ class="relative mb-4 mt-8 min-h-36 w-full overflow-hidden rounded-[0.5rem]"
13
18
  >
14
19
  <NuxtImg
15
- v-if="category.media?.url"
16
- :src="category.media.url"
20
+ :src="categoryCover"
17
21
  class="absolute inset-0 h-full w-full object-cover"
18
22
  sizes="sm:100vw md:700px"
19
- alt="Pizza Kategorie"
23
+ :alt="category.name + ' Cover Image'"
20
24
  placeholder
21
25
  />
22
- <div
23
- v-if="category.media?.url"
24
- class="absolute inset-0 bg-gradient-to-t from-black/50 to-black/10"
25
- />
26
-
27
- <div v-else class="absolute inset-0 bg-primary" />
26
+ <div class="absolute inset-0 bg-linear-to-t from-black/50 to-black/10" />
28
27
 
29
28
  <div class="relative p-4">
30
- <h1 class="text-white font-semibold">
31
- {{ category.name }}
29
+ <h1
30
+ class="text-white text-4xl md:text-5xl lg:text-6xl font-extrabold leading-none tracking-tighter mb-3"
31
+ >
32
+ {{ categoryRef.name }}
32
33
  </h1>
33
- <p v-if="category.description">
34
- {{ category.description }}
34
+ <p
35
+ v-if="categoryRef.description"
36
+ class="text-white/90 text-[16px] text-pretty mt-1"
37
+ >
38
+ {{ categoryRef.description }}
35
39
  </p>
36
40
  </div>
37
41
  </div>
38
- <UPageCard
39
- v-else
40
- :title="category.translated.name ?? category.name"
41
- :description="category.translated.description ?? category.description"
42
- variant="soft"
43
- class="my-4"
44
- :ui="{
45
- title: 'text-3xl md:text-4xl',
46
- }"
47
- />
48
42
  </template>
49
-
50
- <style scoped>
51
- @import "tailwindcss";
52
-
53
- h1 {
54
- @apply text-white text-4xl md:text-5xl lg:text-6xl font-extrabold leading-none tracking-tighter mb-3;
55
- }
56
-
57
- p {
58
- @apply text-white/90 text-[16px] text-pretty mt-1;
59
- }
60
- </style>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { operations, Schemas } from "#shopware";
3
+ import Breadcrumb from "~/components/Category/Breadcrumb.vue";
3
4
 
4
5
  const props = defineProps<{
5
6
  id: string;
@@ -59,12 +60,11 @@ const {
59
60
 
60
61
  const { search: categorySearch } = useCategorySearch();
61
62
 
62
- const { data: category } = await useAsyncData(
63
- `category${categoryId.value}`,
64
- async () => {
65
- return await categorySearch(categoryId.value);
66
- },
67
- );
63
+ const categoryCacheKey = computed(() => `category-${categoryId.value}`);
64
+
65
+ const { data: category } = await useAsyncData(categoryCacheKey, async () => {
66
+ return await categorySearch(categoryId.value);
67
+ });
68
68
 
69
69
  useCategorySeo(category);
70
70
 
@@ -130,6 +130,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
130
130
 
131
131
  <UPageBody>
132
132
  <div>
133
+ <Breadcrumb :category-id="category?.id" />
133
134
  <CategoryHeader v-if="category" :category="category" />
134
135
  <div class="flex flex-row justify-between gap-4 mb-4">
135
136
  <UBadge
@@ -43,9 +43,10 @@ onMounted(() => {
43
43
  loop
44
44
  muted
45
45
  playsinline
46
+ fetchpriority="high"
46
47
  class="absolute inset-0 w-full h-full object-cover -z-10"
47
48
  >
48
- <source fetchpriority="high" :src="backgroundVideo" type="video/mp4" >
49
+ <source :src="backgroundVideo" type="video/mp4" >
49
50
  </video>
50
51
  <div class="bg-black/50 backdrop-blur-sm">
51
52
  <UPageHero
@@ -7,26 +7,26 @@ const { loadNavigationElements } = useNavigation();
7
7
  const { data: navigationElements } = await useAsyncData(
8
8
  `menu-navigation`,
9
9
  async () => {
10
- return await loadNavigationElements({ depth: 2 });
10
+ return await loadNavigationElements({ depth: 3 });
11
11
  },
12
12
  );
13
13
 
14
+ const mapCategoryToNavItem = (
15
+ category: Schemas["Category"],
16
+ ): NavigationMenuItem => {
17
+ const label = category.translated?.name ?? "";
18
+
19
+ return {
20
+ label,
21
+ description: `${label} Kategorie`,
22
+ to: category.seoUrl,
23
+ defaultOpen: true,
24
+ children: (category.children ?? []).map(mapCategoryToNavItem),
25
+ };
26
+ };
27
+
14
28
  const navItems = computed<NavigationMenuItem[]>(() => {
15
- return navigationElements.value?.map((item: Schemas["Category"]) => {
16
- return {
17
- label: item.translated?.name,
18
- description: `${item.translated?.name} Kategorie`,
19
- to: item.seoUrl,
20
- defaultOpen: true,
21
- children: item.children?.map((child: Schemas["Category"]) => {
22
- return {
23
- label: child.translated?.name,
24
- description: `${child.translated?.name} Kategorie`,
25
- to: child.seoUrl,
26
- };
27
- }),
28
- };
29
- });
29
+ return (navigationElements.value ?? []).map(mapCategoryToNavItem);
30
30
  });
31
31
  </script>
32
32
 
@@ -2,23 +2,43 @@
2
2
  import type { NavigationMenuItem } from "@nuxt/ui";
3
3
  import type { Schemas } from "#shopware";
4
4
 
5
+ type Category = Schemas["Category"];
6
+
7
+ const props = withDefaults(
8
+ defineProps<{
9
+ shouldSkipFirstLevel?: boolean;
10
+ }>(),
11
+ {
12
+ shouldSkipFirstLevel: false,
13
+ },
14
+ );
15
+
5
16
  const { loadNavigationElements, navigationElements } = useNavigation();
6
17
 
7
- loadNavigationElements({ depth: 1 });
18
+ loadNavigationElements({ depth: 3 });
8
19
 
9
20
  const navItems = computed<NavigationMenuItem[]>(() => {
10
- return navigationElements.value?.map((item: Schemas["Category"]) => {
21
+ const elements = navigationElements.value ?? [];
22
+
23
+ const mapCategoryRecursively = (category: Category): NavigationMenuItem => {
24
+ const hasChildren = (category.children?.length ?? 0) > 0;
25
+
11
26
  return {
12
- label: item.translated?.name,
13
- to: item.children.length === 0 ? item.seoUrl : undefined,
14
- children: item.children?.map((child: Schemas["Category"]) => {
15
- return {
16
- label: child.translated?.name,
17
- to: child.seoUrl,
18
- };
19
- }),
27
+ label: category.translated?.name ?? "",
28
+ to: hasChildren ? undefined : category.seoUrl,
29
+ children: hasChildren
30
+ ? category.children!.map(mapCategoryRecursively)
31
+ : undefined,
20
32
  };
21
- });
33
+ };
34
+
35
+ if (props.shouldSkipFirstLevel) {
36
+ return elements
37
+ .flatMap((item: Category) => item.children ?? [])
38
+ .map(mapCategoryRecursively);
39
+ }
40
+
41
+ return elements.map(mapCategoryRecursively);
22
42
  });
23
43
  </script>
24
44
 
@@ -250,9 +250,7 @@ const emit = defineEmits<{
250
250
  <template #label>
251
251
  <span>
252
252
  Ich habe die
253
- <ULink to="/unternehmen/datenschutz">
254
- Datenschutzbestimmungen
255
- </ULink>
253
+ <ULink to="/datenschutz"> Datenschutzbestimmungen </ULink>
256
254
  gelesen und akzeptiere diese.
257
255
  </span>
258
256
  </template>
@@ -28,53 +28,6 @@ export function useCategorySeo(category: Ref<Schemas["Category"] | undefined>) {
28
28
  return base + path;
29
29
  });
30
30
 
31
- const breadcrumb = computed<string[]>(
32
- () =>
33
- category.value?.translated?.breadcrumb ??
34
- category.value?.breadcrumb ??
35
- [],
36
- );
37
-
38
- // Build BreadcrumbList items from category breadcrumb
39
- type BreadcrumbListItem = {
40
- "@type": "ListItem";
41
- position: number;
42
- name: string;
43
- item?: string;
44
- };
45
- const breadcrumbItems = computed(() => {
46
- const names = (breadcrumb.value || []) as string[];
47
- const items: BreadcrumbListItem[] = [
48
- {
49
- "@type": "ListItem",
50
- position: 1,
51
- name: "Home",
52
- item: config.public.storeUrl || "/",
53
- },
54
- ];
55
-
56
- if (names.length > 0) {
57
- names.forEach((name: string, idx: number) => {
58
- const isLast = idx === names.length - 1;
59
- items.push({
60
- "@type": "ListItem",
61
- position: idx + 2,
62
- name,
63
- ...(isLast && canonicalUrl.value ? { item: canonicalUrl.value } : {}),
64
- });
65
- });
66
- } else if (pageTitle.value) {
67
- items.push({
68
- "@type": "ListItem",
69
- position: 2,
70
- name: pageTitle.value,
71
- ...(canonicalUrl.value ? { item: canonicalUrl.value } : {}),
72
- });
73
- }
74
-
75
- return items;
76
- });
77
-
78
31
  const ogImage = computed(() => category.value?.media?.url);
79
32
 
80
33
  const siteName = computed(() => config.public.site?.name || "");
@@ -145,14 +98,6 @@ export function useCategorySeo(category: Ref<Schemas["Category"] | undefined>) {
145
98
  ...(ogImage.value ? { image: [ogImage.value] } : {}),
146
99
  }),
147
100
  },
148
- {
149
- type: "application/ld+json",
150
- innerHTML: JSON.stringify({
151
- "@context": "https://schema.org",
152
- "@type": "BreadcrumbList",
153
- itemListElement: breadcrumbItems.value,
154
- }),
155
- },
156
101
  ],
157
102
  });
158
103
 
@@ -3,7 +3,7 @@
3
3
  <template>
4
4
  <UPage>
5
5
  <div class="sticky top-16 left-0 z-20 w-full backdrop-blur-md rounded-md">
6
- <NavigationMobileTop2 />
6
+ <NavigationMobileTop2 :should-skip-first-level="true" />
7
7
  </div>
8
8
  <slot />
9
9
  </UPage>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import type { Schemas } from "#shopware";
3
+
4
+ definePageMeta({
5
+ layout: "listing2",
6
+ });
7
+ const { clearBreadcrumbs } = useBreadcrumbs();
8
+ const { resolvePath } = useNavigationSearch();
9
+ const route = useRoute();
10
+ const routePath = route.path;
11
+
12
+ const { data: seoResult, error } = await useAsyncData(
13
+ `cmsResponse${routePath}`,
14
+ async () => {
15
+ // For client links if the history state contains seo url information we can omit the api call
16
+ if (import.meta.client) {
17
+ if (history.state?.routeName) {
18
+ return {
19
+ routeName: history.state?.routeName,
20
+ foreignKey: history.state?.foreignKey,
21
+ };
22
+ }
23
+ }
24
+ const seoUrl = await resolvePath(routePath);
25
+
26
+ if (!seoUrl?.foreignKey) {
27
+ throw createError({
28
+ statusCode: 404,
29
+ statusMessage: `No data fetched from API for ${routePath}`,
30
+ });
31
+ }
32
+
33
+ return seoUrl;
34
+ },
35
+ );
36
+
37
+ if (error.value) {
38
+ throw error.value;
39
+ }
40
+
41
+ const { foreignKey } = useNavigationContext(
42
+ seoResult as Ref<Schemas["SeoUrl"]>,
43
+ );
44
+
45
+ onBeforeRouteLeave(() => {
46
+ clearBreadcrumbs();
47
+ });
48
+ </script>
49
+
50
+ <template>
51
+ <CategoryListing :id="foreignKey" :key="foreignKey" />
52
+ </template>
package/content/index.yml CHANGED
@@ -23,7 +23,7 @@ hero:
23
23
  icon: i-lucide-arrow-right
24
24
  trailing: true
25
25
  color: primary
26
- to: /menu/
26
+ to: /speisekarte/
27
27
  size: xl
28
28
  - label: Tisch reservieren
29
29
  icon: i-lucide-phone
@@ -84,10 +84,10 @@ gallery:
84
84
  cta:
85
85
  title: Jetzt bestellen!
86
86
  description: Genieße die italienische Küche, frisch zubereitet und direkt zu dir geliefert oder vor Ort genießen.
87
- backgroundImage: https://shopware.shopbite.de/media/f5/19/4a/1762546880/category-pizza-header.webp
87
+ backgroundImage: https://shopware.shopbite.de/media/f2/22/5f/1770899823/category-pizza-header2.webp
88
88
  links:
89
89
  - label: Zur Speisekarte
90
- to: /menu/
90
+ to: /speisekarte/
91
91
  color: primary
92
92
  - label: Tisch reservieren
93
93
  to: tel:+49610471427
package/node.dockerfile CHANGED
@@ -35,4 +35,4 @@ USER node
35
35
 
36
36
  EXPOSE 3000
37
37
 
38
- CMD [ "node", ".output/server/index.mjs" ]
38
+ CMD [ "node", "--trace-warnings", ".output/server/index.mjs" ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -18,7 +18,7 @@
18
18
  "@heroicons/vue": "^2.2.0",
19
19
  "@iconify-json/lucide": "^1.2.73",
20
20
  "@iconify-json/simple-icons": "^1.2.59",
21
- "@nuxt/content": "3.11.2",
21
+ "@nuxt/content": "3.10.0",
22
22
  "@nuxt/image": "^2.0.0",
23
23
  "@nuxt/scripts": "0.13.2",
24
24
  "@nuxt/ui": "^4.1.0",
Binary file
@@ -50,7 +50,7 @@ async function clearCart(page: Page) {
50
50
  }
51
51
 
52
52
  async function navigateToCategoryAndVerifyProducts(page: Page) {
53
- await page.goto("/c/Pizza/", { waitUntil: "load" });
53
+ await page.goto("/speisekarte/pizza/", { waitUntil: "load" });
54
54
  await expect(page.locator("h1")).toHaveText("Pizza");
55
55
 
56
56
  const productCards = page.locator('[id^="product-card-"]');
@@ -107,26 +107,12 @@ describe("useCategorySeo", () => {
107
107
 
108
108
  // JSON-LD scripts
109
109
  const scripts = headArg.script || [];
110
- expect(scripts.length).toBeGreaterThanOrEqual(2);
110
+ expect(scripts.length).toBeGreaterThanOrEqual(1);
111
111
 
112
112
  const collection = JSON.parse(scripts[0].innerHTML);
113
113
  expect(collection["@type"]).toBe("CollectionPage");
114
114
  expect(collection.url).toBe("https://example.com/c/pasta");
115
115
  expect(collection.image?.[0]).toBe("https://example.com/img/pasta.jpg");
116
-
117
- const breadcrumb = JSON.parse(scripts[1].innerHTML);
118
- expect(breadcrumb["@type"]).toBe("BreadcrumbList");
119
- const items = breadcrumb.itemListElement;
120
- // Home item
121
- expect(items[0]).toMatchObject({
122
- "@type": "ListItem",
123
- position: 1,
124
- name: "Home",
125
- item: "https://example.com",
126
- });
127
- // Last item should include canonical URL
128
- const last = items[items.length - 1];
129
- expect(last.item).toBe("https://example.com/c/pasta");
130
116
  });
131
117
 
132
118
  it("sets robots to noindex when category is inactive", () => {
@@ -1,57 +0,0 @@
1
- <script setup lang="ts">
2
- import type { Schemas } from "#shopware";
3
-
4
- const {
5
- public: { site },
6
- } = useRuntimeConfig();
7
-
8
- const pageTitle = computed(() => `Speisekarte | ${site?.name}`);
9
-
10
- useSeoMeta({ title: pageTitle });
11
-
12
- definePageMeta({
13
- layout: "listing",
14
- });
15
-
16
- const { loadNavigationElements, navigationElements } = useNavigation();
17
- await loadNavigationElements({ depth: 1 });
18
-
19
- const searchBarRef = ref<{
20
- showSuggest: boolean;
21
- loading: boolean;
22
- products: Schemas["Product"];
23
- } | null>(null);
24
-
25
- const searchInProgress = ref(false);
26
- </script>
27
-
28
- <template>
29
- <div v-if="navigationElements">
30
- <div class="sticky top-16 left-0 z-20 w-full backdrop-blur-md rounded-md">
31
- <NavigationMobileTop v-if="!searchInProgress" class="" />
32
- <ProductSearchBar
33
- ref="searchBarRef"
34
- v-model:search-in-progress="searchInProgress"
35
- />
36
- </div>
37
- <div v-if="searchBarRef?.showSuggest" class="flex flex-col gap-4 mt-4">
38
- <div v-if="!searchBarRef?.loading" class="flex flex-col gap-4">
39
- <ProductCard
40
- v-for="product in searchBarRef?.products"
41
- :key="product.id"
42
- :product="product"
43
- :with-favorite-button="true"
44
- :with-add-to-cart-button="true"
45
- />
46
- </div>
47
- </div>
48
- <div v-else>
49
- <ProductCategory
50
- v-for="category in navigationElements"
51
- :key="category.id"
52
- :category="category"
53
- />
54
- </div>
55
- </div>
56
- <div v-else>Loading...</div>
57
- </template>