@shopbite-de/storefront 1.14.4 → 1.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.
@@ -118,7 +118,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
118
118
  <UPage>
119
119
  <template #left>
120
120
  <UPageAside>
121
- <NavigationDesktopLeft2 />
121
+ <NavigationDesktopLeft />
122
122
  </UPageAside>
123
123
  </template>
124
124
 
@@ -212,7 +212,7 @@ const moreThanOneFilterAndOption = computed<boolean>(
212
212
  <template #right>
213
213
  <UPageAside>
214
214
  <div v-if="moreThanOneFilterAndOption" class="flex flex-col gap-4">
215
- <h2 class="text-3xl md:text-4xl mt-8 mb-3 pb-2">Filter</h2>
215
+ <h2 class="text-3xl md:text-4xl mb-3 pb-2">Filter</h2>
216
216
  <div
217
217
  v-for="filter in propertyFilters"
218
218
  :key="filter.id"
@@ -0,0 +1,220 @@
1
+ <script setup lang="ts">
2
+ import { z } from "zod";
3
+ import type { FormSubmitEvent } from "#ui/types";
4
+ import { useSalutations } from "@shopware/composables";
5
+
6
+ const { apiClient } = useShopwareContext();
7
+ const { getSalutations } = useSalutations();
8
+ const toast = useToast();
9
+
10
+ const salutations = computed(() =>
11
+ getSalutations.value.map((salutation) => ({
12
+ label: salutation.displayName,
13
+ value: salutation.id,
14
+ })),
15
+ );
16
+
17
+ const schema = z.object({
18
+ salutationId: z.string().optional(),
19
+ firstName: z.string().optional(),
20
+ lastName: z.string().optional(),
21
+ email: z.string().email("Ungültige E-Mail-Adresse"),
22
+ phone: z.string().optional(),
23
+ subject: z.string().min(3, "Bitte gib einen Betreff an"),
24
+ comment: z
25
+ .string()
26
+ .min(10, "Die Nachricht muss mindestens 10 Zeichen lang sein"),
27
+ hp: z.string().optional(),
28
+ });
29
+
30
+ type Schema = z.output<typeof schema>;
31
+
32
+ const state = reactive({
33
+ salutationId: "",
34
+ firstName: "",
35
+ lastName: "",
36
+ email: "",
37
+ phone: "",
38
+ subject: "",
39
+ comment: "",
40
+ hp: "",
41
+ });
42
+
43
+ const loading = ref(false);
44
+ const submitted = ref(false);
45
+ const successMessage = ref("");
46
+
47
+ async function onSubmit(event: FormSubmitEvent<Schema>) {
48
+ if (event.data.hp) {
49
+ console.warn("Honeypot filled, submission ignored.");
50
+ successMessage.value = "Deine Nachricht wurde erfolgreich versendet.";
51
+ submitted.value = true;
52
+ return;
53
+ }
54
+ loading.value = true;
55
+ const salutation = event.data.salutationId || salutations.value.at(-1)?.value;
56
+ try {
57
+ const result = await apiClient.invoke(
58
+ "sendContactMail post /contact-form",
59
+ {
60
+ body: {
61
+ salutationId: salutation,
62
+ firstName: event.data.firstName,
63
+ lastName: event.data.lastName,
64
+ email: event.data.email,
65
+ phone: event.data.phone,
66
+ subject: event.data.subject,
67
+ comment: event.data.comment,
68
+ },
69
+ },
70
+ );
71
+
72
+ const msg =
73
+ typeof result?.data?.individualSuccessMessage === "string"
74
+ ? result.data.individualSuccessMessage.trim()
75
+ : "";
76
+ successMessage.value =
77
+ msg && msg.length > 0
78
+ ? msg
79
+ : "Deine Nachricht wurde erfolgreich versendet.";
80
+ submitted.value = true;
81
+
82
+ toast.add({
83
+ title: "Erfolg!",
84
+ description: successMessage.value,
85
+ color: "success",
86
+ });
87
+
88
+ // Reset form
89
+ state.salutationId = "";
90
+ state.firstName = "";
91
+ state.lastName = "";
92
+ state.email = "";
93
+ state.phone = "";
94
+ state.subject = "";
95
+ state.comment = "";
96
+ state.hp = "";
97
+ } catch (error) {
98
+ console.error("Error sending contact mail:", error);
99
+ toast.add({
100
+ title: "Fehler!",
101
+ description:
102
+ "Deine Nachricht konnte nicht versendet werden. Bitte versuche es später erneut.",
103
+ color: "error",
104
+ });
105
+ } finally {
106
+ loading.value = false;
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <template>
112
+ <div v-if="submitted" class="space-y-4 text-center">
113
+ <UAlert
114
+ color="success"
115
+ variant="soft"
116
+ icon="i-lucide-check-circle"
117
+ :title="successMessage"
118
+ />
119
+ <UButton variant="link" @click="submitted = false">
120
+ Weiteres Formular senden
121
+ </UButton>
122
+ </div>
123
+
124
+ <UForm
125
+ v-else
126
+ :schema="schema"
127
+ :state="state"
128
+ class="space-y-4"
129
+ @submit="onSubmit"
130
+ >
131
+ <UFormField label="Anrede" name="salutationId">
132
+ <USelect
133
+ v-model="state.salutationId"
134
+ :items="salutations"
135
+ placeholder="Bitte wählen"
136
+ class="w-full"
137
+ />
138
+ </UFormField>
139
+
140
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
141
+ <UFormField label="Vorname" name="firstName">
142
+ <UInput
143
+ v-model="state.firstName"
144
+ placeholder="Dein Vorname"
145
+ class="w-full"
146
+ />
147
+ </UFormField>
148
+
149
+ <UFormField label="Nachname" name="lastName">
150
+ <UInput
151
+ v-model="state.lastName"
152
+ placeholder="Dein Nachname"
153
+ class="w-full"
154
+ />
155
+ </UFormField>
156
+ </div>
157
+
158
+ <UFormField label="E-Mail" name="email" required>
159
+ <UInput
160
+ v-model="state.email"
161
+ type="email"
162
+ placeholder="Deine E-Mail-Adresse"
163
+ class="w-full"
164
+ />
165
+ </UFormField>
166
+
167
+ <UFormField label="Telefon" name="phone">
168
+ <UInput
169
+ v-model="state.phone"
170
+ type="tel"
171
+ placeholder="Deine Telefonnummer"
172
+ class="w-full"
173
+ />
174
+ </UFormField>
175
+
176
+ <UFormField label="Betreff" name="subject" required>
177
+ <UInput
178
+ v-model="state.subject"
179
+ placeholder="Worum geht es?"
180
+ class="w-full"
181
+ />
182
+ </UFormField>
183
+
184
+ <UFormField label="Nachricht" name="comment" required>
185
+ <UTextarea
186
+ v-model="state.comment"
187
+ placeholder="Wie können wir dir helfen?"
188
+ class="w-full"
189
+ />
190
+ </UFormField>
191
+
192
+ <!-- Honeypot field -->
193
+ <div class="hidden-field" aria-hidden="true">
194
+ <UFormField label="Address" name="hp">
195
+ <UInput
196
+ v-model="state.hp"
197
+ type="text"
198
+ placeholder="Address"
199
+ tabindex="-1"
200
+ autocomplete="off"
201
+ />
202
+ </UFormField>
203
+ </div>
204
+
205
+ <UButton type="submit" :loading="loading" block> Absenden </UButton>
206
+ </UForm>
207
+ </template>
208
+
209
+ <style scoped>
210
+ .hidden-field {
211
+ opacity: 0;
212
+ position: absolute;
213
+ top: 0;
214
+ left: 0;
215
+ height: 0;
216
+ width: 0;
217
+ z-index: -1;
218
+ overflow: hidden;
219
+ }
220
+ </style>
@@ -3,7 +3,7 @@ import type { NavigationMenuItem } from "@nuxt/ui";
3
3
  import type { Schemas } from "#shopware";
4
4
  import { useNavigation } from "~/composables/useNavigation";
5
5
 
6
- const { mainNavigation } = useNavigation(false);
6
+ const { menuCardNavigation } = useNavigation(true);
7
7
 
8
8
  const mapCategoryToNavItem = (
9
9
  category: Schemas["Category"],
@@ -21,13 +21,13 @@ const mapCategoryToNavItem = (
21
21
  };
22
22
 
23
23
  const navItems = computed<NavigationMenuItem[]>(() => {
24
- return (mainNavigation.value ?? []).map(mapCategoryToNavItem);
24
+ return (menuCardNavigation.value ?? []).map(mapCategoryToNavItem);
25
25
  });
26
26
  </script>
27
27
 
28
28
  <template>
29
29
  <div>
30
- <h2 class="text-3xl md:text-4xl mt-8 mb-3 pb-2">Speisekarte</h2>
30
+ <h2 class="text-3xl md:text-4xl mb-3 pb-2">Speisekarte</h2>
31
31
  <UNavigationMenu
32
32
  variant="link"
33
33
  orientation="vertical"
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { useNavigation } from "~/composables/useNavigation";
3
+
4
+ const { menuCardMenu } = useNavigation(true);
5
+ </script>
6
+
7
+ <template>
8
+ <UNavigationMenu
9
+ class="lg:hidden"
10
+ orientation="horizontal"
11
+ :items="menuCardMenu"
12
+ :ui="{
13
+ list: 'overflow-x-auto',
14
+ item: 'flex-shrink-0',
15
+ }"
16
+ >
17
+ <template #list-leading>
18
+ <UIcon name="i-lucide-chevron-left" class="size-8" />
19
+ </template>
20
+ <template #list-trailing>
21
+ <UIcon name="i-lucide-chevron-right" class="size-8" />
22
+ </template>
23
+ </UNavigationMenu>
24
+ </template>
@@ -4,6 +4,10 @@ import type { Schemas } from "#shopware";
4
4
 
5
5
  export function useNavigation(withChildren: boolean | undefined) {
6
6
  const { apiClient } = useShopwareContext();
7
+ const config = useRuntimeConfig();
8
+ const menuCategoryId = computed(
9
+ () => config.public.shopBite.menuCategoryId ?? "main-navigation",
10
+ );
7
11
 
8
12
  const criteria = encodeForQuery({
9
13
  includes: {
@@ -77,10 +81,36 @@ export function useNavigation(withChildren: boolean | undefined) {
77
81
  return footerNavigation.value?.map(mapCategoryToMenuItem);
78
82
  });
79
83
 
84
+ const { data: menuCardNavigation } = useAsyncData(
85
+ "menu-category",
86
+ async () => {
87
+ const response = await apiClient.invoke(
88
+ "readNavigationGet get /navigation/{activeId}/{rootId}",
89
+ {
90
+ query: { _criteria: criteria },
91
+ pathParams: {
92
+ activeId: "main-navigation",
93
+ rootId: menuCategoryId.value,
94
+ },
95
+ },
96
+ );
97
+
98
+ return response.data;
99
+ },
100
+ );
101
+
102
+ const menuCardMenu = computed<NavigationMenuItem[]>(() => {
103
+ if (!menuCardNavigation.value) return [];
104
+
105
+ return menuCardNavigation.value?.map(mapCategoryToMenuItem);
106
+ });
107
+
80
108
  return {
81
109
  mainNavigation,
82
110
  mainMenu,
83
111
  footerNavigation,
84
112
  footerMenu,
113
+ menuCardNavigation,
114
+ menuCardMenu,
85
115
  };
86
116
  }
@@ -0,0 +1,68 @@
1
+ import { useSessionContext, useShopwareContext } from "#imports";
2
+ import type { Schemas } from "#shopware";
3
+ import { encodeForQuery } from "@shopware/api-client/helpers";
4
+
5
+ export type UseNavigationSearchReturn = {
6
+ /**
7
+ * Get {@link SeoUrl} entity for given path
8
+ * @example resolvePath("/my-category/my-product") or resolvePath("/") for home page
9
+ */
10
+ resolvePath(path: string): Promise<Schemas["SeoUrl"] | null>;
11
+ };
12
+
13
+ /**
14
+ * Composable to get search for SeoUrl entity for given path.
15
+ * @public
16
+ * @category Navigation & Routing
17
+ */
18
+ export function useNavigationSearch(): UseNavigationSearchReturn {
19
+ const { apiClient } = useShopwareContext();
20
+ const { sessionContext } = useSessionContext();
21
+
22
+ async function resolvePath(path: string): Promise<Schemas["SeoUrl"] | null> {
23
+ if (path === "/") {
24
+ // please ignore optional chaining for salesChannel object as it's always present (type definition issue)
25
+ const categoryId =
26
+ sessionContext.value?.salesChannel?.navigationCategoryId;
27
+
28
+ return {
29
+ routeName: "frontend.navigation.page",
30
+ foreignKey: categoryId,
31
+ } as Schemas["SeoUrl"];
32
+ }
33
+
34
+ const isTechnicalUrl =
35
+ path.startsWith("/navigation/") ||
36
+ path.startsWith("/detail/") ||
37
+ path.startsWith("/landingPage/");
38
+
39
+ // remove leading slash in case of seo url or remove trailing slash in case of technical url
40
+ const normalizedPath = isTechnicalUrl
41
+ ? path.endsWith("/")
42
+ ? path.slice(0, -1)
43
+ : path
44
+ : path.substring(1);
45
+
46
+ const criteria = encodeForQuery({
47
+ filter: [
48
+ {
49
+ type: "equals",
50
+ field: isTechnicalUrl ? "pathInfo" : "seoPathInfo",
51
+ value: normalizedPath,
52
+ },
53
+ ],
54
+ });
55
+
56
+ const seoResult = await apiClient.invoke("readSeoUrlGet get /seo-url", {
57
+ query: {
58
+ _criteria: criteria,
59
+ },
60
+ });
61
+
62
+ return seoResult.data.elements?.[0] ?? null;
63
+ }
64
+
65
+ return {
66
+ resolvePath,
67
+ };
68
+ }
@@ -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 :should-skip-first-level="true" />
6
+ <NavigationMobileTop />
7
7
  </div>
8
8
  <slot />
9
9
  </UPage>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import ContactForm from "~/components/Contact/Form.vue";
3
+ const config = useRuntimeConfig();
4
+
5
+ if (config.public.shopBite.feature.contactForm !== true) {
6
+ throw createError({
7
+ statusCode: 404,
8
+ statusMessage: "Page not found",
9
+ fatal: true,
10
+ });
11
+ }
12
+ </script>
13
+
14
+ <template>
15
+ <UPageSection
16
+ title="Sie haben eine Frage oder Anregung?Wir freuen uns auf Ihre Nachricht."
17
+ description="Bitte beachten Sie, dass wir Tischreservierung oder Tischstornierung nur telefonisch entgegennehmen können."
18
+ icon="i-lucide-mail"
19
+ orientation="horizontal"
20
+ >
21
+ <ContactForm />
22
+ </UPageSection>
23
+ </template>
@@ -2,8 +2,9 @@
2
2
  import type { Schemas } from "#shopware";
3
3
 
4
4
  definePageMeta({
5
- layout: "listing2",
5
+ layout: "listing",
6
6
  });
7
+
7
8
  const { clearBreadcrumbs } = useBreadcrumbs();
8
9
  const { resolvePath } = useNavigationSearch();
9
10
  const route = useRoute();
package/nuxt.config.ts CHANGED
@@ -55,9 +55,11 @@ export default defineNuxtConfig({
55
55
  geoapifyApiKey: "",
56
56
  public: {
57
57
  shopBite: {
58
+ menuCategoryId: "",
58
59
  feature: {
59
60
  multiChannel: "",
60
61
  secureKey: "",
62
+ contactForm: false,
61
63
  },
62
64
  },
63
65
  site: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shopbite-de/storefront",
3
- "version": "1.14.4",
3
+ "version": "1.16.0",
4
4
  "main": "nuxt.config.ts",
5
5
  "description": "Shopware storefront for food delivery shops",
6
6
  "keywords": [
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
3
+ import ContactForm from "~/components/Contact/Form.vue";
4
+
5
+ const { mockInvoke } = vi.hoisted(() => {
6
+ return {
7
+ mockInvoke: vi.fn(),
8
+ };
9
+ });
10
+
11
+ mockNuxtImport("useShopwareContext", () => () => ({
12
+ apiClient: {
13
+ invoke: mockInvoke,
14
+ },
15
+ }));
16
+
17
+ mockNuxtImport("useSalutations", () => () => ({
18
+ getSalutations: ref([
19
+ { id: "1", displayName: "Herr" },
20
+ { id: "2", displayName: "Frau" },
21
+ ]),
22
+ }));
23
+
24
+ const mockToastAdd = vi.fn();
25
+ mockNuxtImport("useToast", () => () => ({
26
+ add: mockToastAdd,
27
+ }));
28
+
29
+ describe("ContactForm", () => {
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it("submits the form and shows success message component with custom individualSuccessMessage", async () => {
35
+ const customMessage = "Vielen Dank für deine Nachricht!";
36
+ mockInvoke.mockResolvedValueOnce({
37
+ data: {
38
+ individualSuccessMessage: customMessage,
39
+ apiAlias: "contact_form_result",
40
+ },
41
+ });
42
+
43
+ const wrapper = await mountSuspended(ContactForm);
44
+
45
+ const validData = {
46
+ salutationId: "1",
47
+ firstName: "Max",
48
+ lastName: "Mustermann",
49
+ email: "max@example.com",
50
+ phone: "01234 567890",
51
+ subject: "Frage zum Produkt",
52
+ comment: "Ich habe eine Frage zu Ihrem Produkt.",
53
+ };
54
+
55
+ await (wrapper.vm as any).onSubmit({ data: validData });
56
+
57
+ expect(mockInvoke).toHaveBeenCalled();
58
+ expect(mockToastAdd).toHaveBeenCalledWith(
59
+ expect.objectContaining({
60
+ description: customMessage,
61
+ color: "success",
62
+ }),
63
+ );
64
+
65
+ // Verify submitted state
66
+ expect((wrapper.vm as any).submitted).toBe(true);
67
+ expect((wrapper.vm as any).successMessage).toBe(customMessage);
68
+
69
+ // Wait for DOM update
70
+ await nextTick();
71
+
72
+ // Check if success message is displayed in the template
73
+ expect(wrapper.text()).toContain(customMessage);
74
+ expect(wrapper.text()).toContain("Weiteres Formular senden");
75
+ expect(wrapper.find("form").exists()).toBe(false);
76
+
77
+ // Click on "Weiteres Formular senden"
78
+ await wrapper.find("button").trigger("click");
79
+ expect((wrapper.vm as any).submitted).toBe(false);
80
+
81
+ await nextTick();
82
+ expect(wrapper.find("form").exists()).toBe(true);
83
+ });
84
+
85
+ it("submits the form and shows default success message if individualSuccessMessage is empty", async () => {
86
+ mockInvoke.mockResolvedValueOnce({
87
+ data: {
88
+ individualSuccessMessage: "",
89
+ apiAlias: "contact_form_result",
90
+ },
91
+ });
92
+
93
+ const wrapper = await mountSuspended(ContactForm);
94
+
95
+ const validData = {
96
+ salutationId: "1",
97
+ firstName: "Max",
98
+ lastName: "Mustermann",
99
+ email: "max@example.com",
100
+ phone: "01234 567890",
101
+ subject: "Frage zum Produkt",
102
+ comment: "Ich habe eine Frage zu Ihrem Produkt.",
103
+ };
104
+
105
+ await (wrapper.vm as any).onSubmit({ data: validData });
106
+
107
+ const defaultMessage = "Deine Nachricht wurde erfolgreich versendet.";
108
+ expect((wrapper.vm as any).successMessage).toBe(defaultMessage);
109
+
110
+ await nextTick();
111
+ expect(wrapper.text()).toContain(defaultMessage);
112
+ });
113
+
114
+ it("submits the form with only mandatory fields", async () => {
115
+ mockInvoke.mockResolvedValueOnce({
116
+ data: {
117
+ individualSuccessMessage: "",
118
+ apiAlias: "contact_form_result",
119
+ },
120
+ });
121
+
122
+ const wrapper = await mountSuspended(ContactForm);
123
+
124
+ const mandatoryDataOnly = {
125
+ email: "test@example.com",
126
+ subject: "Nur Betreff",
127
+ comment: "Dies ist ein Test mit nur Pflichtfeldern.",
128
+ };
129
+
130
+ await (wrapper.vm as any).onSubmit({ data: mandatoryDataOnly });
131
+
132
+ expect(mockInvoke).toHaveBeenCalledWith(
133
+ "sendContactMail post /contact-form",
134
+ {
135
+ body: expect.objectContaining({
136
+ email: "test@example.com",
137
+ subject: "Nur Betreff",
138
+ comment: "Dies ist ein Test mit nur Pflichtfeldern.",
139
+ }),
140
+ },
141
+ );
142
+
143
+ expect((wrapper.vm as any).submitted).toBe(true);
144
+ });
145
+
146
+ it("shows error toast when submission fails", async () => {
147
+ mockInvoke.mockRejectedValueOnce(new Error("Network error"));
148
+
149
+ const wrapper = await mountSuspended(ContactForm);
150
+
151
+ const validData = {
152
+ salutationId: "2",
153
+ firstName: "Erika",
154
+ lastName: "Musterfrau",
155
+ email: "erika@example.com",
156
+ phone: "09876 54321",
157
+ subject: "Supportanfrage",
158
+ comment: "Bitte um Unterstützung.",
159
+ };
160
+
161
+ await (wrapper.vm as any).onSubmit({ data: validData });
162
+
163
+ expect(mockInvoke).toHaveBeenCalled();
164
+
165
+ expect(mockToastAdd).toHaveBeenCalledWith(
166
+ expect.objectContaining({
167
+ title: "Fehler!",
168
+ color: "error",
169
+ }),
170
+ );
171
+ });
172
+
173
+ it("does not call API but marks form as submitted when honeypot is filled", async () => {
174
+ mockInvoke.mockClear();
175
+ mockToastAdd.mockClear();
176
+ const wrapper = await mountSuspended(ContactForm);
177
+ const botData = {
178
+ email: "bot@example.com",
179
+ subject: "Bot request",
180
+ comment: "This is spam.",
181
+ hp: "I am a bot",
182
+ };
183
+ await (wrapper.vm as any).onSubmit({ data: botData });
184
+ expect(mockInvoke).not.toHaveBeenCalled();
185
+ expect((wrapper.vm as any).submitted).toBe(true);
186
+ });
187
+ });
@@ -1,61 +0,0 @@
1
- <script setup lang="ts">
2
- import { useNavigation } from "~/composables/useNavigation";
3
- import type { NavigationMenuItem } from "@nuxt/ui";
4
- import type { Schemas } from "#shopware";
5
-
6
- type Category = Schemas["Category"];
7
-
8
- const props = withDefaults(
9
- defineProps<{
10
- shouldSkipFirstLevel?: boolean;
11
- }>(),
12
- {
13
- shouldSkipFirstLevel: false,
14
- },
15
- );
16
-
17
- const { mainNavigation } = useNavigation(true);
18
-
19
- const navItems = computed<NavigationMenuItem[]>(() => {
20
- const elements = mainNavigation.value ?? [];
21
-
22
- const mapCategoryRecursively = (category: Category): NavigationMenuItem => {
23
- const hasChildren = (category.children?.length ?? 0) > 0;
24
-
25
- return {
26
- label: category.translated?.name ?? "",
27
- to: hasChildren ? undefined : category.seoUrl,
28
- children: hasChildren
29
- ? category.children!.map(mapCategoryRecursively)
30
- : undefined,
31
- };
32
- };
33
-
34
- if (props.shouldSkipFirstLevel) {
35
- return elements
36
- .flatMap((item: Category) => item.children ?? [])
37
- .map(mapCategoryRecursively);
38
- }
39
-
40
- return elements.map(mapCategoryRecursively);
41
- });
42
- </script>
43
-
44
- <template>
45
- <UNavigationMenu
46
- class="lg:hidden"
47
- orientation="horizontal"
48
- :items="navItems"
49
- :ui="{
50
- list: 'overflow-x-auto',
51
- item: 'flex-shrink-0',
52
- }"
53
- >
54
- <template #list-leading>
55
- <UIcon name="i-lucide-chevron-left" class="size-8" />
56
- </template>
57
- <template #list-trailing>
58
- <UIcon name="i-lucide-chevron-right" class="size-8" />
59
- </template>
60
- </UNavigationMenu>
61
- </template>