feat(contact): add toast notifications for form feedback

- Add toast notifications for contact form success/error responses
- Add toast notifications for backend errors in AppFooter
- Add accessibility explanation for honeypot field
- Add loading state to contact form submit button
- Add i18n translations for toast messages (en/fr)
- Fix honeypot input missing v-model binding
This commit is contained in:
2026-02-04 15:19:08 +01:00
parent 10e51b5da4
commit 37972aa660
6 changed files with 57 additions and 17 deletions

View File

@@ -27,6 +27,7 @@
import type { NavigationMenuItem } from '@nuxt/ui';
import { version } from '../../package.json';
const toast = useToast();
const { isMobile } = useDevice();
const orientation = computed(() => (isMobile ? 'vertical' : 'horizontal'));
const { getMeta } = useBackend();
@@ -48,4 +49,13 @@ const items = computed<NavigationMenuItem[]>(() => [
to: 'https://rust-lang.org/',
},
]);
watch(error, (value) => {
if (value) {
toast.add({
title: $t('backend.errors.title'),
description: $t(value.message ?? 'backend.errors.unknown'),
color: 'error',
});
}
});
</script>

View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { nextTick } from 'vue';
import type { FetchError } from 'ofetch';
@@ -14,10 +16,6 @@ vi.mock('#app', () => ({
}));
// Mock $fetch globally
declare global {
var $fetch: ReturnType<typeof vi.fn>;
}
// global.$fetch = vi.fn();
vi.mock('#app', () => ({
useRuntimeConfig: vi.fn(() => ({
public: {

View File

@@ -7,7 +7,7 @@ export const useBackend = () => {
const getMeta = (): UseApiResponse<MetaResponse> => api.get<MetaResponse>('/meta');
const postContact = (): UseApiResponse<ContactResponse, ContactRequest> =>
api.post<ContactResponse, ContactRequest>('/contact', undefined, true);
api.post<ContactResponse, ContactRequest>('/contact', undefined, false);
return { getMeta, postContact };
};

View File

@@ -43,18 +43,27 @@
</UFormField>
</div>
<UFormField
class="w-full"
class="w-full sr-only"
name="website"
:label="$t('pages.contact.form.honeypot')"
:label="$t('pages.contact.form.labels.website')"
:ui="{ label: 'text-text text-lg text-bold' }"
tabindex="-1"
>
<div>
If you see this input, you may be using accessibility tools to access this website. This input is meant to
be hidden to human visitors, but not bots which do not necessarily render the website the way it is meant
to. Unfortunately, this also affects accessibility tools, such as the ones for visually-impared people. If
that is indeed, please ignore this input, as it is not meant to be filled by human beings. Filling this
input will result in a discarded contact form.
</div>
<UInput
v-model="state.website"
:ui="{ root: 'relative inline-flex items-center w-full', base: 'placeholder:text-300' }"
:placeholder="$t('pages.contact.form.placeholders.honeypot')"
:placeholder="$t('pages.contact.form.placeholders.website')"
tabindex="-1"
/>
</UFormField>
<UFormField
v-model="state.website"
:label="$t('pages.contact.form.labels.message')"
name="message"
:ui="{ label: 'text-text text-lg text-bold' }"
@@ -74,6 +83,7 @@
type="submit"
class="w-full text-center text-lg justify-center-safe"
size="lg"
:loading="loading"
>
{{ $t('pages.contact.form.sendButton') }}
</UButton>
@@ -86,6 +96,7 @@
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui';
import { z } from 'zod';
const toast = useToast();
const { postContact } = useBackend();
@@ -115,9 +126,22 @@ const { data: contactResponse, error: contactError, loading, run: sendRequest }
const submitContactForm = async (event: FormSubmitEvent<Schema>) => await sendRequest!(event.data);
watchEffect(() => {
if (loading.value) console.log('loading');
if (contactResponse.value) console.log('Response:', contactResponse.value.message);
if (contactError.value) console.error('Error', contactError.value);
watch(contactResponse, async (response) => {
if (response) {
toast.add({
title: response.success ? $t('pages.contact.toast.success') : $t('pages.contact.toast.error'),
description: $t(response.message),
color: response.success ? 'info' : 'error',
});
}
});
watch(contactError, async (response) => {
if (response) {
toast.add({
title: $t('pages.contact.toast.error'),
description: $t(response.message),
color: 'error',
});
}
});
</script>

View File

@@ -38,6 +38,11 @@
"name": "Languages & Worldbuilding"
},
"contact": {
"name": "Contact",
"toast": {
"success": "Email sent!",
"error": "Failure sending message"
},
"form": {
"sendButton": "Send Message",
"validation": {
@@ -59,8 +64,7 @@
"message": "Hello, ...",
"website": "https://example.com"
}
},
"name": "Contact"
}
}
},
"footer": {
@@ -81,7 +85,7 @@
"unknown": "The website encountered an unknown error. Please try again later."
},
"contact": {
"success": "Email sent! Weve also sent you a confirmation email!",
"success": "Weve also sent you a confirmation email. If you havent received anything in a few minutes, please check your junk mail.",
"honeypot": "Mmmmmh, I love me some honey from the honeypot!",
"errors": {
"internal": "The website encountered an internal error. Please try again later.",

View File

@@ -39,6 +39,10 @@
},
"contact": {
"name": "Contact",
"toast": {
"success": "Couriel envoyé !",
"error": "Erreur lors de l'envoi du message"
},
"form": {
"sendButton": "Envoyer le message",
"validation": {
@@ -81,7 +85,7 @@
"unknown": "Une erreur inconnue est survenue. Veuillez réessayer plus tard."
},
"contact": {
"success": "Message envoyé ! Un email de confirmation vous a été également envoyé.",
"success": "Un email de confirmation vous a été également envoyé. Si vous navez rien reçu dici quelques minutes, vérifiez vos spams.",
"honeypot": "Miam, du bon miel pour le robot !",
"errors": {
"internal": "Une erreur interne est survenue, veuillez réessayer plus tard.",