Add contact form with reCAPTCHA, saved to DB, viewable in admin
Adds a public /contact page (name/email/message, protected by the same reCAPTCHA v3 setup used on registration) that persists submissions to a new contact_message table, plus a Contact section in the admin panel to read them. Linked from the site footer.
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260829120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create contact_message table for the public contact form';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE contact_message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Game\Controller;
|
||||||
|
|
||||||
|
use App\Website\Repository\ContactMessageRepository;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[Route('/admin/contact')]
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
final class GameAdminContactController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'game_admin_contact', methods: ['GET'])]
|
||||||
|
public function index(ContactMessageRepository $contactMessageRepository): Response
|
||||||
|
{
|
||||||
|
return $this->render('game/admin/contact/index.html.twig', [
|
||||||
|
'messages' => $contactMessageRepository->findBy([], ['createdAt' => 'DESC']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Website\Controller;
|
||||||
|
|
||||||
|
use App\Website\Form\ContactFormType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
final class ContactController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route(path: '/contact', name: 'website_contact', methods: ['GET', 'POST'])]
|
||||||
|
public function contact(Request $request, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(ContactFormType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$entityManager->persist($form->getData());
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
$this->addFlash('success', 'Thanks for reaching out — your message has been sent.');
|
||||||
|
|
||||||
|
return $this->redirectToRoute('website_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('website/contact.html.twig', [
|
||||||
|
'contactForm' => $form->createView(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Entity;
|
||||||
|
|
||||||
|
use App\Website\Repository\ContactMessageRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ContactMessageRepository::class)]
|
||||||
|
#[ORM\Table(name: 'contact_message')]
|
||||||
|
class ContactMessage
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT)]
|
||||||
|
private ?string $message = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private ?\DateTimeImmutable $createdAt = 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): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmail(string $email): static
|
||||||
|
{
|
||||||
|
$this->email = $email;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): ?string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMessage(string $message): static
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Form;
|
||||||
|
|
||||||
|
use App\Website\Entity\ContactMessage;
|
||||||
|
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
|
||||||
|
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
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\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
|
||||||
|
class ContactFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your name'),
|
||||||
|
new Length(max: 255, maxMessage: 'Your name cannot be longer than {{ limit }} characters'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your email address'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('message', TextareaType::class, [
|
||||||
|
'attr' => ['rows' => 6],
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter a message'),
|
||||||
|
new Length(max: 5000, maxMessage: 'Your message cannot be longer than {{ limit }} characters'),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->add('captcha', Recaptcha3Type::class, [
|
||||||
|
'constraints' => new Recaptcha3(),
|
||||||
|
'action_name' => 'contact',
|
||||||
|
])
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => ContactMessage::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Website\Repository;
|
||||||
|
|
||||||
|
use App\Website\Entity\ContactMessage;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ContactMessage>
|
||||||
|
*/
|
||||||
|
class ContactMessageRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ContactMessage::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@
|
|||||||
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
|
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
|
||||||
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
|
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
|
||||||
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
|
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
|
||||||
|
{ route: 'game_admin_contact', label: 'Contact', icon: '📨' },
|
||||||
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
|
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
|
||||||
] %}
|
] %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends 'game/admin/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Contact Messages — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_body %}
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||||
|
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Contact Messages</h1>
|
||||||
|
<span style="color: #64748b; font-size: 0.9rem;">{{ messages|length }} message{{ messages|length != 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
|
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Received</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Name</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
|
||||||
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in messages %}
|
||||||
|
<tr style="border-bottom: 1px solid #f1f5f9;">
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ entry.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.name }}</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569;">
|
||||||
|
<a href="mailto:{{ entry.email }}" style="color: #3b82f6; text-decoration: none;">{{ entry.email }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 480px; white-space: pre-wrap;">{{ entry.message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="padding: 2rem; text-align: center; color: #94a3b8;">No messages yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -67,6 +67,8 @@
|
|||||||
<footer class="site-footer py-4">
|
<footer class="site-footer py-4">
|
||||||
<div class="container text-center small">
|
<div class="container text-center small">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
|
<a href="{{ path('website_contact') }}" class="link-light">{{ 'footer.contact'|trans }}</a>
|
||||||
|
·
|
||||||
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
|
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
|
||||||
·
|
·
|
||||||
<a href="{{ path('website_terms') }}" class="link-light">{{ 'footer.terms'|trans }}</a>
|
<a href="{{ path('website_terms') }}" class="link-light">{{ 'footer.terms'|trans }}</a>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Contact | {{ 'site.name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h1 class="display-6 fw-bold">Contact</h1>
|
||||||
|
<p class="lead text-secondary">Got a question, a bug to report, or just want to say hi?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
{{ form_errors(contactForm) }}
|
||||||
|
|
||||||
|
{{ form_start(contactForm) }}
|
||||||
|
{{ form_row(contactForm.name) }}
|
||||||
|
{{ form_row(contactForm.email) }}
|
||||||
|
{{ form_row(contactForm.message) }}
|
||||||
|
{{ form_row(contactForm.captcha) }}
|
||||||
|
|
||||||
|
<div class="d-grid mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">Send Message</button>
|
||||||
|
</div>
|
||||||
|
{{ form_end(contactForm) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,6 +9,7 @@ nav.register: Register
|
|||||||
nav.logout: Logout
|
nav.logout: Logout
|
||||||
link.enter_game: Enter the Game Area
|
link.enter_game: Enter the Game Area
|
||||||
link.back_to_website: Back to Website
|
link.back_to_website: Back to Website
|
||||||
|
footer.contact: Contact
|
||||||
footer.help_wanted: Looking for Help
|
footer.help_wanted: Looking for Help
|
||||||
footer.terms: Terms and Conditions
|
footer.terms: Terms and Conditions
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ nav.register: Registreren
|
|||||||
nav.logout: Uitloggen
|
nav.logout: Uitloggen
|
||||||
link.enter_game: Ga naar het Speelgedeelte
|
link.enter_game: Ga naar het Speelgedeelte
|
||||||
link.back_to_website: Terug naar Website
|
link.back_to_website: Terug naar Website
|
||||||
|
footer.contact: Contact
|
||||||
footer.help_wanted: Op zoek naar hulp
|
footer.help_wanted: Op zoek naar hulp
|
||||||
footer.terms: Algemene Voorwaarden
|
footer.terms: Algemene Voorwaarden
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user