@shopbite-de/storefront 1.14.4 → 1.15.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/app/components/Contact/Form.vue +220 -0
- package/app/pages/kontakt.vue +23 -0
- package/nuxt.config.ts +1 -0
- package/package.json +1 -1
- package/test/nuxt/ContactForm.test.ts +187 -0
|
@@ -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>
|
|
@@ -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>
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -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
|
+
});
|