Skip to content

Commit 2470d62

Browse files
committed
Merge branch 'release/0.8.62'
2 parents 394c32a + 2793717 commit 2470d62

26 files changed

Lines changed: 2284 additions & 5 deletions

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 8,
5-
"patch": 61,
5+
"patch": 62,
66
"build": 0
77
}

resources/.cms-manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"20251221000000_create_event_categories_table.php",
1616
"20251221000001_create_events_table.php",
1717
"20251229000000_update_foreign_keys_to_set_null.php",
18-
"20260603120000_create_content_revisions_table.php"
18+
"20260603120000_create_content_revisions_table.php",
19+
"20260605000000_create_contact_submissions_table.php"
1920
],
2021
"config_files": [
2122
"auth.yaml",
@@ -29,6 +30,7 @@
2930
"admin",
3031
"auth",
3132
"blog",
33+
"contact",
3234
"content",
3335
"emails",
3436
"home",

resources/config/neuron.yaml.example

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,30 @@ maintenance:
196196
retry_after: 3600 # Retry-after value in seconds (1 hour)
197197
custom_view: null # Path to custom maintenance view (optional)
198198
show_countdown: false # Show countdown timer on maintenance page
199+
200+
# Contact Form Configuration
201+
# Drives the built-in [contact form="key"] shortcode. Each form declares its own
202+
# recipient plus its own configurable field list, so adding a new form or
203+
# changing a field set is a config edit only - no code changes required.
204+
#
205+
# Field properties:
206+
# name input name (also the stored payload key)
207+
# label display label
208+
# type text | email | tel | textarea | select | checkbox
209+
# required true|false
210+
# options list of allowed values (select only)
211+
# rules optional: length: { min, max }, pattern: "/regex/"
212+
# reply_to true -> this field supplies the reply-to email address
213+
# sender_name true -> this field supplies the reply-to display name
214+
contact:
215+
default_form: general # Form rendered by a bare [contact] shortcode
216+
forms:
217+
general:
218+
to: "info@yourdomain.com"
219+
subject: "Website Contact: General Inquiry"
220+
label: "Contact Us"
221+
button: "Send Message"
222+
fields:
223+
- { name: name, label: "Your Name", type: text, required: true, sender_name: true, rules: { length: { min: 2, max: 80 } } }
224+
- { name: email, label: "Email", type: email, required: true, reply_to: true }
225+
- { name: message, label: "Message", type: textarea, required: true, rules: { length: { min: 10, max: 4000 } } }
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
use Phinx\Migration\AbstractMigration;
4+
5+
/**
6+
* Create contact_submissions table.
7+
*
8+
* Stores every contact-form submission. Fixed metadata columns capture
9+
* routing/delivery info while the JSON `payload` column holds the dynamic,
10+
* per-form field values so the schema stays stable regardless of how each
11+
* form's configurable field set changes.
12+
*/
13+
class CreateContactSubmissionsTable extends AbstractMigration
14+
{
15+
/**
16+
* Create contact_submissions table
17+
*/
18+
public function change()
19+
{
20+
$table = $this->table( 'contact_submissions', [ 'signed' => false ] );
21+
22+
$table->addColumn( 'form_key', 'string', [ 'limit' => 64, 'null' => false ] )
23+
->addColumn( 'recipient', 'string', [ 'limit' => 255, 'null' => false ] )
24+
->addColumn( 'subject', 'string', [ 'limit' => 255, 'null' => true ] )
25+
->addColumn( 'reply_to_email', 'string', [ 'limit' => 255, 'null' => true ] )
26+
->addColumn( 'reply_to_name', 'string', [ 'limit' => 255, 'null' => true ] )
27+
->addColumn( 'payload', 'text', [ 'null' => false, 'default' => '{}' ] )
28+
->addColumn( 'ip_address', 'string', [ 'limit' => 45, 'null' => true ] )
29+
->addColumn( 'user_agent', 'string', [ 'limit' => 500, 'null' => true ] )
30+
->addColumn( 'delivered', 'boolean', [ 'default' => false ] )
31+
->addColumn( 'delivered_at', 'timestamp', [ 'null' => true ] )
32+
->addColumn( 'created_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP' ] )
33+
->addIndex( [ 'form_key' ] )
34+
->addIndex( [ 'delivered' ] )
35+
->addIndex( [ 'created_at' ] )
36+
->create();
37+
}
38+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<div class="container-fluid">
2+
<div class="d-flex justify-content-between align-items-center mb-4">
3+
<h2>Contact Submissions</h2>
4+
<form method="GET" action="<?= route_path('admin_contact_submissions') ?>" class="d-flex align-items-center gap-2">
5+
<label for="formFilter" class="form-label mb-0 small text-muted">Filter:</label>
6+
<select id="formFilter" name="form" class="form-select form-select-sm" onchange="this.form.submit()">
7+
<option value="">All forms</option>
8+
<?php foreach( ( $formKeys ?? [] ) as $key ): ?>
9+
<option value="<?= htmlspecialchars( $key ) ?>" <?= ( ( $activeFormKey ?? null ) === $key ) ? 'selected' : '' ?>>
10+
<?= htmlspecialchars( $key ) ?>
11+
</option>
12+
<?php endforeach; ?>
13+
</select>
14+
</form>
15+
</div>
16+
17+
<div class="card">
18+
<div class="card-body">
19+
<?php if( empty( $submissions ) ): ?>
20+
<p class="text-muted text-center py-4">No contact submissions found.</p>
21+
<?php else: ?>
22+
<div class="table-responsive">
23+
<table class="table table-hover align-middle">
24+
<thead>
25+
<tr>
26+
<th>#</th>
27+
<th>Form</th>
28+
<th>Recipient</th>
29+
<th>Reply-To</th>
30+
<th>Delivered</th>
31+
<th>Received</th>
32+
<th width="120">Actions</th>
33+
</tr>
34+
</thead>
35+
<tbody>
36+
<?php foreach( $submissions as $s ): ?>
37+
<tr>
38+
<td><?= htmlspecialchars( (string) ( $s['id'] ?? '' ) ) ?></td>
39+
<td><span class="badge bg-secondary"><?= htmlspecialchars( (string) ( $s['form_key'] ?? '' ) ) ?></span></td>
40+
<td><?= htmlspecialchars( (string) ( $s['recipient'] ?? '' ) ) ?></td>
41+
<td>
42+
<?php if( !empty( $s['reply_to_email'] ) ): ?>
43+
<?= htmlspecialchars( trim( ( $s['reply_to_name'] ?? '' ) . ' <' . $s['reply_to_email'] . '>' ) ) ?>
44+
<?php else: ?>
45+
<span class="text-muted">-</span>
46+
<?php endif; ?>
47+
</td>
48+
<td>
49+
<?php if( !empty( $s['delivered'] ) ): ?>
50+
<span class="badge bg-success">Delivered</span>
51+
<?php else: ?>
52+
<span class="badge bg-warning text-dark">Pending</span>
53+
<?php endif; ?>
54+
</td>
55+
<td><?= htmlspecialchars( (string) ( $s['created_at'] ?? '' ) ) ?></td>
56+
<td>
57+
<a href="<?= route_path('admin_contact_submission_show', ['id' => $s['id']]) ?>" class="btn btn-sm btn-outline-primary" title="View">
58+
<i class="bi bi-eye"></i>
59+
</a>
60+
<form action="<?= route_path('admin_contact_submission_delete', ['id' => $s['id']]) ?>" method="POST" class="d-inline" onsubmit="return confirm('Delete this submission?');">
61+
<input type="hidden" name="_method" value="DELETE">
62+
<?= csrf_field() ?>
63+
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
64+
<i class="bi bi-trash"></i>
65+
</button>
66+
</form>
67+
</td>
68+
</tr>
69+
<?php endforeach; ?>
70+
</tbody>
71+
</table>
72+
</div>
73+
74+
<?php if( ( $pages ?? 1 ) > 1 ): ?>
75+
<nav class="mt-3">
76+
<ul class="pagination pagination-sm mb-0">
77+
<?php
78+
$filterQs = !empty( $activeFormKey ) ? '&form=' . urlencode( $activeFormKey ) : '';
79+
for( $p = 1; $p <= $pages; $p++ ):
80+
?>
81+
<li class="page-item <?= ( $p === ( $page ?? 1 ) ) ? 'active' : '' ?>">
82+
<a class="page-link" href="<?= route_path('admin_contact_submissions') ?>?page=<?= $p . $filterQs ?>"><?= $p ?></a>
83+
</li>
84+
<?php endfor; ?>
85+
</ul>
86+
</nav>
87+
<?php endif; ?>
88+
89+
<p class="text-muted small mt-2 mb-0"><?= (int) ( $total ?? 0 ) ?> total submission(s)</p>
90+
<?php endif; ?>
91+
</div>
92+
</div>
93+
</div>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
$labelFor = static function( string $name ) use ( $fields ): string {
3+
foreach( ( $fields ?? [] ) as $field )
4+
{
5+
if( ( $field['name'] ?? null ) === $name )
6+
{
7+
return $field['label'] ?? $name;
8+
}
9+
}
10+
return $name;
11+
};
12+
13+
// Order values by configured fields first, then any extra payload keys.
14+
$orderedKeys = [];
15+
foreach( ( $fields ?? [] ) as $field )
16+
{
17+
if( isset( $field['name'] ) && array_key_exists( $field['name'], $payload ?? [] ) )
18+
{
19+
$orderedKeys[] = $field['name'];
20+
}
21+
}
22+
foreach( array_keys( $payload ?? [] ) as $key )
23+
{
24+
if( !in_array( $key, $orderedKeys, true ) )
25+
{
26+
$orderedKeys[] = $key;
27+
}
28+
}
29+
?>
30+
<div class="container-fluid">
31+
<div class="d-flex justify-content-between align-items-center mb-4">
32+
<h2>Contact Submission #<?= htmlspecialchars( (string) ( $submission['id'] ?? '' ) ) ?></h2>
33+
<a href="<?= route_path('admin_contact_submissions') ?>" class="btn btn-outline-secondary">
34+
<i class="bi bi-arrow-left"></i> Back
35+
</a>
36+
</div>
37+
38+
<div class="row g-4">
39+
<div class="col-lg-4">
40+
<div class="card">
41+
<div class="card-header">Metadata</div>
42+
<div class="card-body">
43+
<dl class="row mb-0 small">
44+
<dt class="col-5">Form</dt>
45+
<dd class="col-7"><?= htmlspecialchars( (string) ( $submission['form_key'] ?? '' ) ) ?></dd>
46+
47+
<dt class="col-5">Recipient</dt>
48+
<dd class="col-7"><?= htmlspecialchars( (string) ( $submission['recipient'] ?? '' ) ) ?></dd>
49+
50+
<dt class="col-5">Subject</dt>
51+
<dd class="col-7"><?= htmlspecialchars( (string) ( $submission['subject'] ?? '-' ) ) ?></dd>
52+
53+
<dt class="col-5">Reply-To</dt>
54+
<dd class="col-7">
55+
<?php if( !empty( $submission['reply_to_email'] ) ): ?>
56+
<?= htmlspecialchars( trim( ( $submission['reply_to_name'] ?? '' ) . ' <' . $submission['reply_to_email'] . '>' ) ) ?>
57+
<?php else: ?>
58+
-
59+
<?php endif; ?>
60+
</dd>
61+
62+
<dt class="col-5">Delivered</dt>
63+
<dd class="col-7">
64+
<?php if( !empty( $submission['delivered'] ) ): ?>
65+
<span class="badge bg-success">Yes</span>
66+
<?php if( !empty( $submission['delivered_at'] ) ): ?>
67+
<div class="text-muted"><?= htmlspecialchars( (string) $submission['delivered_at'] ) ?></div>
68+
<?php endif; ?>
69+
<?php else: ?>
70+
<span class="badge bg-warning text-dark">No</span>
71+
<?php endif; ?>
72+
</dd>
73+
74+
<dt class="col-5">Received</dt>
75+
<dd class="col-7"><?= htmlspecialchars( (string) ( $submission['created_at'] ?? '' ) ) ?></dd>
76+
77+
<dt class="col-5">IP</dt>
78+
<dd class="col-7"><?= htmlspecialchars( (string) ( $submission['ip_address'] ?? '-' ) ) ?></dd>
79+
80+
<dt class="col-5">User Agent</dt>
81+
<dd class="col-7"><small class="text-muted"><?= htmlspecialchars( (string) ( $submission['user_agent'] ?? '-' ) ) ?></small></dd>
82+
</dl>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<div class="col-lg-8">
88+
<div class="card">
89+
<div class="card-header">Submitted Fields</div>
90+
<div class="card-body">
91+
<?php if( empty( $orderedKeys ) ): ?>
92+
<p class="text-muted mb-0">No field data.</p>
93+
<?php else: ?>
94+
<dl class="row mb-0">
95+
<?php foreach( $orderedKeys as $name ): ?>
96+
<dt class="col-sm-3"><?= htmlspecialchars( $labelFor( $name ) ) ?></dt>
97+
<dd class="col-sm-9" style="white-space: pre-wrap;"><?= htmlspecialchars( (string) ( $payload[ $name ] ?? '' ) ) ?></dd>
98+
<?php endforeach; ?>
99+
</dl>
100+
<?php endif; ?>
101+
</div>
102+
</div>
103+
</div>
104+
</div>
105+
</div>

resources/views/contact/index.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<section class="py-5">
2+
<div class="container">
3+
<div class="row justify-content-center">
4+
<div class="col-lg-7">
5+
<?= $ContactForm ?? '' ?>
6+
</div>
7+
</div>
8+
</div>
9+
</section>

resources/views/emails/contact.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
/**
3+
* Generic contact form email body.
4+
*
5+
* Available variables (extracted by the Sender):
6+
* $fields array Field definitions for the form
7+
* $values array Submitted values keyed by field name
8+
* $formLabel string Human label for the form
9+
* $formKey string Form key
10+
*/
11+
$fields = $fields ?? [];
12+
$values = $values ?? [];
13+
$formLabel = $formLabel ?? 'Contact Form';
14+
?>
15+
<!DOCTYPE html>
16+
<html lang="en">
17+
<head>
18+
<meta charset="UTF-8">
19+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
20+
<title><?= htmlspecialchars( $formLabel ) ?></title>
21+
<style>
22+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f4f4f4; }
23+
.email-container { max-width: 640px; margin: 20px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
24+
.email-header { background: #2c3e50; color: #fff; padding: 24px 28px; }
25+
.email-header h1 { margin: 0; font-size: 20px; }
26+
.email-body { padding: 24px 28px; }
27+
table.fields { width: 100%; border-collapse: collapse; }
28+
table.fields th { text-align: left; vertical-align: top; padding: 10px 12px; width: 30%; background: #f7f9fb; border-bottom: 1px solid #e5e7eb; color: #555; }
29+
table.fields td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: pre-wrap; }
30+
.email-footer { padding: 16px 28px; color: #888; font-size: 12px; background: #f9fafb; border-top: 1px solid #e5e7eb; }
31+
</style>
32+
</head>
33+
<body>
34+
<div class="email-container">
35+
<div class="email-header">
36+
<h1><?= htmlspecialchars( $formLabel ) ?></h1>
37+
</div>
38+
<div class="email-body">
39+
<p>A new contact form submission was received:</p>
40+
<table class="fields">
41+
<tbody>
42+
<?php foreach( $fields as $field ): ?>
43+
<?php
44+
$name = $field['name'] ?? null;
45+
if( $name === null )
46+
{
47+
continue;
48+
}
49+
$label = $field['label'] ?? $name;
50+
$value = $values[ $name ] ?? '';
51+
if( !is_scalar( $value ) )
52+
{
53+
$value = json_encode( $value );
54+
}
55+
?>
56+
<tr>
57+
<th><?= htmlspecialchars( (string) $label ) ?></th>
58+
<td><?= nl2br( htmlspecialchars( (string) $value ) ) ?></td>
59+
</tr>
60+
<?php endforeach; ?>
61+
</tbody>
62+
</table>
63+
</div>
64+
<div class="email-footer">
65+
This message was sent from the website contact form.
66+
</div>
67+
</div>
68+
</body>
69+
</html>

resources/views/layouts/admin.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
<li><hr class="dropdown-divider"></li>
4545
<li><a class="dropdown-item" href="<?= route_path('admin_events') ?>"><i class="bi bi-calendar-event me-2"></i>Events</a></li>
4646
<li><a class="dropdown-item" href="<?= route_path('admin_event_categories') ?>"><i class="bi bi-calendar3 me-2"></i>Event Categories</a></li>
47+
<li><hr class="dropdown-divider"></li>
48+
<li><a class="dropdown-item" href="<?= route_path('admin_contact_submissions') ?>"><i class="bi bi-envelope me-2"></i>Contact Submissions</a></li>
4749
</ul>
4850
</li>
4951
<li class="nav-item dropdown">

0 commit comments

Comments
 (0)