<?php
|
namespace JVBase\managers;
|
|
use JVBase\meta\MetaManager;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
/**
|
* Form Manager Class
|
* Mainly used for front-end forms.
|
* Handles form rendering and processing using MetaManager
|
*/
|
class FormManager
|
{
|
|
protected array $forms;
|
protected array $fields = [];
|
protected string $turnstile_site_key = '';
|
protected string $turnstile_secret_key = '';
|
protected object $meta;
|
protected object $cache;
|
|
|
/**
|
* Constructor
|
*/
|
public function __construct()
|
{
|
// Add form processing actions
|
add_action('admin_post_nopriv_jvb_forms', [$this, 'processForm']);
|
add_action('admin_post_jvb_forms', [$this, 'processForm']);
|
|
// Add query var for form submission
|
add_filter('query_vars', [$this, 'addQueryVars']);
|
|
$this->forms = apply_filters('jvbForms', []);
|
if (empty($this->forms)) {
|
return;
|
}
|
|
$this->formContact = apply_filters('jvbFormContact', '');
|
|
// Setup Turnstile
|
$this->turnstile_site_key = JVB_CLOUDFLARE_SITE_KEY;
|
$this->turnstile_secret_key = JVB_CLOUDFLARE_SECRET_KEY;
|
$this->meta = new MetaManager(null, 'form');
|
$this->cache = new CacheManager('forms', WEEK_IN_SECONDS);
|
}
|
|
/**
|
* Add query vars for form submission
|
* @param array $vars
|
*
|
* @return array
|
*/
|
public function addQueryVars(array $vars):array
|
{
|
$vars[] = 'jvb_submitted';
|
$vars[] = 'jvb_form_error';
|
return $vars;
|
}
|
|
|
/**
|
* @param string $type
|
*
|
* @return false|string
|
*/
|
public function renderForm(string $type):string|false
|
{
|
if (!array_key_exists($type, $this->forms)) {
|
return false;
|
}
|
$submitted = get_query_var('jvb_submitted', false);
|
$error = get_query_var('jvb_form_error', false);
|
|
ob_start();
|
|
// Handle success state - return only success message
|
if ($submitted) {
|
$submission_id = sanitize_text_field($submitted);
|
$submission_data = $this->cache->get('submission_' . $submission_id);
|
|
echo '<div class="form-success">';
|
echo '<h2>'.$this->forms[$type]['success_title']??'We got it'.'!</h2>';
|
if (!empty($this->forms[$type]['success_message'])) {
|
foreach ($this->forms[$type]['success_message'] as $message) {
|
echo '<p>'.$message.'</p>';
|
}
|
}
|
|
if ($submission_data) {
|
echo '<div class="submission-summary">';
|
echo '<h3>Your submission:</h3>';
|
echo '<ul>';
|
foreach ($submission_data as $key => $value) {
|
if (!in_array($key, ['action', 'form_id', 'form_type', 'timestamp', '_wpnonce'])) {
|
if (is_array($value)) {
|
$value = implode(', ', $value);
|
}
|
echo '<li><strong>' . esc_html(ucfirst($key)) . ':</strong> ' . esc_html($value) . '</li>';
|
}
|
}
|
echo '</ul>';
|
echo '</div>';
|
}
|
|
echo ($this->formContact !== '') ? '<p>'.$this->formContact.'</p>' : '';
|
echo '</div>';
|
|
return ob_get_clean();
|
}
|
|
// Handle error state - show error message above form
|
if ($error) {
|
echo '<div class="form-error">';
|
echo '<h2>Whoops!</h2>';
|
echo '<p>Something went wrong there. Sorry about that.</p>';
|
echo ($this->formContact !== '') ? '<p>'.$this->formContact.'</p>' : '';
|
echo '</div>';
|
}
|
|
$id = uniqid($type);
|
|
$this->renderFormStart($type, $id);
|
|
$this->renderFields($type);
|
|
$this->renderTurnstile();
|
$this->renderFormEnd($type, $id);
|
|
return ob_get_clean();
|
}
|
|
/**
|
* @param string $type
|
* @param string $id
|
*
|
* @return void
|
*/
|
protected function renderFormStart(string $type, string $id):void
|
{
|
?>
|
<form id="<?= $id ?>" class="jvb-form" action="<?=esc_url(admin_url('admin-post.php'))?>" method="post">
|
<?php
|
wp_nonce_field('jvb_form_' . $id);
|
echo jvbFormStatus();
|
}
|
|
|
/**
|
* @return void
|
*/
|
protected function renderFields(string $type): void
|
{
|
if (!array_key_exists($type, $this->forms)) {
|
return;
|
}
|
|
|
if (empty($this->forms[$type]['fields'])) {
|
return;
|
}
|
|
if (array_key_exists('sections', $this->forms[$type])) {
|
$this->renderSections($type);
|
return;
|
} else {
|
foreach ($this->forms[$type]['fields'] as $field_name => $field_config) {
|
$this->meta->render('form', $field_name, $field_config, false, false);
|
}
|
}
|
|
|
}
|
|
protected function renderSections(string $type):void
|
{
|
echo '<div class="container">';
|
$nav = '<nav class="tabs row start" role="tablist">';
|
$i = 1;
|
foreach ($this->forms[$type]['sections'] as $slug => $section) {
|
$nav .= '<button type="button" class="tab';
|
|
$ariaActive = 'false';
|
if ($i === 1) {
|
$nav .= ' active';
|
$ariaActive = 'true';
|
}
|
$nav .= '" data-tab="'.$slug.'" role="tab" aria-selected="'.$ariaActive.'">
|
<h2>'.$section.'</h2></button>';
|
$i++;
|
}
|
echo $nav.'</nav>';
|
|
$fields = $this->forms[$type]['fields'];
|
|
$i = 0;
|
foreach ($this->forms[$type]['sections'] as $slug => $section) {
|
$class = ($i == 0) ? ' active' : '';
|
?>
|
<section id="<?= $slug ?>" class="tab-content<?=$class?>" data-tab="<?=$slug?>" role="tabpanel">
|
<?php if (!empty($section['title'])) : ?>
|
<h2><?= esc_html($section['title']); ?></h2>
|
<?php endif; ?>
|
|
<?php if (!empty($section['description'])) : ?>
|
<p class="section-description">
|
<?= wp_kses_post($section['description']); ?>
|
</p>
|
<?php endif; ?>
|
|
|
<?php
|
$sectionFields = array_filter($fields, function ($f) use ($slug) {
|
return array_key_exists('section', $f) && $f['section'] == $slug;
|
});
|
foreach ($sectionFields as $field => $config) : ?>
|
<?php
|
$this->meta->render('form', $field, $config, false, false);
|
?>
|
<?php endforeach; ?>
|
</section>
|
<?php
|
$i++;
|
}
|
}
|
|
|
/**
|
* @param string $type
|
* @param string $id
|
*
|
* @return void
|
*/
|
protected function renderFormEnd(string $type, string $id):void
|
{
|
$submit = $this->forms[$type]['submit']??'Submit';
|
// Add hidden fields
|
?>
|
<input type="hidden" name="action" value="jvb_forms">
|
<input type="hidden" name="form_id" value="<?= $id ?>">
|
<input type="hidden" name="form_type" value="<?=$type?>">
|
<input type="hidden" name="timestamp" value="<?= time() ?>">
|
|
<div class="form-actions">
|
<button type="submit"><?= $submit ?></button>
|
</div>
|
</form>
|
<?php
|
}
|
|
/**
|
* Render Cloudflare Turnstile
|
* @return void
|
*/
|
protected function renderTurnstile(): void
|
{
|
if (!jvbSiteUsesCloudflare()) {
|
return;
|
}
|
|
$cloudflare = JVB()->connect('cloudflare');
|
if ($cloudflare->isSetUp()) {
|
$cloudflare->renderTurnstile();
|
}
|
}
|
|
/**
|
* Process form submission
|
* @return void
|
* @throws Exception
|
*/
|
public function processForm():void
|
{
|
// Verify nonce
|
if (!isset($_POST['_wpnonce'])) {
|
wp_redirect(home_url());
|
exit;
|
}
|
|
$form_id = sanitize_text_field($_POST['form_id']);
|
|
if (!wp_verify_nonce($_POST['_wpnonce'], 'jvb_form_' . $form_id)) {
|
wp_redirect(home_url());
|
exit;
|
}
|
|
$type = sanitize_text_field($_POST['form_type']);
|
if (!array_key_exists($type, $this->forms)) {
|
wp_redirect(home_url());
|
}
|
error_log('Form Post Data: '.print_r($_POST, true));
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile()) {
|
$referer = wp_get_referer() ?: home_url($path);
|
wp_redirect(add_query_arg('jvb_form_error', urlencode('Please complete the security check.'), $referer));
|
exit;
|
}
|
|
// Check rate limits
|
$ip_address = $_SERVER['REMOTE_ADDR'];
|
$email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
|
$rate_check = $this->checkRateLimit($ip_address, $email);
|
|
if ($rate_check !== true) {
|
$error_message = $rate_check === 'hourly_limit' ?
|
'Too many submissions in the last hour' :
|
'Too many submissions in the last 24 hours';
|
wp_redirect(add_query_arg('jvb_form_error', urlencode($error_message), wp_get_referer()));
|
exit;
|
}
|
|
// Process form data
|
$form_data = [];
|
|
foreach ($this->forms[$type]??[] as $field_name => $field_config) {
|
// Skip fields that weren't submitted (like hidden conditional fields)
|
if (!isset($_POST[$field_name])) {
|
continue;
|
}
|
|
$value = $_POST[$field_name];
|
if (!$this->meta->validator->validate($value, $field_config)) {
|
error_log('Validation unsuccessful');
|
throw new Exception("Validation failed for {$field_name}");
|
}
|
|
$form_data[$field_name] = $this->meta->sanitizer->sanitize($value, $field_config);
|
}
|
|
// Send email
|
$email_sent = $this->sendEmail($type, $form_data);
|
|
if (!$email_sent) {
|
$referer = wp_get_referer() ?: home_url();
|
wp_redirect(add_query_arg('jvb_form_error', urlencode('Failed to send your message. Please try again later.'), $referer));
|
exit;
|
}
|
|
$this->cache->set('submission_' . $form_id, $form_data, HOUR_IN_SECONDS);
|
|
// Redirect back to form with success parameter
|
$redirect = wp_get_referer() ?: home_url($path);
|
wp_redirect(add_query_arg('jvb_submitted', $form_id, $redirect));
|
exit;
|
}
|
|
/**
|
* Send email with form data
|
*/
|
/**
|
* @param string $type
|
* @param array $form_data
|
*
|
* @return bool
|
*/
|
protected function sendEmail(string $type, array $form_data):bool
|
{
|
// Set up email data
|
$to = get_bloginfo('admin_email');
|
$subject = $this->forms[$type]['subject']??'New Form Entry';
|
|
// Build email body
|
$body = '<h2>Hey team!</h2>';
|
$body .= '<p><strong>Date:</strong> ' . date_i18n(get_option('date_format') . ' ' . get_option('time_format')) . '</p>';
|
$body .= '<div class="divider">';
|
|
foreach ($form_data as $field_name => $value) {
|
// Skip internal fields
|
if (in_array($field_name, ['action', 'form_id', 'form_type', 'timestamp', '_wpnonce'])) {
|
continue;
|
}
|
|
// Get field label from config
|
$label = $this->forms[$type][$field_name]['label'] ?? $field_name;
|
|
// Format value for display
|
if (is_array($value)) {
|
$value = implode(', ', $value);
|
}
|
|
$body .= '<p><strong>' . esc_html($label) . ':</strong> ' . nl2br(esc_html($value)) . '</p>';
|
}
|
|
// Add reply-to if email field exists
|
if (isset($form_data['email'])) {
|
$name = isset($form_data['name']) ? $form_data['name'] : '';
|
$headers[] = 'Reply-To: ' . $name . ' <' . $form_data['email'] . '>';
|
}
|
|
// Send email
|
return jvbMail($to, $subject, $body, $headers);
|
}
|
|
/**
|
* Verify Cloudflare Turnstile token
|
* @return bool
|
*/
|
protected function verifyTurnstile():bool
|
{
|
if (empty($_POST['cf-turnstile-response'])) {
|
return false;
|
}
|
|
|
$cloudflare = JVB()->connect('cloudflare');
|
if (!$cloudflare->isSetUp()){
|
return true;
|
}
|
|
$token = $_POST['cf-turnstile-response'];
|
$ip = $_SERVER['REMOTE_ADDR'];
|
return $cloudflare->verifyTurnstile($token, $ip);
|
}
|
|
/**
|
* Check rate limits for form submissions
|
* @param string $ip_address
|
* @param string $email
|
*
|
* @return string|true
|
*/
|
protected function checkRateLimit(string $ip_address, string $email):string|bool
|
{
|
// Check submissions in last hour
|
$hour_limit = 3;
|
$day_limit = 10;
|
|
$submissions = get_transient('jvb_form_submissions_' . md5($ip_address));
|
if (!$submissions) {
|
$submissions = [
|
'hour' => [],
|
'day' => [],
|
'email' => []
|
];
|
}
|
|
// Clean old submissions
|
$now = time();
|
$submissions['hour'] = array_filter($submissions['hour'], function ($time) use ($now) {
|
return ($now - $time) < 3600; // Last hour
|
});
|
$submissions['day'] = array_filter($submissions['day'], function ($time) use ($now) {
|
return ($now - $time) < 86400; // Last 24 hours
|
});
|
$submissions['email'] = array_filter($submissions['email'], function ($data) use ($now) {
|
return ($now - $data['time']) < 86400;
|
});
|
|
// Check limits
|
if (count($submissions['hour']) >= $hour_limit) {
|
return 'hourly_limit';
|
}
|
if (count($submissions['day']) >= $day_limit) {
|
return 'daily_limit';
|
}
|
|
// Add new submission
|
$submissions['hour'][] = $now;
|
$submissions['day'][] = $now;
|
|
if (!empty($email)) {
|
$submissions['email'][] = [
|
'email' => $email,
|
'time' => $now
|
];
|
}
|
|
// Store updated submissions
|
set_transient('jvb_form_submissions_' . md5($ip_address), $submissions, DAY_IN_SECONDS);
|
|
return true;
|
}
|
}
|