Private
Public Access
1
0

new: usr: add Contact page with email sending behaviour #4
All checks were successful
Deploy to Production / deploy (push) Successful in 39s

This commit is contained in:
2026-04-15 18:35:05 +02:00
parent c52939a7a3
commit 6f3edb41ea
11 changed files with 723 additions and 9 deletions

View File

@@ -180,6 +180,41 @@
input[type="checkbox"] { accent-color: #236f87; } input[type="checkbox"] { accent-color: #236f87; }
} }
.auth-checkbox {
accent-color: #236f87;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.auth-checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
user-select: none;
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
a {
color: #95cff5;
text-decoration: none;
font-weight: 600;
transition: color 180ms;
&:hover { color: #c5e8ff; }
}
}
textarea.auth-input {
padding: 11px 14px;
min-height: 120px;
resize: vertical;
font-family: 'Rajdhani', sans-serif;
line-height: 1.5;
}
.auth-submit { .auth-submit {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,83 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
export default ContactForm;

31
assets/js/contact.jsx Normal file
View File

@@ -0,0 +1,31 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import ContactForm from './components/ContactForm';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}

View File

@@ -10,10 +10,19 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\ContactMessage;
use App\Form\ContactFormType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/** /**
@@ -36,6 +45,9 @@ class GameController extends AbstractController
private readonly string $mercurePublicUrl, private readonly string $mercurePublicUrl,
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')] #[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
private readonly string $mercureSubscriberJwt, private readonly string $mercureSubscriberJwt,
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private readonly string $appContactMailAddress,
private readonly LoggerInterface $logger,
) { ) {
} }
@@ -69,9 +81,28 @@ class GameController extends AbstractController
} }
#[Route('/contact', name: 'MineSeekerBundle_contact')] #[Route('/contact', name: 'MineSeekerBundle_contact')]
public function contact(): Response public function contact(
{ Request $request,
return $this->render('Official/contact.html.twig'); EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
$contactMessage = new ContactMessage();
$form = $this->createForm(ContactFormType::class, $contactMessage);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contactMessage->setIpAddress($request->getClientIp());
$em->persist($contactMessage);
$em->flush();
$this->sendMail($mailer, $contactMessage);
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
return $this->redirectToRoute('MineSeekerBundle_contact');
}
return $this->render('Official/contact.html.twig', [
'form' => $form,
]);
} }
#[Route('/landing-page', name: 'MineSeekerBundle_landing')] #[Route('/landing-page', name: 'MineSeekerBundle_landing')]
@@ -79,4 +110,31 @@ class GameController extends AbstractController
{ {
return $this->render('Official/landing.html.twig'); return $this->render('Official/landing.html.twig');
} }
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
{
try {
$mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->replyTo($contactMessage->getEmail())
->subject('New Contact Message from ' . $contactMessage->getName())
->htmlTemplate('emails/contact_notification.html.twig')
->context(['message' => $contactMessage])
);
} catch (\Exception $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
'exception' => $e,
'message' => $contactMessage,
]);
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
}
}
} }

View File

@@ -0,0 +1,128 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use App\Repository\ContactMessageRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
/**
* Class ContactMessage
*
* @package App\Entity
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
#[Entity(repositoryClass: ContactMessageRepository::class)]
#[Table(name: 'contact_messages')]
class ContactMessage
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
#[Column]
private string $name;
#[Column]
private string $email;
#[Column(type: Types::TEXT)]
private string $content;
#[Column]
private bool $consent = false;
#[Column]
private DateTimeImmutable $createdAt;
#[Column(length: 45, nullable: true)]
private ?string $ipAddress = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function isConsent(): bool
{
return $this->consent;
}
public function setConsent(bool $consent): self
{
$this->consent = $consent;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getIpAddress(): ?string
{
return $this->ipAddress;
}
public function setIpAddress(?string $ipAddress): self
{
$this->ipAddress = $ipAddress;
return $this;
}
}

View File

@@ -0,0 +1,89 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\ContactMessage;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* Class ContactFormType
*
* @package App\Form
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
class ContactFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Name',
'constraints' => [
new NotBlank(message: 'Please enter your name.'),
new Length(
min: 2,
max: 255,
minMessage: 'Name must be at least {{ limit }} characters.',
maxMessage: 'Name cannot be longer than {{ limit }} characters.',
),
],
])
->add('email', EmailType::class, [
'label' => 'Email',
'constraints' => [
new NotBlank(message: 'Please enter your email address.'),
new Email(message: 'Please enter a valid email address.'),
],
])
->add('content', TextareaType::class, [
'label' => 'Message',
'constraints' => [
new NotBlank(message: 'Please enter your message.'),
new Length(
min: 10,
max: 5000,
minMessage: 'Message must be at least {{ limit }} characters.',
maxMessage: 'Message cannot be longer than {{ limit }} characters.',
),
],
])
->add('consent', CheckboxType::class, [
'label' => 'I have read the Privacy and Data Processing Policy and I consent to the processing of my data.',
'mapped' => true,
'constraints' => [
new IsTrue(message: 'You must agree to the privacy policy to submit this form.'),
],
])
->add('recaptcha', RecaptchaType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContactMessage::class,
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260415160446
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*/
final class Version20260415160446 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contact mail storage support';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE contact_messages_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE contact_messages (id INT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, content TEXT NOT NULL, consent BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, ip_address VARCHAR(45) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN contact_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER INDEX played_game_uuid_unique RENAME TO UNIQ_54BE8039D17F50A6');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE contact_messages_id_seq CASCADE');
$this->addSql('DROP TABLE contact_messages');
$this->addSql('ALTER INDEX uniq_54be8039d17f50a6 RENAME TO played_game_uuid_unique');
}
}

View File

@@ -0,0 +1,35 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\ContactMessage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class ContactMessageRepository
*
* @package App\Repository
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 15.
*
* @extends ServiceEntityRepository<ContactMessage>
*/
class ContactMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactMessage::class);
}
}

View File

@@ -20,8 +20,117 @@
{% block body %} {% block body %}
<div class="txt"> <div class="txt">
<h2>Contact and user support</h2> <h2 style="text-align: center;">Contact and user support</h2>
<h3>Under construction</h3>
<a href="mailto:langlasz@gmail.com">langlasz@gmail.com</a> {% for message in app.flashes('contact_success') %}
<div class="auth-card auth-card--sent" style="margin: 20px auto; max-width: 600px;">
<div class="auth-sent-icon"><i class="far fa-envelope"></i></div>
<h3 style="color: #667eea; margin: 16px 0;">Message Sent!</h3>
<p class="auth-sent-note">{{ message }}</p>
<a href="{{ path('MineSeekerBundle_homepage') }}" class="auth-submit" style="text-decoration:none; margin-top:16px;">
Back to Home
</a>
</div>
{% else %}
<p style="text-align: center; color: #666; margin-bottom: 30px;">
Have a question, feedback, or need support? We'd love to hear from you!
</p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field">
<label for="{{ form.name.vars.id }}" class="auth-label">Name *</label>
<div class="auth-input-wrap">
<i class="fas fa-user auth-input-icon"></i>
{{ form_widget(form.name, {
attr: {
class: 'auth-input' ~ (not form.name.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Your name',
autofocus: true,
}
}) }}
</div>
{% if not form.name.vars.valid %}
{% for error in form.name.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.email.vars.id }}" class="auth-label">Email *</label>
<div class="auth-input-wrap">
<i class="fas fa-envelope auth-input-icon"></i>
{{ form_widget(form.email, {
attr: {
class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''),
placeholder: 'your.email@example.com',
}
}) }}
</div>
{% if not form.email.vars.valid %}
{% for error in form.email.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label for="{{ form.content.vars.id }}" class="auth-label">Message *</label>
<div class="auth-input-wrap">
<i class="fas fa-comment-dots auth-input-icon" style="top: 16px;"></i>
{{ form_widget(form.content, {
attr: {
class: 'auth-input' ~ (not form.content.vars.valid ? ' auth-input--error' : ''),
placeholder: 'Tell us what\'s on your mind...',
rows: 6,
style: 'min-height: 150px; resize: vertical;'
}
}) }}
</div>
{% if not form.content.vars.valid %}
{% for error in form.content.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<div class="auth-field">
<label class="auth-checkbox-label" style="display: flex; align-items: flex-start; cursor: pointer; user-select: none;">
{{ form_widget(form.consent, {
attr: {
class: 'auth-checkbox',
style: 'margin-right: 10px; margin-top: 3px;'
}
}) }}
<span style="flex: 1; font-size: 14px; line-height: 1.5; color: #666;">
I have read the <a href="{{ path('MineSeekerBundle_privacy') }}" target="_blank" style="color: #667eea; text-decoration: none;">Privacy and Data Processing Policy</a> and I consent to the processing of my data. *
</span>
</label>
{% if not form.consent.vars.valid %}
{% for error in form.consent.vars.errors %}
<p class="auth-field-error"><i class="fas fa-circle-exclamation"></i> {{ error.message }}</p>
{% endfor %}
{% endif %}
</div>
<button type="submit" class="auth-submit">
<i class="fas fa-paper-plane"></i> Send Message
</button>
{{ form_end(form) }}
</div>
{% endfor %}
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact') }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New Contact Message</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>📬 New Contact Message</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">From</div>
<div class="field-value">
<strong>{{ message.name }}</strong><br>
<a href="mailto:{{ message.email }}">{{ message.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Message</div>
<div class="field-value message-content">{{ message.content }}</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Submitted:</strong> {{ message.createdAt|date('Y-m-d H:i:s') }}<br>
{% if message.ipAddress %}
<strong>IP Address:</strong> {{ message.ipAddress }}<br>
{% endif %}
<strong>Consent:</strong> {% if message.consent %}✓ Given{% else %}✗ Not given{% endif %}
</div>
</div>
<div class="footer">
This message was sent via the MineSeeker contact form.<br>
You can reply directly to this email to respond to {{ message.name }}.
</div>
</div>
</body>
</html>

View File

@@ -25,6 +25,7 @@ export default defineConfig({
mineseeker: './assets/js/app.jsx', mineseeker: './assets/js/app.jsx',
passkey: './assets/js/passkey.jsx', passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx', profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx',
mineseekerStyle: './assets/css/style.mineseeker.scss', mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss', homeStyle: './assets/css/style.layout.scss',
passkeyStyle: './assets/css/passkey.scss', passkeyStyle: './assets/css/passkey.scss',