feat(fullstack): Add contact form
This commit is contained in:
@@ -1169,6 +1169,29 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
|
||||||
|
####### Contact #######
|
||||||
|
/contact:
|
||||||
|
post:
|
||||||
|
summary: Send a contact message
|
||||||
|
operationId: sendContactMessage
|
||||||
|
tags:
|
||||||
|
- contact
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ContactMessageDto"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Contact message sent successfully
|
||||||
|
"400":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/Unauthorized"
|
||||||
|
"500":
|
||||||
|
$ref: "https://api.swaggerhub.com/domains/smartbear-public/ProblemDetails/1.0.0#/components/responses/ServerError"
|
||||||
|
|
||||||
/application-forms/{applicationFormId}/files:
|
/application-forms/{applicationFormId}/files:
|
||||||
parameters:
|
parameters:
|
||||||
- name: applicationFormId
|
- name: applicationFormId
|
||||||
@@ -2044,6 +2067,22 @@ components:
|
|||||||
- WARNING
|
- WARNING
|
||||||
- ERROR
|
- ERROR
|
||||||
|
|
||||||
|
####### Contact DTOs #######
|
||||||
|
ContactMessageDto:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- subject
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: Subject of the contact message
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description: HTML body of the contact message
|
||||||
|
|
||||||
####### File Upload DTOs #######
|
####### File Upload DTOs #######
|
||||||
UploadedFileDto:
|
UploadedFileDto:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.contact
|
||||||
|
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub_api.api.ContactApi
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub_api.model.ContactMessageDto
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class ContactController(
|
||||||
|
private val contactService: ContactService,
|
||||||
|
) : ContactApi {
|
||||||
|
@PreAuthorize(
|
||||||
|
"hasAnyRole('CHIEF_EXECUTIVE_OFFICER', 'BUSINESS_DEPARTMENT', 'IT_DEPARTMENT', 'HUMAN_RESOURCES', 'HEAD_OF_WORKS_COUNCIL', 'WORKS_COUNCIL', 'EMPLOYEE')",
|
||||||
|
)
|
||||||
|
override fun sendContactMessage(contactMessageDto: ContactMessageDto): ResponseEntity<Unit> {
|
||||||
|
contactService.sendContactMessage(
|
||||||
|
subject = contactMessageDto.subject,
|
||||||
|
message = contactMessageDto.message,
|
||||||
|
)
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.contact
|
||||||
|
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.email.EmailService
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.user.UserService
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.thymeleaf.TemplateEngine
|
||||||
|
import org.thymeleaf.context.Context
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class ContactService(
|
||||||
|
private val emailService: EmailService,
|
||||||
|
private val userService: UserService,
|
||||||
|
private val templateEngine: TemplateEngine,
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(ContactService::class.java)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CONTACT_EMAIL = "kontakt@gremiumhub.de"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendContactMessage(
|
||||||
|
subject: String,
|
||||||
|
message: String,
|
||||||
|
) {
|
||||||
|
val currentUser = userService.getCurrentUser()
|
||||||
|
|
||||||
|
val emailBody =
|
||||||
|
buildContactEmail(
|
||||||
|
senderName = currentUser.name,
|
||||||
|
senderEmail = currentUser.email ?: "Keine E-Mail angegeben",
|
||||||
|
subject = subject,
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
emailService.sendEmail(
|
||||||
|
to = CONTACT_EMAIL,
|
||||||
|
subject = "Kontaktanfrage: $subject",
|
||||||
|
body = emailBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Contact message sent from user ${currentUser.keycloakId} with subject: $subject")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContactEmail(
|
||||||
|
senderName: String,
|
||||||
|
senderEmail: String,
|
||||||
|
subject: String,
|
||||||
|
message: String,
|
||||||
|
): String {
|
||||||
|
val context = Context()
|
||||||
|
context.setVariable("senderName", senderName)
|
||||||
|
context.setVariable("senderEmail", senderEmail)
|
||||||
|
context.setVariable("subject", subject)
|
||||||
|
context.setVariable("message", message)
|
||||||
|
return templateEngine.process("email/contact_message", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background-color: #4F46E5; color: white; padding: 20px; border-radius: 5px 5px 0 0; }
|
||||||
|
.content { background-color: #f9fafb; padding: 20px; }
|
||||||
|
.message-box { background-color: white; padding: 15px; border-radius: 5px; border: 1px solid #e5e7eb; margin-top: 15px; }
|
||||||
|
.footer { color: #6b7280; font-size: 12px; margin-top: 20px; }
|
||||||
|
.info-label { font-weight: bold; color: #374151; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h2>Neue Kontaktanfrage</h2>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Eine neue Kontaktanfrage wurde über GremiumHub gesendet:</p>
|
||||||
|
<ul>
|
||||||
|
<li><span class="info-label">Absender:</span> <span th:text="${senderName}"></span></li>
|
||||||
|
<li><span class="info-label">E-Mail:</span> <span th:text="${senderEmail}"></span></li>
|
||||||
|
<li><span class="info-label">Betreff:</span> <span th:text="${subject}"></span></li>
|
||||||
|
</ul>
|
||||||
|
<p class="info-label">Nachricht:</p>
|
||||||
|
<div class="message-box" th:utext="${message}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Diese E-Mail wurde automatisch von GremiumHub gesendet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
legalconsenthub/app/components/ContactEditor.vue
Normal file
58
legalconsenthub/app/components/ContactEditor.vue
Normal 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>
|
||||||
13
legalconsenthub/app/composables/contact/useContact.ts
Normal file
13
legalconsenthub/app/composables/contact/useContact.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
legalconsenthub/app/composables/contact/useContactApi.ts
Normal file
24
legalconsenthub/app/composables/contact/useContactApi.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ export { useApplicationFormVersionApi } from './applicationFormVersion/useApplic
|
|||||||
export { useNotificationApi } from './notification/useNotificationApi'
|
export { useNotificationApi } from './notification/useNotificationApi'
|
||||||
export { useUser } from './user/useUser'
|
export { useUser } from './user/useUser'
|
||||||
export { useUserApi } from './user/useUserApi'
|
export { useUserApi } from './user/useUserApi'
|
||||||
|
export { useContact } from './contact/useContact'
|
||||||
|
export { useContactApi } from './contact/useContactApi'
|
||||||
|
|||||||
@@ -55,20 +55,13 @@ const { t: $t } = useI18n()
|
|||||||
const links = [
|
const links = [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: $t('common.general'),
|
label: $t('contact.title'),
|
||||||
icon: 'i-lucide-user',
|
icon: 'i-lucide-mail',
|
||||||
to: '/settings',
|
to: '/contact',
|
||||||
exact: true
|
exact: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[]
|
||||||
{
|
|
||||||
label: $t('common.general'),
|
|
||||||
icon: 'i-lucide-user',
|
|
||||||
to: '/settings',
|
|
||||||
exact: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const logger = useLogger().withTag('layout')
|
const logger = useLogger().withTag('layout')
|
||||||
|
|||||||
110
legalconsenthub/app/pages/contact.vue
Normal file
110
legalconsenthub/app/pages/contact.vue
Normal 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>
|
||||||
@@ -263,5 +263,32 @@
|
|||||||
"onCommentAdded": "Wenn jemand meinen Antrag kommentiert",
|
"onCommentAdded": "Wenn jemand meinen Antrag kommentiert",
|
||||||
"saved": "Einstellungen gespeichert"
|
"saved": "Einstellungen gespeichert"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Kontakt",
|
||||||
|
"header": "Kontaktieren Sie unsere Experten",
|
||||||
|
"description": "Haben Sie Fragen zur Mitbestimmung, zum Datenschutz oder zu IT-Systemen? Unsere Experten helfen Ihnen gerne weiter.",
|
||||||
|
"experts": {
|
||||||
|
"laborLaw": "Fachanwalt für Arbeitsrecht",
|
||||||
|
"itConsultant": "IT-Consultant",
|
||||||
|
"dataProtection": "Datenschutzbeauftragter",
|
||||||
|
"itSecurity": "IT-Sicherheitsbeauftragter"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Nachricht senden",
|
||||||
|
"description": "Beschreiben Sie Ihr Anliegen und wir melden uns schnellstmöglich bei Ihnen.",
|
||||||
|
"subject": "Betreff",
|
||||||
|
"subjectPlaceholder": "Worum geht es in Ihrer Anfrage?",
|
||||||
|
"message": "Nachricht",
|
||||||
|
"messagePlaceholder": "Beschreiben Sie Ihr Anliegen",
|
||||||
|
"submit": "Nachricht senden"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Nachricht gesendet",
|
||||||
|
"description": "Ihre Nachricht wurde erfolgreich gesendet. Wir werden uns so schnell wie möglich bei Ihnen melden."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"description": "Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,5 +263,32 @@
|
|||||||
"onCommentAdded": "When someone comments on my application form",
|
"onCommentAdded": "When someone comments on my application form",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact",
|
||||||
|
"header": "Contact Our Experts",
|
||||||
|
"description": "Do you have questions about co-determination, data protection, or IT systems? Our experts are happy to help.",
|
||||||
|
"experts": {
|
||||||
|
"laborLaw": "Labor Law Attorney",
|
||||||
|
"itConsultant": "IT Consultant",
|
||||||
|
"dataProtection": "Data Protection Officer",
|
||||||
|
"itSecurity": "IT Security Officer"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Send a Message",
|
||||||
|
"description": "Describe your inquiry and we will get back to you as soon as possible.",
|
||||||
|
"subject": "Subject",
|
||||||
|
"subjectPlaceholder": "What is your inquiry about?",
|
||||||
|
"message": "Message",
|
||||||
|
"messagePlaceholder": "Describe your inquiry",
|
||||||
|
"submit": "Send Message"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Message Sent",
|
||||||
|
"description": "Your message has been sent successfully. We will get back to you as soon as possible."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"description": "The message could not be sent. Please try again later."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user