Add newsletter signup plugin with Listmonk integration
Add newsletter signup page with small theme example
This commit is contained in:
parent
e7f2832751
commit
8f88e242b7
2 changed files with 522 additions and 0 deletions
1
content/04-nyhetsbrev.php
Normal file
1
content/04-nyhetsbrev.php
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?= newsletter_signup('Få månedelige nyhetsbrev rett i innboksen!', 'small') ?>
|
||||
521
custom/plugins/page/newsletter-signup.php
Normal file
521
custom/plugins/page/newsletter-signup.php
Normal 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">✓ <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-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">✓ <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 .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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue