feat(pages): add contact page

This commit is contained in:
2025-11-19 22:03:35 +01:00
parent 0b65e17903
commit 10e51b5da4
7 changed files with 230 additions and 7 deletions

View File

@@ -14,7 +14,17 @@ vi.mock('#app', () => ({
})); }));
// Mock $fetch globally // Mock $fetch globally
global.$fetch = vi.fn(); declare global {
var $fetch: ReturnType<typeof vi.fn>;
}
// global.$fetch = vi.fn();
vi.mock('#app', () => ({
useRuntimeConfig: vi.fn(() => ({
public: {
apiBase: 'http://localhost:3100/api'
}
}))
}))
describe('useApi', () => { describe('useApi', () => {
beforeEach(() => { beforeEach(() => {

View File

@@ -3,6 +3,8 @@ import type { ApiError } from '~/types/api/error';
import type { HttpMethod } from '~/types/http-method'; import type { HttpMethod } from '~/types/http-method';
import { QueryResult } from '~/types/query-result'; import { QueryResult } from '~/types/query-result';
export type UseApiResponse<T, B = unknown> = QueryResult<T, B>;
export interface UseApi { export interface UseApi {
get: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>; get: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;
del: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>; del: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;

View File

@@ -1,12 +1,13 @@
import type { ContactRequest, ContactResponse } from '~/types/api/contact'; import type { ContactRequest, ContactResponse } from '~/types/api/contact';
import type { MetaResponse } from '~/types/api/meta'; import type { MetaResponse } from '~/types/api/meta';
import type { UseApiResponse } from './useApi';
export const useBackend = () => { export const useBackend = () => {
const api = useApi(); const api = useApi();
const getMeta = (): UseApiResponse<MetaResponse> => api.get<MetaResponse>('/meta'); const getMeta = (): UseApiResponse<MetaResponse> => api.get<MetaResponse>('/meta');
const postContact = (): UseApiResponse<ContactResponse, ContactRequest> => const postContact = (): UseApiResponse<ContactResponse, ContactRequest> =>
api.post<ContactResponse, ContactRequest>('/contact', false); api.post<ContactResponse, ContactRequest>('/contact', undefined, true);
return { getMeta, postContact }; return { getMeta, postContact };
}; };

123
app/pages/contact.vue Normal file
View File

@@ -0,0 +1,123 @@
<template>
<NuxtLayout name="default">
<UPage>
<h1 class="text-4xl text-highlighted font-bold mb-8">
{{ $t('pages.contact.name') }}
</h1>
<UPageCard class="bg-background-100">
<UForm :schema="schema" :state="state" class="space-y-4" @submit="submitContactForm">
<div class="flex flex-row w-full gap-5">
<UFormField
:label="$t('pages.contact.form.labels.name')"
name="name"
class="w-full"
:ui="{ label: 'text-text text-lg text-bold' }"
>
<UInput
v-model="state.name"
autofocus
:ui="{
root: 'relative inline-flex items-center w-full',
base: 'placeholder:text-300',
}"
required
:placeholder="$t('pages.contact.form.placeholders.name')"
/>
</UFormField>
<UFormField
:label="$t('pages.contact.form.labels.email')"
name="email"
class="w-full"
:ui="{ label: 'text-text text-lg text-bold' }"
>
<UInput
v-model="state.email"
type="email"
:ui="{
root: 'relative inline-flex items-center w-full',
base: 'placeholder:text-300',
}"
required
:placeholder="$t('pages.contact.form.placeholders.email')"
/>
</UFormField>
</div>
<UFormField
class="w-full"
name="website"
:label="$t('pages.contact.form.honeypot')"
:ui="{ label: 'text-text text-lg text-bold' }"
>
<UInput
:ui="{ root: 'relative inline-flex items-center w-full', base: 'placeholder:text-300' }"
:placeholder="$t('pages.contact.form.placeholders.honeypot')"
/>
</UFormField>
<UFormField
v-model="state.website"
:label="$t('pages.contact.form.labels.message')"
name="message"
:ui="{ label: 'text-text text-lg text-bold' }"
>
<UTextarea
v-model="state.message"
:ui="{
root: 'relative inline-flex items-center w-full',
base: 'placeholder:text-300',
}"
:placeholder="$t('pages.contact.form.placeholders.message')"
/>
</UFormField>
<UButton
icon="mdi:send-outline"
color="primary"
type="submit"
class="w-full text-center text-lg justify-center-safe"
size="lg"
>
{{ $t('pages.contact.form.sendButton') }}
</UButton>
</UForm>
</UPageCard>
</UPage>
</NuxtLayout>
</template>
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui';
import { z } from 'zod';
const { postContact } = useBackend();
const schema = z.object({
email: z.email($t('pages.contact.form.validation.invalidEmail')),
name: z
.string()
.min(1, $t('pages.contact.form.validation.shortName'))
.max(100, $t('pages.contact.form.validation.longName')),
message: z
.string()
.min(10, $t('pages.contact.form.validation.shortMessage'))
.max(5000, $t('pages.contact.form.validation.longMessage')),
website: z.string().optional(),
});
type Schema = z.output<typeof schema>;
const state = reactive<Partial<Schema>>({
name: undefined,
email: undefined,
message: undefined,
website: undefined,
});
const { data: contactResponse, error: contactError, loading, run: sendRequest } = postContact();
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);
});
</script>

View File

@@ -38,6 +38,28 @@
"name": "Languages & Worldbuilding" "name": "Languages & Worldbuilding"
}, },
"contact": { "contact": {
"form": {
"sendButton": "Send Message",
"validation": {
"shortName": "Must contain at least one character",
"longName": "Cannot exceed 100 characters",
"shortMessage": "Must contain at least 10 characters",
"longMessage": "Cannot exceed 5000 characters",
"invalidEmail": "Invalid email address format"
},
"labels": {
"name": "Name",
"email": "Email Address",
"message": "Message",
"website": "Website"
},
"placeholders": {
"name": "Alex Taylor",
"email": "alex.taylor[at]example.com",
"message": "Hello, ...",
"website": "https://example.com"
}
},
"name": "Contact" "name": "Contact"
} }
}, },
@@ -51,5 +73,25 @@
"frontend": "Frontend Version", "frontend": "Frontend Version",
"backend": "Backend Version" "backend": "Backend Version"
} }
},
"backend": {
"failed": "Error",
"errors": {
"title": "There was an error",
"unknown": "The website encountered an unknown error. Please try again later."
},
"contact": {
"success": "Email sent! Weve also sent you a confirmation email!",
"honeypot": "Mmmmmh, I love me some honey from the honeypot!",
"errors": {
"internal": "The website encountered an internal error. Please try again later.",
"validation": {
"name": "Incorrect name format. Must contain from 1 to 50 characters.",
"email": "Incorrect email format.",
"message": "Incorrect message format. Must contain from 10 to 5000 characters.",
"other": "Malformed request."
}
}
}
} }
} }

View File

@@ -38,7 +38,29 @@
"name": "Langues et Univers Fictifs" "name": "Langues et Univers Fictifs"
}, },
"contact": { "contact": {
"name": "Contact" "name": "Contact",
"form": {
"sendButton": "Envoyer le message",
"validation": {
"shortName": "Longueur minimale du nom : 1 caractère",
"longName": "Longeur maximale du nom : 100 caractères",
"shortMessage": "Longueur minimale du message : 10 caractères",
"longMessage": "Longueur maximale du message : 5000 caractères",
"invalidEmail": "Format adresse courriel invalide"
},
"labels": {
"name": "Nom",
"email": "Addresse courriel",
"message": "Message",
"website": "Site web"
},
"placeholders": {
"name": "Dominique Dubois",
"email": "dominique.dubois[at]example.com",
"message": "Bonjour, ...",
"website": "https://example.com"
}
}
} }
}, },
"footer": { "footer": {
@@ -48,8 +70,28 @@
"rust": "Backend fait avec Rust" "rust": "Backend fait avec Rust"
}, },
"versions": { "versions": {
"frontend": "Frontend Version", "frontend": "Version frontend",
"backend": "Backend Version" "backend": "Version backend"
}
},
"backend": {
"failed": "Erreur",
"errors": {
"title": "Une erreur est survenue",
"unknown": "Une erreur inconnue est survenue. Veuillez réessayer plus tard."
},
"contact": {
"success": "Message envoyé ! Un email de confirmation vous a été également envoyé.",
"honeypot": "Miam, du bon miel pour le robot !",
"errors": {
"internal": "Une erreur interne est survenue, veuillez réessayer plus tard.",
"validation": {
"name": "Format du nom incorrect. Doit faire de 1 à 50 caractères.",
"email": "Format de ladresse courriel invalide.",
"message": "Format du message invalide. Doit faire entre 10 et 5000 caractères.",
"other": "Données de la requête malformées."
}
}
} }
} }
} }

View File

@@ -5,6 +5,10 @@ export default defineNuxtConfig({
enabled: true, enabled: true,
vueDevTools: true, vueDevTools: true,
telemetry: false, telemetry: false,
timeline: {
enabled: true
}
}, },
modules: [ modules: [
@@ -66,7 +70,6 @@ export default defineNuxtConfig({
} }
}, },
turnstile: { turnstile: {
siteKey: '', // Overridden by NUXT_PUBLIC_TURNSTILE_SITE_KEY
addValidateEndpoint: true addValidateEndpoint: true
}, },
runtimeConfig: { runtimeConfig: {
@@ -76,5 +79,5 @@ export default defineNuxtConfig({
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3100/api', apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3100/api',
} }
}, }
}); });