Add newsletter signup plugin with Listmonk integration

Add newsletter signup page with small theme example
This commit is contained in:
Ruben 2026-01-17 00:03:41 +01:00
parent e7f2832751
commit 8f88e242b7
2 changed files with 522 additions and 0 deletions

View file

@ -0,0 +1,521 @@
<?php
/**
* Newsletter Signup Plugin
*
* Modular newsletter signup section that can be included on any page.
* Uses Listmonk's public subscription API for double opt-in.
* Supports i18n via language files.
*
* Usage: Add to metadata.ini:
* plugins = "newsletter-signup"
*
* Then in your PHP content file, call the function with your custom text and theme:
* <?= newsletter_signup('Your custom intro text here', 'hero') ?>
* <?= newsletter_signup('Short text here', 'small') ?>
*
* Available themes:
* - 'hero': Full-width gradient background section (like CTA sections)
* - 'small': Compact inline notice with rounded border, fits between paragraphs
*/
// Start session if not already started
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Generate CSRF token if not exists
if (empty($_SESSION['newsletter_csrf_token'])) {
$_SESSION['newsletter_csrf_token'] = bin2hex(random_bytes(32));
}
/**
* Get translation for newsletter plugin
*/
function newsletterT(string $key): string {
$ctx = $GLOBALS['ctx'] ?? null;
if (!$ctx) {
// Fallback values if no context
$fallbacks = [
'name_label' => 'Navn',
'name_placeholder' => 'Ditt navn',
'email_label' => 'E-post',
'email_placeholder' => 'Din e-postadresse',
'notice' => 'Vi sender ca. 12 e-poster i året.',
'submit_button' => 'Meld deg på',
'success_message' => 'Sjekk innboksen din!',
'error_message' => 'Noe gikk galt!'
];
return $fallbacks[$key] ?? $key;
}
$translations = $ctx->get('translations', []);
$fullKey = "newsletter.{$key}";
return $translations[$fullKey] ?? $key;
}
/**
* Subscribe to newsletter via Listmonk public API
*/
function newsletterSubscribe(string $email, string $name): array {
$configPath = dirname(__DIR__, 2) . '/listmonk-config.php';
if (!file_exists($configPath)) {
return ['success' => false, 'error' => 'config_missing'];
}
$config = require $configPath;
if (!$config['enabled']) {
return ['success' => false, 'error' => 'disabled'];
}
$payload = json_encode([
'email' => $email,
'name' => $name,
'list_uuids' => $config['list_uuids']
]);
$url = $config['url'] . '/api/public/subscription';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($curlError) {
error_log("Newsletter curl error: {$curlError}");
return ['success' => false, 'error' => 'network'];
}
if ($httpCode >= 200 && $httpCode < 300) {
return ['success' => true];
}
error_log("Newsletter subscription failed: HTTP {$httpCode}, Response: {$response}");
return ['success' => false, 'error' => 'api'];
}
/**
* Rate limit check for newsletter signups
*/
function newsletterCheckRateLimit(): bool {
$lastSubmit = $_SESSION['newsletter_last_submit'] ?? 0;
return (time() - $lastSubmit) >= 30;
}
/**
* Get shared form translations
*/
function newsletterGetTranslations(): array {
return [
'nameLabel' => htmlspecialchars(newsletterT('name_label'), ENT_QUOTES, 'UTF-8'),
'namePlaceholder' => htmlspecialchars(newsletterT('name_placeholder'), ENT_QUOTES, 'UTF-8'),
'emailLabel' => htmlspecialchars(newsletterT('email_label'), ENT_QUOTES, 'UTF-8'),
'emailPlaceholder' => htmlspecialchars(newsletterT('email_placeholder'), ENT_QUOTES, 'UTF-8'),
'notice' => htmlspecialchars(newsletterT('notice'), ENT_QUOTES, 'UTF-8'),
'submitButton' => htmlspecialchars(newsletterT('submit_button'), ENT_QUOTES, 'UTF-8'),
'successMessage' => htmlspecialchars(newsletterT('success_message'), ENT_QUOTES, 'UTF-8'),
'errorMessage' => htmlspecialchars(newsletterT('error_message'), ENT_QUOTES, 'UTF-8'),
];
}
/**
* Get shared JavaScript (only included once per page)
*/
function newsletterGetScript(): string {
static $scriptIncluded = false;
if ($scriptIncluded) {
return '';
}
$scriptIncluded = true;
return <<<'SCRIPT'
<script>
(function() {
var forms = document.querySelectorAll('[data-newsletter-form]');
forms.forEach(function(form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type="submit"]');
if (btn.classList.contains('is-loading') || btn.classList.contains('is-success')) return;
btn.classList.remove('is-error');
btn.classList.add('is-loading');
btn.disabled = true;
var formData = new FormData(form);
formData.append('newsletter_submit', '1');
fetch(window.location.href, {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.classList.remove('is-loading');
if (data.success) {
btn.classList.add('is-success');
var successTextEl = btn.querySelector('.btn-success-text');
var successMsg = form.getAttribute('data-success-message');
if (successTextEl && successMsg) successTextEl.textContent = successMsg;
setTimeout(function() {
btn.classList.remove('is-success');
btn.disabled = false;
}, 5000);
} else {
btn.classList.add('is-error');
var errorTextEl = btn.querySelector('.btn-error-text');
var errorMsg = form.getAttribute('data-error-message');
if (errorTextEl && errorMsg) errorTextEl.textContent = errorMsg;
btn.disabled = false;
setTimeout(function() {
btn.classList.remove('is-error');
}, 5000);
}
})
.catch(function() {
btn.classList.remove('is-loading');
btn.classList.add('is-error');
var errorTextEl = btn.querySelector('.btn-error-text');
var errorMsg = form.getAttribute('data-error-message');
if (errorTextEl && errorMsg) errorTextEl.textContent = errorMsg;
btn.disabled = false;
setTimeout(function() {
btn.classList.remove('is-error');
}, 5000);
});
});
});
})();
</script>
SCRIPT;
}
/**
* Render the "hero" theme - full-width gradient background section
*/
function newsletterRenderHero(string $introText, string $formId): string {
$csrfToken = $_SESSION['newsletter_csrf_token'];
$nonce = bin2hex(random_bytes(8));
$escapedIntro = htmlspecialchars($introText, ENT_QUOTES, 'UTF-8');
$escapedFormId = htmlspecialchars($formId, ENT_QUOTES, 'UTF-8');
$t = newsletterGetTranslations();
$html = <<<HTML
<section class="newsletter-section newsletter-hero escape" id="{$escapedFormId}">
<div class="newsletter-content contain">
<p class="newsletter-intro">{$escapedIntro}</p>
<form class="newsletter-form" method="post" data-newsletter-form data-success-message="{$t['successMessage']}" data-error-message="{$t['errorMessage']}">
<input type="hidden" name="newsletter_csrf" value="{$csrfToken}">
<input type="hidden" name="newsletter_form_id" value="{$escapedFormId}">
<div class="newsletter-fields">
<div class="newsletter-field">
<label for="newsletter_name_{$nonce}" class="visually-hidden">{$t['nameLabel']}</label>
<input type="text" id="newsletter_name_{$nonce}" name="newsletter_name" placeholder="{$t['namePlaceholder']}" required maxlength="100">
</div>
<div class="newsletter-field">
<label for="newsletter_email_{$nonce}" class="visually-hidden">{$t['emailLabel']}</label>
<input type="email" id="newsletter_email_{$nonce}" name="newsletter_email" placeholder="{$t['emailPlaceholder']}" required maxlength="100">
</div>
</div>
<p class="newsletter-notice">{$t['notice']}</p>
<button type="submit" name="newsletter_submit" class="button bigger inverted">
<span class="btn-text">{$t['submitButton']}</span>
<span class="btn-spinner" aria-hidden="true"></span>
<span class="btn-success" aria-hidden="true">&#10003; <span class="btn-success-text"></span></span>
<span class="btn-error" aria-hidden="true">&#10007; <span class="btn-error-text"></span></span>
</button>
</form>
</div>
</section>
HTML;
return $html;
}
/**
* Render the "small" theme - compact inline notice with border
*/
function newsletterRenderSmall(string $introText, string $formId): string {
$csrfToken = $_SESSION['newsletter_csrf_token'];
$nonce = bin2hex(random_bytes(8));
$escapedIntro = htmlspecialchars($introText, ENT_QUOTES, 'UTF-8');
$escapedFormId = htmlspecialchars($formId, ENT_QUOTES, 'UTF-8');
$t = newsletterGetTranslations();
$html = <<<HTML
<aside class="newsletter-section newsletter-small" id="{$escapedFormId}">
<p class="newsletter-intro">{$escapedIntro}</p>
<form class="newsletter-form" method="post" data-newsletter-form data-success-message="{$t['successMessage']}" data-error-message="{$t['errorMessage']}">
<input type="hidden" name="newsletter_csrf" value="{$csrfToken}">
<input type="hidden" name="newsletter_form_id" value="{$escapedFormId}">
<div class="newsletter-fields">
<div class="newsletter-field">
<label for="newsletter_name_{$nonce}" class="visually-hidden">{$t['nameLabel']}</label>
<input type="text" id="newsletter_name_{$nonce}" name="newsletter_name" placeholder="{$t['namePlaceholder']}" required maxlength="100">
</div>
<div class="newsletter-field">
<label for="newsletter_email_{$nonce}" class="visually-hidden">{$t['emailLabel']}</label>
<input type="email" id="newsletter_email_{$nonce}" name="newsletter_email" placeholder="{$t['emailPlaceholder']}" required maxlength="100">
</div>
<button type="submit" name="newsletter_submit" class="button">
<span class="btn-text">{$t['submitButton']}</span>
<span class="btn-spinner" aria-hidden="true"></span>
<span class="btn-success" aria-hidden="true">&#10003; <span class="btn-success-text"></span></span>
<span class="btn-error" aria-hidden="true">&#10007; <span class="btn-error-text"></span></span>
</button>
</div>
</form>
</aside>
HTML;
return $html;
}
/**
* Get CSS for all themes (only included once per page)
*/
function newsletterGetStyles(): string {
static $stylesIncluded = false;
if ($stylesIncluded) {
return '';
}
$stylesIncluded = true;
return <<<'STYLES'
<style>
/* Shared styles */
.newsletter-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.newsletter-form .btn-spinner,
.newsletter-form .btn-success,
.newsletter-form .btn-error {
display: none;
}
.newsletter-form .button {
position: relative;
}
.newsletter-form .button.is-loading .btn-text { visibility: hidden; }
.newsletter-form .button.is-loading .btn-spinner {
display: block;
position: absolute;
inset: 0;
margin: auto;
width: 1.2rem;
height: 1.2rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: newsletter-spin 0.8s linear infinite;
}
.newsletter-form .button.is-success .btn-text { display: none; }
.newsletter-form .button.is-success .btn-success { display: inline; }
.newsletter-form .button.is-error .btn-text { display: none; }
.newsletter-form .button.is-error .btn-error { display: inline; }
@keyframes newsletter-spin {
to { transform: rotate(360deg); }
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Hero theme */
.newsletter-hero {
background: linear-gradient(135deg, var(--color-green), var(--color-blue));
color: white;
padding: 1rem 1rem 2rem 1rem;
margin-top: 3rem;
}
.newsletter-hero .newsletter-content {
text-align: center;
}
.newsletter-hero .newsletter-intro {
font-size: clamp(1.1rem, 3vw, 1.3rem);
line-height: 1.5;
}
.newsletter-hero .newsletter-fields {
display: flex;
gap: 0.8rem;
width: 100%;
max-width: 32rem;
flex-direction: row;
flex-wrap: wrap;
}
.newsletter-hero .newsletter-field {
flex: 1 1 clamp(45%, (20rem - 100vw) * 999, 100%);
min-width: 0;
}
.newsletter-hero .newsletter-field input {
width: 100%;
padding: 0.7rem 1rem;
border: none;
border-radius: 2rem;
font-family: inherit;
font-size: 1rem;
box-sizing: border-box;
}
.newsletter-hero .newsletter-field input:focus {
outline: 2px solid white;
outline-offset: 2px;
}
.newsletter-hero .newsletter-notice {
font-size: 0.85rem;
opacity: 0.9;
margin: 0;
}
.newsletter-hero .button {
min-width: 10rem;
}
.newsletter-hero .button.is-success { background-color: white; color: var(--color-green); }
.newsletter-hero .button.is-error { background-color: oklch(0.55 0.2 25); color: white; outline: none; }
/* Small theme */
.newsletter-small {
border: 2px solid var(--color-green);
border-radius: 0.5rem;
padding: 0.6rem 0.8rem;
margin-top: 2em;
}
.newsletter-small .newsletter-intro {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 600;
// text-align: center;
font-size: 1.2rem;
}
.newsletter-small .newsletter-form {
align-items: stretch;
gap: 0;
margin-top:0;
}
.newsletter-small .newsletter-fields {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.newsletter-small .newsletter-field {
flex: 1 1 clamp(30%, (28rem - 100%) * 999, 100%);
min-width: 0;
}
.newsletter-small .newsletter-field input {
width: 100%;
padding: 0.4rem 0.7rem;
border: 1px solid oklch(0.8 0 0);
border-radius: 0.25rem;
font-family: inherit;
font-size: 0.9rem;
box-sizing: border-box;
}
.newsletter-small .newsletter-field input:focus {
outline: none;
border-color: var(--color-green);
box-shadow: 0 0 0 0.15rem oklch(0.618 0.1176 173.93 / 0.25);
}
.newsletter-small .button {
flex-shrink: 0;
padding: 0.2rem 0.8rem;
font-size: 0.9rem;
margin-top:0;
}
.newsletter-small .button.is-success { background-color: var(--color-green); color: white; }
.newsletter-small .button.is-error { background-color: oklch(0.55 0.2 25); color: white; outline: none; }
</style>
STYLES;
}
/**
* Render the newsletter signup section HTML
* This is the main function to call from content files.
*
* @param string $introText Custom intro text to display above the form
* @param string $theme Theme to use: 'hero' (default) or 'small'
* @param string $formId Optional form ID for the section (default: 'newsletter')
* @return string The complete HTML for the newsletter signup section
*/
function newsletter_signup(string $introText, string $theme = 'hero', string $formId = 'newsletter'): string {
$html = newsletterGetStyles();
switch ($theme) {
case 'small':
$html .= newsletterRenderSmall($introText, $formId);
break;
case 'hero':
default:
$html .= newsletterRenderHero($introText, $formId);
break;
}
$html .= newsletterGetScript();
return $html;
}
/**
* Handle AJAX form submission
*/
function newsletterHandleSubmission(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_POST['newsletter_submit'])) {
return;
}
header('Content-Type: application/json');
// CSRF validation
if (!isset($_POST['newsletter_csrf']) ||
!hash_equals($_SESSION['newsletter_csrf_token'] ?? '', $_POST['newsletter_csrf'])) {
echo json_encode(['success' => false, 'error' => 'csrf']);
exit;
}
// Rate limit
if (!newsletterCheckRateLimit()) {
echo json_encode(['success' => false, 'error' => 'rate_limit']);
exit;
}
// Validate input
$name = trim($_POST['newsletter_name'] ?? '');
$email = strtolower(trim($_POST['newsletter_email'] ?? ''));
if (empty($name) || strlen($name) < 2 || strlen($name) > 100) {
echo json_encode(['success' => false, 'error' => 'invalid_name']);
exit;
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100) {
echo json_encode(['success' => false, 'error' => 'invalid_email']);
exit;
}
// Subscribe
$result = newsletterSubscribe($email, $name);
if ($result['success']) {
$_SESSION['newsletter_last_submit'] = time();
}
echo json_encode($result);
exit;
}
// Handle form submission early (before any output)
newsletterHandleSubmission();