Add success confirmation message to newsletter plugin Update translation files with confirmation text Improve form handling to show confirmation message Add styling for confirmation message display Ensure name field is properly included in form
537 lines
18 KiB
PHP
537 lines
18 KiB
PHP
<?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!',
|
|
'success_confirm' => 'Takk! Vi har sendt en e-post til deg. Åpne lenken i e-posten og bekreft for å fullføre påmeldingen.',
|
|
'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'),
|
|
'successConfirm' => htmlspecialchars(newsletterT('success_confirm'), 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) {
|
|
var confirmMsg = form.getAttribute('data-success-confirm');
|
|
if (confirmMsg) {
|
|
var notice = document.createElement('p');
|
|
notice.className = 'newsletter-confirm';
|
|
notice.textContent = confirmMsg;
|
|
form.replaceWith(notice);
|
|
} else {
|
|
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">✓ <span class="btn-success-text"></span></span>
|
|
<span class="btn-error" aria-hidden="true">✗ <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-success-confirm="{$t['successConfirm']}" 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_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 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>
|
|
<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">✓ <span class="btn-success-text"></span></span>
|
|
<span class="btn-error" aria-hidden="true">✗ <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 .newsletter-confirm {
|
|
margin: 0.3rem 0 0;
|
|
font-size: 0.95rem;
|
|
color: var(--color-green);
|
|
font-weight: 600;
|
|
}
|
|
.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();
|