feat(fullstack): Add contact form

This commit is contained in:
2026-02-08 18:21:07 +01:00
parent 43aef3b5b1
commit 36132a3bef
12 changed files with 420 additions and 11 deletions

View File

@@ -0,0 +1,58 @@
<template>
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
<UEditor
v-slot="{ editor }"
v-model="content"
content-type="html"
:editable="!props.disabled"
:placeholder="props.placeholder"
:ui="{
content: 'bg-white dark:bg-white',
base: 'min-h-[200px] p-3 bg-white dark:bg-white'
}"
class="w-full"
>
<UEditorToolbar
:editor="editor"
:items="toolbarItems"
class="border-b border-muted sticky top-0 inset-x-0 px-3 py-2 z-50 bg-default overflow-x-auto"
/>
</UEditor>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
disabled?: boolean
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const content = computed({
get: () => props.modelValue,
set: (newValue: string) => {
emit('update:modelValue', newValue)
}
})
const toolbarItems = [
[
{ kind: 'undo', icon: 'i-lucide-undo' },
{ kind: 'redo', icon: 'i-lucide-redo' }
],
[
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' }
],
[
{ kind: 'bulletList', icon: 'i-lucide-list' },
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
],
[{ kind: 'link', icon: 'i-lucide-link' }]
]
</script>

View File

@@ -0,0 +1,13 @@
import { useContactApi } from './useContactApi'
export function useContact() {
const { sendContactMessage: sendContactMessageApi } = useContactApi()
async function sendContactMessage(subject: string, message: string): Promise<void> {
return sendContactMessageApi({ subject, message })
}
return {
sendContactMessage
}
}

View File

@@ -0,0 +1,24 @@
import { ContactApi, Configuration, type ContactMessageDto } from '~~/.api-client'
import { cleanDoubleSlashes, withoutTrailingSlash } from 'ufo'
import { wrappedFetchWrap } from '~/utils/wrappedFetch'
export function useContactApi() {
const appBaseUrl = useRuntimeConfig().app.baseURL
const { serverApiBasePath, clientProxyBasePath } = useRuntimeConfig().public
const basePath = withoutTrailingSlash(
cleanDoubleSlashes(import.meta.client ? appBaseUrl + clientProxyBasePath : clientProxyBasePath + serverApiBasePath)
)
const contactApiClient = new ContactApi(
new Configuration({ basePath, fetchApi: wrappedFetchWrap(useRequestFetch()) })
)
async function sendContactMessage(contactMessageDto: ContactMessageDto): Promise<void> {
return contactApiClient.sendContactMessage({ contactMessageDto })
}
return {
sendContactMessage
}
}

View File

@@ -5,3 +5,5 @@ export { useApplicationFormVersionApi } from './applicationFormVersion/useApplic
export { useNotificationApi } from './notification/useNotificationApi'
export { useUser } from './user/useUser'
export { useUserApi } from './user/useUserApi'
export { useContact } from './contact/useContact'
export { useContactApi } from './contact/useContactApi'

View File

@@ -55,20 +55,13 @@ const { t: $t } = useI18n()
const links = [
[
{
label: $t('common.general'),
icon: 'i-lucide-user',
to: '/settings',
label: $t('contact.title'),
icon: 'i-lucide-mail',
to: '/contact',
exact: true
}
],
[
{
label: $t('common.general'),
icon: 'i-lucide-user',
to: '/settings',
exact: true
}
]
[]
]
const open = ref(false)
const logger = useLogger().withTag('layout')

View File

@@ -0,0 +1,110 @@
<template>
<UDashboardPanel id="contact">
<template #header>
<UDashboardNavbar :title="$t('contact.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full lg:max-w-4xl mx-auto p-6">
<!-- Introduction Card -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('contact.header') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('contact.description') }}</p>
</div>
</template>
</UCard>
<!-- Contact Form -->
<UCard>
<template #header>
<div>
<h3 class="text-lg font-semibold text-highlighted">{{ $t('contact.form.title') }}</h3>
<p class="text-sm text-muted mt-1">{{ $t('contact.form.description') }}</p>
</div>
</template>
<form class="space-y-4" @submit.prevent="handleSubmit">
<UFormField :label="$t('contact.form.subject')" required>
<UInput
v-model="subject"
:placeholder="$t('contact.form.subjectPlaceholder')"
:disabled="isSubmitting"
class="w-full"
aria-required="true"
/>
</UFormField>
<UFormField :label="$t('contact.form.message')" required>
<ContactEditor
v-model="message"
:disabled="isSubmitting"
:placeholder="$t('contact.form.messagePlaceholder')"
/>
</UFormField>
<UButton
type="submit"
:label="$t('contact.form.submit')"
:loading="isSubmitting"
:disabled="isSubmitting || !isFormValid"
color="primary"
/>
</form>
</UCard>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useContact } from '~/composables'
definePageMeta({
layout: 'default'
})
const { t: $t } = useI18n()
const toast = useToast()
const { sendContactMessage } = useContact()
const subject = ref('')
const message = ref('')
const isSubmitting = ref(false)
const isFormValid = computed(() => {
return subject.value.trim().length > 0 && message.value.trim().length > 0
})
async function handleSubmit() {
if (!isFormValid.value) return
isSubmitting.value = true
try {
await sendContactMessage(subject.value, message.value)
toast.add({
title: $t('contact.success.title'),
description: $t('contact.success.description'),
color: 'success'
})
// Reset form
subject.value = ''
message.value = ''
} catch {
toast.add({
title: $t('common.error'),
description: $t('contact.error.description'),
color: 'error'
})
} finally {
isSubmitting.value = false
}
}
</script>