diff --git a/assets/css/homepage/_auth.scss b/assets/css/homepage/_auth.scss index 12c33af..4e3f90a 100644 --- a/assets/css/homepage/_auth.scss +++ b/assets/css/homepage/_auth.scss @@ -180,6 +180,41 @@ 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 { display: flex; align-items: center; diff --git a/assets/js/components/ContactForm.jsx b/assets/js/components/ContactForm.jsx new file mode 100644 index 0000000..a07040e --- /dev/null +++ b/assets/js/components/ContactForm.jsx @@ -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; diff --git a/assets/js/contact.jsx b/assets/js/contact.jsx new file mode 100644 index 0000000..d36d074 --- /dev/null +++ b/assets/js/contact.jsx @@ -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( + + ); + } else { + console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes'); + } +} + diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index a1e685c..e7bd9f5 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -10,10 +10,19 @@ 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\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -31,11 +40,14 @@ class GameController extends AbstractController { public function __construct( #[Autowire(env: 'APP_ENV')] - private readonly string $env, + private readonly string $env, #[Autowire(env: 'MERCURE_PUBLIC_URL')] - private readonly string $mercurePublicUrl, + private readonly string $mercurePublicUrl, #[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')] - public function contact(): Response - { - return $this->render('Official/contact.html.twig'); + public function contact( + Request $request, + 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')] @@ -79,4 +110,31 @@ class GameController extends AbstractController { 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()); + } + } } diff --git a/src/Entity/ContactMessage.php b/src/Entity/ContactMessage.php new file mode 100644 index 0000000..011c90f --- /dev/null +++ b/src/Entity/ContactMessage.php @@ -0,0 +1,128 @@ + + * @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; + } +} + diff --git a/src/Form/ContactFormType.php b/src/Form/ContactFormType.php new file mode 100644 index 0000000..8594eca --- /dev/null +++ b/src/Form/ContactFormType.php @@ -0,0 +1,89 @@ + + * @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, + ]); + } +} + diff --git a/src/Migrations/2026/04/Version20260415160446.php b/src/Migrations/2026/04/Version20260415160446.php new file mode 100644 index 0000000..6d45357 --- /dev/null +++ b/src/Migrations/2026/04/Version20260415160446.php @@ -0,0 +1,47 @@ + + * @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'); + } +} diff --git a/src/Repository/ContactMessageRepository.php b/src/Repository/ContactMessageRepository.php new file mode 100644 index 0000000..cbff1b3 --- /dev/null +++ b/src/Repository/ContactMessageRepository.php @@ -0,0 +1,35 @@ + + * @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 + */ +class ContactMessageRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ContactMessage::class); + } +} diff --git a/templates/Official/contact.html.twig b/templates/Official/contact.html.twig index 486b1f2..970e8e2 100644 --- a/templates/Official/contact.html.twig +++ b/templates/Official/contact.html.twig @@ -20,8 +20,117 @@ {% block body %}
-

Contact and user support

-

Under construction

- langlasz@gmail.com +

Contact and user support

+ + {% for message in app.flashes('contact_success') %} +
+
+

Message Sent!

+

{{ message }}

+ + Back to Home + +
+ {% else %} +

+ Have a question, feedback, or need support? We'd love to hear from you! +

+ +
+ {{ form_start(form, {attr: {class: 'auth-form'}}) }} + +
+ +
+ + {{ form_widget(form.name, { + attr: { + class: 'auth-input' ~ (not form.name.vars.valid ? ' auth-input--error' : ''), + placeholder: 'Your name', + autofocus: true, + } + }) }} +
+ {% if not form.name.vars.valid %} + {% for error in form.name.vars.errors %} +

{{ error.message }}

+ {% endfor %} + {% endif %} +
+ +
+ +
+ + {{ form_widget(form.email, { + attr: { + class: 'auth-input' ~ (not form.email.vars.valid ? ' auth-input--error' : ''), + placeholder: 'your.email@example.com', + } + }) }} +
+ {% if not form.email.vars.valid %} + {% for error in form.email.vars.errors %} +

{{ error.message }}

+ {% endfor %} + {% endif %} +
+ +
+ +
+ + {{ 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;' + } + }) }} +
+ {% if not form.content.vars.valid %} + {% for error in form.content.vars.errors %} +

{{ error.message }}

+ {% endfor %} + {% endif %} +
+ +
+ + {% if not form.consent.vars.valid %} + {% for error in form.consent.vars.errors %} +

{{ error.message }}

+ {% endfor %} + {% endif %} +
+ + + + {{ form_end(form) }} +
+ {% endfor %} +
+{% endblock %} + +{% block javascripts %} + {{ parent() }} + + {{ vite_entry_script_tags('contact') }} +
{% endblock %} diff --git a/templates/emails/contact_notification.html.twig b/templates/emails/contact_notification.html.twig new file mode 100644 index 0000000..47840d3 --- /dev/null +++ b/templates/emails/contact_notification.html.twig @@ -0,0 +1,98 @@ + + + + + New Contact Message + + + +
+

📬 New Contact Message

+
+
+
+
From
+
+ {{ message.name }}
+ {{ message.email }} +
+
+ +
+
Message
+
{{ message.content }}
+
+ +
+
Details
+
+ Submitted: {{ message.createdAt|date('Y-m-d H:i:s') }}
+ {% if message.ipAddress %} + IP Address: {{ message.ipAddress }}
+ {% endif %} + Consent: {% if message.consent %}✓ Given{% else %}✗ Not given{% endif %} +
+
+ + +
+ + + diff --git a/vite.config.js b/vite.config.js index ce51692..70f1b8a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,6 +25,7 @@ export default defineConfig({ mineseeker: './assets/js/app.jsx', passkey: './assets/js/passkey.jsx', profile: './assets/js/profile.jsx', + contact: './assets/js/contact.jsx', mineseekerStyle: './assets/css/style.mineseeker.scss', homeStyle: './assets/css/style.layout.scss', passkeyStyle: './assets/css/passkey.scss',