Remove unused templates for newsletter creation, settings, and submissions; update unsubscribe confirmation link; add tests for email templates API. #1
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -105,5 +105,5 @@ jobs:
|
||||
file: Dockerfile
|
||||
push: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && (env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '') }}
|
||||
tags: |
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:latest
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:${{ steps.meta.outputs.sha }}
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact-server:latest
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact-server:${{ steps.meta.outputs.sha }}
|
||||
|
||||
34
README.md
34
README.md
@@ -1,6 +1,34 @@
|
||||
# Server README
|
||||
|
||||
Backend service for the contact website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
|
||||
Backend service for a static website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Server README](#server-readme)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Admin Access](#admin-access)
|
||||
- [API Surface](#api-surface)
|
||||
- [Embeddable Forms](#embeddable-forms)
|
||||
- [Running With Docker](#running-with-docker)
|
||||
- [Build manually](#build-manually)
|
||||
- [Run with explicit environment variables](#run-with-explicit-environment-variables)
|
||||
- [Run using docker-compose](#run-using-docker-compose)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Core runtime](#core-runtime)
|
||||
- [Admin authentication](#admin-authentication)
|
||||
- [Database configuration](#database-configuration)
|
||||
- [Email delivery](#email-delivery)
|
||||
- [Rate limiting and caching](#rate-limiting-and-caching)
|
||||
- [Request hardening](#request-hardening)
|
||||
- [Observability](#observability)
|
||||
- [Docker / Gunicorn runtime](#docker--gunicorn-runtime)
|
||||
- [Health Checks and Monitoring](#health-checks-and-monitoring)
|
||||
- [Testing](#testing)
|
||||
- [Deployment Notes](#deployment-notes)
|
||||
- [Email Templates](#email-templates)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -119,7 +147,7 @@ Replace `https://your-server-domain` with your actual server URL. The iframe cod
|
||||
### Build manually
|
||||
|
||||
```pwsh
|
||||
docker build -t contact.allucanget.biz -f Dockerfile .
|
||||
docker build -t backend-server -f Dockerfile .
|
||||
```
|
||||
|
||||
### Run with explicit environment variables
|
||||
@@ -132,7 +160,7 @@ docker run --rm -p 5002:5002 `
|
||||
-e SMTP_USERNAME=api@example.com `
|
||||
-e SMTP_PASSWORD=secret `
|
||||
-e SMTP_RECIPIENTS=hello@example.com `
|
||||
contact.allucanget.biz
|
||||
backend-server
|
||||
```
|
||||
|
||||
### Run using docker-compose
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import admin, auth, contact, embed, monitoring, newsletter
|
||||
from . import admin, auth, contact, embed, monitoring, newsletter, static
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
@@ -14,3 +14,4 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(embed.bp)
|
||||
app.register_blueprint(static.bp)
|
||||
|
||||
@@ -14,21 +14,21 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@auth.login_required
|
||||
def dashboard():
|
||||
"""Display admin dashboard overview."""
|
||||
return render_template("admin_dashboard.html")
|
||||
return render_template("admin/admin_dashboard.html")
|
||||
|
||||
|
||||
@bp.route("/newsletter")
|
||||
@auth.login_required
|
||||
def newsletter_subscribers():
|
||||
"""Display newsletter subscriber management page."""
|
||||
return render_template("admin_newsletter.html")
|
||||
return render_template("admin/admin_newsletter.html")
|
||||
|
||||
|
||||
@bp.route("/newsletter/create")
|
||||
@auth.login_required
|
||||
def newsletter_create():
|
||||
"""Display newsletter creation and sending page."""
|
||||
return render_template("admin_newsletter_create.html")
|
||||
return render_template("admin/admin_newsletter_create.html")
|
||||
|
||||
|
||||
@bp.route("/settings")
|
||||
@@ -72,21 +72,28 @@ def settings_page():
|
||||
},
|
||||
}
|
||||
|
||||
return render_template("admin_settings.html", settings=app_settings)
|
||||
return render_template("admin/admin_settings.html", settings=app_settings)
|
||||
|
||||
|
||||
@bp.route("/submissions")
|
||||
@auth.login_required
|
||||
def submissions():
|
||||
"""Display contact form submissions page."""
|
||||
return render_template("admin_submissions.html")
|
||||
return render_template("admin/admin_submissions.html")
|
||||
|
||||
|
||||
@bp.route("/embeds")
|
||||
@auth.login_required
|
||||
def embeds():
|
||||
"""Display embeddable forms management page."""
|
||||
return render_template("admin_embeds.html")
|
||||
return render_template("admin/admin_embeds.html")
|
||||
|
||||
|
||||
@bp.route('/email-templates')
|
||||
@auth.login_required
|
||||
def email_templates():
|
||||
"""Display admin page for editing email templates."""
|
||||
return render_template('admin/admin_email_templates.html')
|
||||
|
||||
|
||||
@bp.route("/api/settings", methods=["GET"])
|
||||
@@ -110,6 +117,11 @@ def validate_setting(key: str, value: str) -> str | None:
|
||||
"newsletter_enabled": lambda v: v in ["true", "false"],
|
||||
"rate_limit_max": lambda v: v.isdigit() and 0 <= int(v) <= 1000,
|
||||
"rate_limit_window": lambda v: v.isdigit() and 1 <= int(v) <= 3600,
|
||||
# Embed dimensions (pixels)
|
||||
"embed_contact_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_contact_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_newsletter_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_newsletter_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
}
|
||||
|
||||
if key in validations and not validations[key](value):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Embeddable forms routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
import os
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint("embed", __name__)
|
||||
|
||||
@@ -17,10 +16,3 @@ def contact_form():
|
||||
def newsletter_form():
|
||||
"""Serve the embeddable newsletter subscription form."""
|
||||
return render_template("embed_newsletter.html")
|
||||
|
||||
|
||||
@bp.route("/static/css/styles.css", methods=["GET"])
|
||||
def serve_css():
|
||||
"""Serve the unified CSS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'css/styles.css')
|
||||
|
||||
27
server/routes/static.py
Normal file
27
server/routes/static.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
import os
|
||||
|
||||
bp = Blueprint("static", __name__)
|
||||
|
||||
|
||||
@bp.route("/static/css/styles.css", methods=["GET"])
|
||||
def serve_css():
|
||||
"""Serve the unified CSS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'css/styles.css')
|
||||
|
||||
|
||||
@bp.route("/static/css/admin.css", methods=["GET"])
|
||||
def serve_admin_css():
|
||||
"""Serve the unified CSS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'css/admin.css')
|
||||
|
||||
|
||||
@bp.route("/static/js/admin.js", methods=["GET"])
|
||||
def serve_admin_js():
|
||||
"""Serve the unified JS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'js/admin.js')
|
||||
231
static/css/admin.css
Normal file
231
static/css/admin.css
Normal file
@@ -0,0 +1,231 @@
|
||||
/* Navigation links */
|
||||
nav a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Admin newsletter */
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Subscribers table */
|
||||
.subscribers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.subscribers-table th,
|
||||
.subscribers-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.subscribers-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
.subscribers-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Newsletter creation form */
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.newsletter-preview {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.newsletter-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
}
|
||||
.newsletter-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Admin settings */
|
||||
.setting strong {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
.iframe-code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Admin settings cards grid */
|
||||
.settings-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
flex: 1 1 300px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Keep individual dynamic setting layout readable */
|
||||
.setting {
|
||||
font-size: 0.95rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-card {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner styles used by embed pages */
|
||||
#spinnerIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
#submitSpinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
color: #333;
|
||||
}
|
||||
@@ -76,21 +78,32 @@ button:hover,
|
||||
.btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* Message styles */
|
||||
@@ -164,13 +177,21 @@ button:hover,
|
||||
min-width: 150px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.stat-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.stat-card p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #6c757d;
|
||||
@@ -260,17 +281,6 @@ button:hover,
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Navigation links */
|
||||
nav a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
table {
|
||||
width: 100%;
|
||||
@@ -298,6 +308,7 @@ tr:hover {
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -321,6 +332,26 @@ tr:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pagination button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pagination .current-page {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
|
||||
986
static/js/admin.js
Normal file
986
static/js/admin.js
Normal file
@@ -0,0 +1,986 @@
|
||||
/**
|
||||
* Admin JavaScript - Consolidated functionality for all admin pages
|
||||
* Provides shared utilities, API interactions, and page-specific features
|
||||
*/
|
||||
|
||||
// ==================== UTILITY FUNCTIONS ====================
|
||||
|
||||
/**
|
||||
* Displays a message to the user with auto-hide functionality
|
||||
* @param {string} text - The message text to display
|
||||
* @param {string} type - Message type ('success', 'error', 'info', etc.)
|
||||
*/
|
||||
function showMessage(text, type) {
|
||||
let messageDiv = document.getElementById("message");
|
||||
if (!messageDiv) {
|
||||
messageDiv = document.createElement("div");
|
||||
messageDiv.id = "message";
|
||||
document.body.insertBefore(messageDiv, document.body.firstChild);
|
||||
}
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
setTimeout(() => (messageDiv.style.display = "none"), 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML characters to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML string
|
||||
*/
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies text to clipboard with fallback for older browsers
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback method
|
||||
const tmp = document.createElement("textarea");
|
||||
tmp.value = text;
|
||||
tmp.setAttribute("readonly", "");
|
||||
tmp.style.position = "absolute";
|
||||
tmp.style.left = "-9999px";
|
||||
document.body.appendChild(tmp);
|
||||
tmp.select();
|
||||
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(tmp);
|
||||
return ok;
|
||||
} catch (err) {
|
||||
document.body.removeChild(tmp);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SETTINGS MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
* Loads settings from API and displays them
|
||||
*/
|
||||
function loadSettingsForList() {
|
||||
fetch("/admin/api/settings")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings = data.settings || {};
|
||||
displaySettings();
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading settings: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading settings", "error");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches embed settings from API
|
||||
* @returns {Promise<Object>} Settings object or empty object on error
|
||||
*/
|
||||
async function fetchEmbedSettings() {
|
||||
try {
|
||||
const response = await fetch("/admin/api/settings");
|
||||
const data = await response.json();
|
||||
return data.status === "ok" && data.settings ? data.settings : {};
|
||||
} catch (err) {
|
||||
console.error("Failed to load embed settings", err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a setting via API
|
||||
* @param {string} key - Setting key
|
||||
* @param {string} inputId - Input element ID
|
||||
*/
|
||||
async function saveEmbedSetting(key, inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return showMessage("Input not found", "error");
|
||||
|
||||
const value = (input.value || "").toString().trim();
|
||||
if (!value) return showMessage("Value cannot be empty", "error");
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/admin/api/settings/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "ok") {
|
||||
showMessage("Setting saved", "success");
|
||||
// Rebuild textarea values to reflect the new size
|
||||
if (typeof loadEmbedSettingsAndInit === "function") {
|
||||
loadEmbedSettingsAndInit();
|
||||
}
|
||||
} else {
|
||||
showMessage("Failed to save setting: " + (data.message || ""), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save setting", err);
|
||||
showMessage("Failed to save setting", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EMBED MANAGEMENT ====================
|
||||
|
||||
/**
|
||||
* Initializes embed settings and populates form fields
|
||||
*/
|
||||
async function loadEmbedSettingsAndInit() {
|
||||
const origin =
|
||||
window.location && window.location.origin
|
||||
? window.location.origin
|
||||
: "http://your-server-domain";
|
||||
|
||||
// Default dimensions
|
||||
let contactWidth = "600",
|
||||
contactHeight = "400";
|
||||
let newsletterWidth = "600",
|
||||
newsletterHeight = "300";
|
||||
|
||||
try {
|
||||
const settings = await fetchEmbedSettings();
|
||||
contactWidth = settings.embed_contact_width || contactWidth;
|
||||
contactHeight = settings.embed_contact_height || contactHeight;
|
||||
newsletterWidth = settings.embed_newsletter_width || newsletterWidth;
|
||||
newsletterHeight = settings.embed_newsletter_height || newsletterHeight;
|
||||
} catch (err) {
|
||||
console.error("Error loading embed settings", err);
|
||||
}
|
||||
|
||||
// Update input fields
|
||||
const inputs = {
|
||||
contactWidth,
|
||||
contactHeight,
|
||||
newsletterWidth,
|
||||
newsletterHeight,
|
||||
};
|
||||
|
||||
Object.entries(inputs).forEach(([id, value]) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.value = value;
|
||||
});
|
||||
|
||||
// Update iframe code textareas
|
||||
const contactTextarea = document.getElementById("iframeCode");
|
||||
if (contactTextarea) {
|
||||
contactTextarea.value = `<iframe src="${origin}/embed/contact" width="${contactWidth}" height="${contactHeight}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
const newsletterTextarea = document.getElementById("iframeNewsletterCode");
|
||||
if (newsletterTextarea) {
|
||||
newsletterTextarea.value = `<iframe src="${origin}/embed/newsletter" width="${newsletterWidth}" height="${newsletterHeight}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
const contactPreview = document.getElementById("contactFormPreview");
|
||||
if (contactPreview) {
|
||||
contactPreview.innerHTML = `<iframe src="${origin}/embed/contact" width="${contactWidth}" height="${contactHeight}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
const newsletterPreview = document.getElementById("newsletterFormPreview");
|
||||
if (newsletterPreview) {
|
||||
newsletterPreview.innerHTML = `<iframe src="${origin}/embed/newsletter" width="${newsletterWidth}" height="${newsletterHeight}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies contact iframe code to clipboard
|
||||
*/
|
||||
function copyIframeCode() {
|
||||
const textarea = document.getElementById("iframeCode");
|
||||
if (!textarea) return showMessage("Contact iframe not found", "error");
|
||||
|
||||
copyToClipboard(textarea.value).then((ok) =>
|
||||
showMessage(
|
||||
ok
|
||||
? "Contact iframe code copied to clipboard!"
|
||||
: "Failed to copy contact iframe code",
|
||||
ok ? "success" : "error"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies newsletter iframe code to clipboard
|
||||
*/
|
||||
function copyNewsletterIframeCode() {
|
||||
const textarea = document.getElementById("iframeNewsletterCode");
|
||||
if (!textarea) return showMessage("Newsletter iframe not found", "error");
|
||||
|
||||
copyToClipboard(textarea.value).then((ok) =>
|
||||
showMessage(
|
||||
ok
|
||||
? "Newsletter iframe code copied to clipboard!"
|
||||
: "Failed to copy newsletter iframe code",
|
||||
ok ? "success" : "error"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== DASHBOARD ====================
|
||||
|
||||
/**
|
||||
* Loads and displays dashboard statistics
|
||||
*/
|
||||
async function loadDashboardStats() {
|
||||
try {
|
||||
const [contactRes, newsletterRes, settingsRes] = await Promise.all([
|
||||
fetch("/admin/api/contact?page=1&per_page=1"),
|
||||
fetch("/admin/api/newsletter?page=1&per_page=1"),
|
||||
fetch("/admin/api/settings"),
|
||||
]);
|
||||
|
||||
if (contactRes.ok) {
|
||||
const data = await contactRes.json();
|
||||
document.getElementById("contact-count").textContent =
|
||||
data.pagination.total;
|
||||
}
|
||||
|
||||
if (newsletterRes.ok) {
|
||||
const data = await newsletterRes.json();
|
||||
document.getElementById("newsletter-count").textContent =
|
||||
data.pagination.total;
|
||||
}
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
document.getElementById("settings-count").textContent = Object.keys(
|
||||
data.settings || {}
|
||||
).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EMAIL TEMPLATES ====================
|
||||
|
||||
/**
|
||||
* Loads email template from settings
|
||||
*/
|
||||
function loadEmailTemplate() {
|
||||
const textarea = document.getElementById("newsletterTemplate");
|
||||
if (!textarea) return;
|
||||
|
||||
fetch("/admin/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (
|
||||
data.status === "ok" &&
|
||||
data.settings &&
|
||||
data.settings.newsletter_confirmation_template
|
||||
) {
|
||||
textarea.value = data.settings.newsletter_confirmation_template;
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load template", err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves email template to settings
|
||||
*/
|
||||
function saveEmailTemplate() {
|
||||
const textarea = document.getElementById("newsletterTemplate");
|
||||
const message = document.getElementById("message");
|
||||
if (!textarea || !message) return;
|
||||
|
||||
const value = textarea.value || "";
|
||||
|
||||
fetch("/admin/api/settings/newsletter_confirmation_template", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const isSuccess = data.status === "ok";
|
||||
message.className = `message ${isSuccess ? "success" : "error"}`;
|
||||
message.textContent = isSuccess
|
||||
? "Template saved"
|
||||
: `Failed to save template: ${data.message || ""}`;
|
||||
setTimeout(() => (message.textContent = ""), 4000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to save template", err);
|
||||
message.className = "message error";
|
||||
message.textContent = "Failed to save template";
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== NEWSLETTER CREATION ====================
|
||||
|
||||
let newsletterStats = {};
|
||||
|
||||
/**
|
||||
* Loads newsletter statistics
|
||||
*/
|
||||
function loadNewsletterStats() {
|
||||
fetch("/admin/api/newsletter?page=1&per_page=1")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
const total = data.pagination.total;
|
||||
document.getElementById("totalSubscribers").textContent = total;
|
||||
document.getElementById("activeSubscribers").textContent = total; // Assume all active
|
||||
newsletterStats.totalSubscribers = total;
|
||||
}
|
||||
})
|
||||
.catch((error) => console.error("Error loading subscriber stats:", error))
|
||||
.finally(() => {
|
||||
const lastSentEl = document.getElementById("lastSent");
|
||||
if (lastSentEl) lastSentEl.textContent = "N/A";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates newsletter preview
|
||||
*/
|
||||
function previewNewsletter() {
|
||||
const subject = document.getElementById("subject").value.trim();
|
||||
const content = document.getElementById("content").value.trim();
|
||||
const senderName = document.getElementById("senderName").value.trim();
|
||||
|
||||
if (!subject || !content) {
|
||||
return showMessage(
|
||||
"Subject and content are required for preview.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
if (previewContent) {
|
||||
previewContent.innerHTML = `
|
||||
<h2>${escapeHtml(subject)}</h2>
|
||||
${
|
||||
senderName
|
||||
? `<p><strong>From:</strong> ${escapeHtml(senderName)}</p>`
|
||||
: ""
|
||||
}
|
||||
<div style="margin-top: 20px; line-height: 1.6;">
|
||||
${content.replace(/\n/g, "<br>")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const previewSection = document.getElementById("previewSection");
|
||||
if (previewSection) previewSection.classList.remove("hidden");
|
||||
|
||||
showMessage("Newsletter preview generated.", "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves newsletter as draft
|
||||
*/
|
||||
function saveDraft() {
|
||||
const form = document.getElementById("newsletterForm");
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const newsletterData = {
|
||||
subject: formData.get("subject"),
|
||||
content: formData.get("content"),
|
||||
sender_name: formData.get("sender_name"),
|
||||
send_date: formData.get("send_date"),
|
||||
status: "draft",
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
return showMessage("Subject and content are required.", "error");
|
||||
}
|
||||
|
||||
fetch("/admin/api/newsletters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newsletterData),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
showMessage(
|
||||
data.status === "ok"
|
||||
? "Newsletter draft saved successfully!"
|
||||
: data.message || "Failed to save draft.",
|
||||
data.status === "ok" ? "success" : "error"
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error saving draft:", error);
|
||||
showMessage("Failed to save draft.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends newsletter to subscribers
|
||||
*/
|
||||
function sendNewsletter() {
|
||||
const form = document.getElementById("newsletterForm");
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const newsletterData = {
|
||||
subject: formData.get("subject"),
|
||||
content: formData.get("content"),
|
||||
sender_name: formData.get("sender_name"),
|
||||
send_date: formData.get("send_date"),
|
||||
status: "sent",
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
return showMessage("Subject and content are required.", "error");
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to send this newsletter to ${
|
||||
newsletterStats.totalSubscribers || 0
|
||||
} subscribers?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save and send
|
||||
fetch("/admin/api/newsletters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newsletterData),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
const newsletterId = data.newsletter_id;
|
||||
return fetch(`/admin/api/newsletters/${newsletterId}/send`, {
|
||||
method: "POST",
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to save newsletter.");
|
||||
}
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
showMessage(
|
||||
data.status === "ok"
|
||||
? `Newsletter sent successfully to ${data.sent_count} subscribers!`
|
||||
: data.message || "Failed to send newsletter.",
|
||||
data.status === "ok" ? "success" : "error"
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error sending newsletter:", error);
|
||||
showMessage("Failed to send newsletter.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears newsletter form with confirmation
|
||||
*/
|
||||
function clearForm() {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to clear the form? All unsaved changes will be lost."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById("newsletterForm");
|
||||
if (form) form.reset();
|
||||
|
||||
const previewSection = document.getElementById("previewSection");
|
||||
if (previewSection) previewSection.classList.add("hidden");
|
||||
|
||||
showMessage("Form cleared.", "info");
|
||||
}
|
||||
|
||||
// ==================== NEWSLETTER SUBSCRIBERS ====================
|
||||
|
||||
let currentPage = 1;
|
||||
let currentFilters = {
|
||||
email: "",
|
||||
sort_by: "subscribed_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies filters to subscriber list
|
||||
*/
|
||||
function applyFilters() {
|
||||
const emailFilter = document.getElementById("emailFilter");
|
||||
const sortBy = document.getElementById("sortBy");
|
||||
const sortOrder = document.getElementById("sortOrder");
|
||||
|
||||
if (emailFilter) currentFilters.email = emailFilter.value.trim();
|
||||
if (sortBy) currentFilters.sort_by = sortBy.value;
|
||||
if (sortOrder) currentFilters.sort_order = sortOrder.value;
|
||||
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all filters
|
||||
*/
|
||||
function clearFilters() {
|
||||
const emailFilter = document.getElementById("emailFilter");
|
||||
const sortBy = document.getElementById("sortBy");
|
||||
const sortOrder = document.getElementById("sortOrder");
|
||||
|
||||
if (emailFilter) emailFilter.value = "";
|
||||
if (sortBy) sortBy.value = "subscribed_at";
|
||||
if (sortOrder) sortOrder.value = "desc";
|
||||
|
||||
currentFilters = { email: "", sort_by: "subscribed_at", sort_order: "desc" };
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads subscribers with current filters and pagination
|
||||
*/
|
||||
function loadSubscribers() {
|
||||
const loading = document.getElementById("loading");
|
||||
const table = document.getElementById("subscribersTable");
|
||||
const pagination = document.getElementById("pagination");
|
||||
|
||||
if (loading) loading.style.display = "block";
|
||||
if (table) table.style.display = "none";
|
||||
if (pagination) pagination.style.display = "none";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: 50,
|
||||
sort_by: currentFilters.sort_by,
|
||||
sort_order: currentFilters.sort_order,
|
||||
});
|
||||
|
||||
if (currentFilters.email) params.append("email", currentFilters.email);
|
||||
|
||||
fetch(`/admin/api/newsletter?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubscribers(data.subscribers);
|
||||
updatePagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading subscribers: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading subscribers", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (loading) loading.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays subscribers in table
|
||||
* @param {Array} subscribers - Array of subscriber objects
|
||||
*/
|
||||
function displaySubscribers(subscribers) {
|
||||
const tbody = document.getElementById("subscribersBody");
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align: center; padding: 40px; color: #666;">No subscribers found</td></tr>';
|
||||
} else {
|
||||
subscribers.forEach((subscriber) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(subscriber.email)}</td>
|
||||
<td>${new Date(subscriber.subscribed_at).toLocaleDateString()}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-danger" onclick="unsubscribe('${escapeHtml(
|
||||
subscriber.email
|
||||
)}')">Unsubscribe</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
const table = document.getElementById("subscribersTable");
|
||||
if (table) table.style.display = "table";
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates pagination controls
|
||||
* @param {Object} pagination - Pagination data
|
||||
*/
|
||||
function updatePagination(pagination) {
|
||||
const pageInfo = document.getElementById("pageInfo");
|
||||
const prevBtn = document.getElementById("prevBtn");
|
||||
const nextBtn = document.getElementById("nextBtn");
|
||||
|
||||
if (pageInfo)
|
||||
pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`;
|
||||
if (prevBtn) prevBtn.disabled = pagination.page <= 1;
|
||||
if (nextBtn) nextBtn.disabled = pagination.page >= pagination.pages;
|
||||
|
||||
const paginationDiv = document.getElementById("pagination");
|
||||
if (paginationDiv) paginationDiv.style.display = "flex";
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes to a different page
|
||||
* @param {number} page - Page number to navigate to
|
||||
*/
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribes a user from newsletter
|
||||
* @param {string} email - Email address to unsubscribe
|
||||
*/
|
||||
function unsubscribe(email) {
|
||||
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) return;
|
||||
|
||||
fetch("/api/newsletter", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Subscriber unsubscribed successfully", "success");
|
||||
loadSubscribers();
|
||||
} else {
|
||||
showMessage(
|
||||
"Error unsubscribing: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error unsubscribing subscriber", "error");
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== CONTACT SUBMISSIONS ====================
|
||||
|
||||
let submissionsCurrentPage = 1;
|
||||
let submissionsSortBy = "created_at";
|
||||
let submissionsSortOrder = "desc";
|
||||
|
||||
/**
|
||||
* Clears contact submission filters
|
||||
*/
|
||||
function clearSubmissionFilters() {
|
||||
const email = document.getElementById("email");
|
||||
const dateFrom = document.getElementById("date_from");
|
||||
const dateTo = document.getElementById("date_to");
|
||||
const perPage = document.getElementById("per_page");
|
||||
|
||||
if (email) email.value = "";
|
||||
if (dateFrom) dateFrom.value = "";
|
||||
if (dateTo) dateTo.value = "";
|
||||
if (perPage) perPage.value = "50";
|
||||
|
||||
submissionsCurrentPage = 1;
|
||||
submissionsSortBy = "created_at";
|
||||
submissionsSortOrder = "desc";
|
||||
loadSubmissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads contact submissions with filters
|
||||
*/
|
||||
function loadSubmissions() {
|
||||
const loading = document.getElementById("loading");
|
||||
const table = document.getElementById("submissionsTable");
|
||||
|
||||
if (loading) loading.style.display = "block";
|
||||
if (table) table.style.opacity = "0.5";
|
||||
|
||||
const perPage = document.getElementById("per_page");
|
||||
const email = document.getElementById("email");
|
||||
const dateFrom = document.getElementById("date_from");
|
||||
const dateTo = document.getElementById("date_to");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: submissionsCurrentPage,
|
||||
per_page: perPage?.value || "50",
|
||||
sort_by: submissionsSortBy,
|
||||
sort_order: submissionsSortOrder,
|
||||
email: email?.value || "",
|
||||
date_from: dateFrom?.value || "",
|
||||
date_to: dateTo?.value || "",
|
||||
});
|
||||
|
||||
fetch(`/api/contact?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubmissions(data.submissions);
|
||||
displaySubmissionPagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading submissions: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading submissions", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (loading) loading.style.display = "none";
|
||||
if (table) table.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays contact submissions in table
|
||||
* @param {Array} submissions - Array of submission objects
|
||||
*/
|
||||
function displaySubmissions(submissions) {
|
||||
const tbody = document.getElementById("submissionsBody");
|
||||
if (!tbody) return;
|
||||
|
||||
if (submissions.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="no-data">No submissions found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = submissions
|
||||
.map(
|
||||
(submission) => `
|
||||
<tr>
|
||||
<td>${submission.id}</td>
|
||||
<td>${escapeHtml(submission.name)}</td>
|
||||
<td>${escapeHtml(submission.email)}</td>
|
||||
<td>${escapeHtml(submission.company || "")}</td>
|
||||
<td class="submission-details">${escapeHtml(submission.message)}</td>
|
||||
<td>${new Date(submission.created_at).toLocaleString()}</td>
|
||||
<td><button class="delete-btn" onclick="deleteSubmission(${
|
||||
submission.id
|
||||
})">Delete</button></td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates submission pagination controls
|
||||
* @param {Object} pagination - Pagination data
|
||||
*/
|
||||
function displaySubmissionPagination(pagination) {
|
||||
const paginationDiv = document.getElementById("pagination");
|
||||
if (!paginationDiv) return;
|
||||
|
||||
if (pagination.pages <= 1) {
|
||||
paginationDiv.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let buttons = [];
|
||||
|
||||
// Previous button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page <= 1 ? "disabled" : ""
|
||||
} onclick="changeSubmissionPage(${pagination.page - 1})">Previous</button>`
|
||||
);
|
||||
|
||||
// Page numbers
|
||||
const startPage = Math.max(1, pagination.page - 2);
|
||||
const endPage = Math.min(pagination.pages, pagination.page + 2);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons.push(
|
||||
`<button class="${
|
||||
i === pagination.page ? "active" : ""
|
||||
}" onclick="changeSubmissionPage(${i})">${i}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page >= pagination.pages ? "disabled" : ""
|
||||
} onclick="changeSubmissionPage(${pagination.page + 1})">Next</button>`
|
||||
);
|
||||
|
||||
paginationDiv.innerHTML = buttons.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes to a different submission page
|
||||
* @param {number} page - Page number to navigate to
|
||||
*/
|
||||
function changeSubmissionPage(page) {
|
||||
submissionsCurrentPage = page;
|
||||
loadSubmissions();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a contact submission
|
||||
* @param {number} id - Submission ID to delete
|
||||
*/
|
||||
function deleteSubmission(id) {
|
||||
if (!confirm("Are you sure you want to delete this submission?")) return;
|
||||
|
||||
fetch(`/api/contact/${id}`, { method: "DELETE" })
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Submission deleted successfully", "success");
|
||||
loadSubmissions();
|
||||
} else {
|
||||
showMessage(
|
||||
"Error deleting submission: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error deleting submission", "error");
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== INITIALIZATION ====================
|
||||
|
||||
// Global admin object for external access
|
||||
window.admin = {
|
||||
showMessage,
|
||||
escapeHtml,
|
||||
fetchEmbedSettings,
|
||||
saveEmbedSetting,
|
||||
copyIframeCode,
|
||||
copyNewsletterIframeCode,
|
||||
loadEmbedSettingsAndInit,
|
||||
loadDashboardStats,
|
||||
loadEmailTemplate,
|
||||
saveEmailTemplate,
|
||||
loadNewsletterStats,
|
||||
previewNewsletter,
|
||||
saveDraft,
|
||||
sendNewsletter,
|
||||
clearForm,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
loadSubscribers,
|
||||
displaySubscribers,
|
||||
updatePagination,
|
||||
changePage,
|
||||
unsubscribe,
|
||||
clearSubmissionFilters,
|
||||
loadSubmissions,
|
||||
displaySubmissions,
|
||||
displaySubmissionPagination,
|
||||
changeSubmissionPage,
|
||||
deleteSubmission,
|
||||
};
|
||||
|
||||
// Auto-initialize based on page content
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Embed page
|
||||
if (
|
||||
document.getElementById("iframeCode") ||
|
||||
document.getElementById("iframeNewsletterCode")
|
||||
) {
|
||||
loadEmbedSettingsAndInit();
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
if (document.getElementById("contact-count")) {
|
||||
loadDashboardStats();
|
||||
}
|
||||
|
||||
// Email templates
|
||||
if (document.getElementById("newsletterTemplate")) {
|
||||
loadEmailTemplate();
|
||||
const form = document.getElementById("templateForm");
|
||||
if (form) {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
saveEmailTemplate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Newsletter creation
|
||||
if (document.getElementById("newsletterForm")) {
|
||||
loadNewsletterStats();
|
||||
}
|
||||
|
||||
// Newsletter subscribers
|
||||
if (document.getElementById("subscribersTable")) {
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
// Contact submissions
|
||||
if (document.getElementById("submissionsTable")) {
|
||||
loadSubmissions();
|
||||
|
||||
const filterForm = document.getElementById("filterForm");
|
||||
if (filterForm) {
|
||||
filterForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
submissionsCurrentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
}
|
||||
|
||||
// Table sorting
|
||||
document.querySelectorAll("th[data-sort]").forEach((header) => {
|
||||
header.addEventListener("click", function () {
|
||||
const sortBy = this.dataset.sort;
|
||||
if (submissionsSortBy === sortBy) {
|
||||
submissionsSortOrder =
|
||||
submissionsSortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
submissionsSortBy = sortBy;
|
||||
submissionsSortOrder = "asc";
|
||||
}
|
||||
submissionsCurrentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Settings
|
||||
if (document.getElementById("settingsList")) {
|
||||
loadSettingsForList();
|
||||
}
|
||||
});
|
||||
15
templates/_base.html
Normal file
15
templates/_base.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Newsletter Subscribers{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
<h1>{% block heading %}Newsletter Subscribers{% endblock %}</h1>
|
||||
{% block content %}{% endblock %}
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
53
templates/_nav.html
Normal file
53
templates/_nav.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<nav class="nav">
|
||||
<!--
|
||||
admin_dashboard.html
|
||||
admin_newsletter.html
|
||||
admin_newsletter_create.html
|
||||
admin_settings.html
|
||||
admin_submissions.html
|
||||
admin_embeds.html
|
||||
|
||||
newsletter_manage.html
|
||||
unsubscribe_confirmation.html
|
||||
-->
|
||||
<a
|
||||
href="/admin/"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/submissions"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Contact Submissions</a
|
||||
>
|
||||
<a
|
||||
href="/admin/newsletter"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Subscribers</a
|
||||
>
|
||||
<a
|
||||
href="/admin/newsletter/create"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Create Newsletter</a
|
||||
>
|
||||
<a
|
||||
href="/admin/embeds"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Embeds</a
|
||||
>
|
||||
<a
|
||||
href="/admin/email-templates"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Email Templates</a
|
||||
>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Settings</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
style="color: #007bff; text-decoration: none"
|
||||
>Logout</a
|
||||
>
|
||||
</nav>
|
||||
68
templates/admin/admin_dashboard.html
Normal file
68
templates/admin/admin_dashboard.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "_base.html" %} {% block title %}Admin Dashboard{% endblock %} {%
|
||||
block heading %}Admin Dashboard{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div id="message"></div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3 id="contact-count">--</h3>
|
||||
<p>Contact Submissions</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="newsletter-count">--</h3>
|
||||
<p>Newsletter Subscribers</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="settings-count">--</h3>
|
||||
<p>App Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<h2>Contact Form Submissions</h2>
|
||||
<p>View and manage contact form submissions from your website visitors.</p>
|
||||
<a href="/admin/submissions">Manage Submissions</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Newsletter Subscribers</h2>
|
||||
<p>
|
||||
Manage newsletter subscriptions and send newsletters to your subscribers.
|
||||
</p>
|
||||
<a href="/admin/newsletter">Manage Subscribers</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Application Settings</h2>
|
||||
<p>Configure application settings and environment variables.</p>
|
||||
<a href="/admin/settings">Manage Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Create Newsletter</h2>
|
||||
<p>Create and send newsletters to your subscribers.</p>
|
||||
<a href="/admin/newsletter/create">Create Newsletter</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Embeddable Forms</h2>
|
||||
<p>
|
||||
Instructions for embedding contact and newsletter forms on other websites.
|
||||
</p>
|
||||
<a href="/admin/embeds">View Embed Codes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logout">
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
{% endblock %}{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script>
|
||||
// Load stats when page loads
|
||||
if (typeof window.admin.loadDashboardStats === "function") {
|
||||
window.admin.loadDashboardStats();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
21
templates/admin/admin_email_templates.html
Normal file
21
templates/admin/admin_email_templates.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "_base.html" %} {% block title %}Email Templates{% endblock %} {%
|
||||
block heading %}Email Templates{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div class="settings-management">
|
||||
<h2>Newsletter Confirmation Template</h2>
|
||||
<p>Edit the HTML template used for the newsletter confirmation email.</p>
|
||||
<div id="message"></div>
|
||||
<form id="templateForm">
|
||||
<div class="form-group">
|
||||
<label for="newsletterTemplate">Template HTML</label>
|
||||
<textarea id="newsletterTemplate" rows="15" cols="80"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" type="submit">Save Template</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
93
templates/admin/admin_embeds.html
Normal file
93
templates/admin/admin_embeds.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "_base.html" %} {% block title %}Embeddable Forms{% endblock %} {%
|
||||
block heading %}Embeddable Forms{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div class="settings-management">
|
||||
<div id="contactForm" style="display: flex; justify-content: space-between">
|
||||
<div id="contactFormSettings" style="min-height: 300px">
|
||||
<h2>Contact Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the contact form on other websites:
|
||||
</p>
|
||||
<div class="embed-config">
|
||||
<label
|
||||
>Width: <input id="contactWidth" type="text" value="400"
|
||||
/></label>
|
||||
<label
|
||||
>Height: <input id="contactHeight" type="text" value="600"
|
||||
/></label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_contact_width','contactWidth')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_contact_height','contactHeight')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="iframeCode" class="iframe-code" readonly></textarea>
|
||||
<button class="btn btn-secondary" onclick="copyIframeCode()">
|
||||
Copy Contact Iframe
|
||||
</button>
|
||||
</div>
|
||||
<div id="contactFormPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<div
|
||||
id="newsletterForm"
|
||||
style="display: flex; justify-content: space-between"
|
||||
>
|
||||
<div id="newsletterFormSettings" style="min-height: 400px">
|
||||
<h2>Newsletter Subscription Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the newsletter subscription form on
|
||||
other websites:
|
||||
</p>
|
||||
<div class="embed-config">
|
||||
<label
|
||||
>Width: <input id="newsletterWidth" type="text" value="600"
|
||||
/></label>
|
||||
<label
|
||||
>Height: <input id="newsletterHeight" type="text" value="300"
|
||||
/></label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_newsletter_width','newsletterWidth')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_newsletter_height','newsletterHeight')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="iframeNewsletterCode"
|
||||
class="iframe-code"
|
||||
readonly
|
||||
></textarea>
|
||||
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
|
||||
Copy Newsletter Iframe
|
||||
</button>
|
||||
</div>
|
||||
<div id="newsletterFormPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-management">
|
||||
<p>
|
||||
Replace <code>http://your-server-domain</code> with your actual server
|
||||
domain and port (e.g., https://yourdomain.com).
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
</div>
|
||||
42
templates/admin/admin_newsletter.html
Normal file
42
templates/admin/admin_newsletter.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "_base.html" %} {% block title %}Newsletter Subscribers{% endblock %}
|
||||
{% block heading %}Newsletter Subscribers{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<input type="text" id="emailFilter" placeholder="Filter by email..." />
|
||||
<select id="sortBy">
|
||||
<option value="subscribed_at">Sort by Date</option>
|
||||
<option value="email">Sort by Email</option>
|
||||
</select>
|
||||
<select id="sortOrder">
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<button onclick="applyFilters()">Apply Filters</button>
|
||||
<button onclick="clearFilters()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Loading subscribers...</div>
|
||||
<table id="subscribersTable" class="subscribers-table" style="display: none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Subscribed Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscribersBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pagination" class="pagination" style="display: none">
|
||||
<button id="prevBtn" onclick="changePage(currentPage - 1)">Previous</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
98
templates/admin/admin_newsletter_create.html
Normal file
98
templates/admin/admin_newsletter_create.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "_base.html" %} {% block title %}Create Newsletter{% endblock %} {%
|
||||
block heading %}Create Newsletter{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="newsletter-stats">
|
||||
<div class="stat-card">
|
||||
<h4>Total Subscribers</h4>
|
||||
<div class="number" id="totalSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Active Subscribers</h4>
|
||||
<div class="number" id="activeSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Last Sent</h4>
|
||||
<div class="number" id="lastSent">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="newsletterForm">
|
||||
<div class="form-section">
|
||||
<h2>Newsletter Details</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject Line *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
placeholder="Enter newsletter subject"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="senderName">Sender Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="senderName"
|
||||
name="sender_name"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
required
|
||||
placeholder="Write your newsletter content here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sendDate">Send Date (optional)</label>
|
||||
<input type="datetime-local" id="sendDate" name="send_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sent">Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Actions</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="previewNewsletter()"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDraft()">
|
||||
Save Draft
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" onclick="sendNewsletter()">
|
||||
Send Newsletter
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="previewSection" class="newsletter-preview hidden">
|
||||
<h3>Newsletter Preview</h3>
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
{% endblock %}{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
59
templates/admin/admin_settings.html
Normal file
59
templates/admin/admin_settings.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "_base.html" %} {% block title %}Settings{% endblock %} {% block
|
||||
heading %}Application Settings{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %} {% block content %}
|
||||
<div class="settings-cards">
|
||||
{% for category, category_settings in settings.items() %}
|
||||
<div class="settings-card">
|
||||
<h2 class="card-title">{{ category }}</h2>
|
||||
<div class="card-body">
|
||||
{% for key, value in category_settings.items() %}
|
||||
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Dynamic Settings Management</h2>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="settings-list" id="settingsList">
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<h3>Add New Setting</h3>
|
||||
<form id="addSettingForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="newKey">Setting Key:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newKey"
|
||||
name="key"
|
||||
required
|
||||
placeholder="e.g., maintenance_mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newValue">Setting Value:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newValue"
|
||||
name="value"
|
||||
required
|
||||
placeholder="e.g., false"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add Setting</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
80
templates/admin/admin_submissions.html
Normal file
80
templates/admin/admin_submissions.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends "_base.html" %} {% block title %}Contact Submissions{% endblock %} {%
|
||||
block heading %}Contact Form Submissions{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
<style>
|
||||
.filters {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block content %}
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<form id="filterForm">
|
||||
<div>
|
||||
<label for="email">Email Filter:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Filter by email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from">Date From:</label>
|
||||
<input type="date" id="date_from" name="date_from" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to">Date To:</label>
|
||||
<input type="date" id="date_to" name="date_to" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="per_page">Items per page:</label>
|
||||
<select id="per_page" name="per_page">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit">Apply Filters</button>
|
||||
<button type="button" class="clear-btn" onclick="clearFilters()">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none">Loading...</div>
|
||||
|
||||
<table id="submissionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="id">ID</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="email">Email</th>
|
||||
<th data-sort="company">Company</th>
|
||||
<th>Message</th>
|
||||
<th data-sort="created_at">Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissionsBody">
|
||||
<tr>
|
||||
<td colspan="7" class="no-data">Loading submissions...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,119 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3 id="contact-count">--</h3>
|
||||
<p>Contact Submissions</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="newsletter-count">--</h3>
|
||||
<p>Newsletter Subscribers</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="settings-count">--</h3>
|
||||
<p>App Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<h2>Contact Form Submissions</h2>
|
||||
<p>
|
||||
View and manage contact form submissions from your website visitors.
|
||||
</p>
|
||||
<a href="/admin/submissions">Manage Submissions</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Newsletter Subscribers</h2>
|
||||
<p>
|
||||
Manage newsletter subscriptions and send newsletters to your
|
||||
subscribers.
|
||||
</p>
|
||||
<a href="/admin/newsletter">Manage Subscribers</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Application Settings</h2>
|
||||
<p>Configure application settings and environment variables.</p>
|
||||
<a href="/admin/settings">Manage Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Create Newsletter</h2>
|
||||
<p>Create and send newsletters to your subscribers.</p>
|
||||
<a href="/admin/newsletter/create">Create Newsletter</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Embeddable Forms</h2>
|
||||
<p>
|
||||
Instructions for embedding contact and newsletter forms on other
|
||||
websites.
|
||||
</p>
|
||||
<a href="/admin/embeds">View Embed Codes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logout">
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load dashboard statistics
|
||||
async function loadStats() {
|
||||
try {
|
||||
// Load contact submissions count
|
||||
const contactResponse = await fetch(
|
||||
"/admin/api/contact?page=1&per_page=1"
|
||||
);
|
||||
if (contactResponse.ok) {
|
||||
const contactData = await contactResponse.json();
|
||||
document.getElementById("contact-count").textContent =
|
||||
contactData.pagination.total;
|
||||
}
|
||||
|
||||
// Load newsletter subscribers count
|
||||
const newsletterResponse = await fetch(
|
||||
"/admin/api/newsletter?page=1&per_page=1"
|
||||
);
|
||||
if (newsletterResponse.ok) {
|
||||
const newsletterData = await newsletterResponse.json();
|
||||
document.getElementById("newsletter-count").textContent =
|
||||
newsletterData.pagination.total;
|
||||
}
|
||||
|
||||
// Load settings count
|
||||
const settingsResponse = await fetch("/admin/api/settings");
|
||||
if (settingsResponse.ok) {
|
||||
const settingsData = await settingsResponse.json();
|
||||
document.getElementById("settings-count").textContent = Object.keys(
|
||||
settingsData.settings
|
||||
).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load stats when page loads
|
||||
loadStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,112 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Embeddable Forms</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
.iframe-code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logout">
|
||||
<a
|
||||
href="/admin/"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/submissions"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>View Submissions</a
|
||||
>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Settings</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
style="color: #007bff; text-decoration: none"
|
||||
>Logout</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1>Embeddable Forms</h1>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Contact Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the contact form on other websites:
|
||||
</p>
|
||||
<textarea id="iframeCode" class="iframe-code" readonly>
|
||||
<iframe src="http://your-server-domain/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
|
||||
</textarea>
|
||||
<button class="btn btn-secondary" onclick="copyIframeCode()">
|
||||
Copy Contact Iframe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Newsletter Subscription Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the newsletter subscription form on
|
||||
other websites:
|
||||
</p>
|
||||
<textarea id="iframeNewsletterCode" class="iframe-code" readonly>
|
||||
<iframe src="http://your-server-domain/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
|
||||
</textarea>
|
||||
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
|
||||
Copy Newsletter Iframe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<p>
|
||||
Replace <code>http://your-server-domain</code> with your actual server
|
||||
domain and port (e.g., https://yourdomain.com).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyIframeCode() {
|
||||
const textarea = document.getElementById("iframeCode");
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
showMessage("Contact iframe code copied to clipboard!", "success");
|
||||
}
|
||||
|
||||
function copyNewsletterIframeCode() {
|
||||
const textarea = document.getElementById("iframeNewsletterCode");
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
showMessage("Newsletter iframe code copied to clipboard!", "success");
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
// Create message div if it doesn't exist
|
||||
let messageDiv = document.getElementById("message");
|
||||
if (!messageDiv) {
|
||||
messageDiv = document.createElement("div");
|
||||
messageDiv.id = "message";
|
||||
document.body.insertBefore(messageDiv, document.body.firstChild);
|
||||
}
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,362 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Newsletter Subscribers</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.subscribers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.subscribers-table th,
|
||||
.subscribers-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.subscribers-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
.subscribers-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pagination button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pagination .current-page {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/submissions">Contact Submissions</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Newsletter Subscribers</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<input type="text" id="emailFilter" placeholder="Filter by email..." />
|
||||
<select id="sortBy">
|
||||
<option value="subscribed_at">Sort by Date</option>
|
||||
<option value="email">Sort by Email</option>
|
||||
</select>
|
||||
<select id="sortOrder">
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<button onclick="applyFilters()">Apply Filters</button>
|
||||
<button onclick="clearFilters()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Loading subscribers...</div>
|
||||
<table
|
||||
id="subscribersTable"
|
||||
class="subscribers-table"
|
||||
style="display: none"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Subscribed Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscribersBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pagination" class="pagination" style="display: none">
|
||||
<button id="prevBtn" onclick="changePage(currentPage - 1)">
|
||||
Previous
|
||||
</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let currentFilters = {
|
||||
email: "",
|
||||
sort_by: "subscribed_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
|
||||
// Load subscribers on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSubscribers();
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
currentFilters.email = document
|
||||
.getElementById("emailFilter")
|
||||
.value.trim();
|
||||
currentFilters.sort_by = document.getElementById("sortBy").value;
|
||||
currentFilters.sort_order = document.getElementById("sortOrder").value;
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById("emailFilter").value = "";
|
||||
document.getElementById("sortBy").value = "subscribed_at";
|
||||
document.getElementById("sortOrder").value = "desc";
|
||||
currentFilters = {
|
||||
email: "",
|
||||
sort_by: "subscribed_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function loadSubscribers() {
|
||||
document.getElementById("loading").style.display = "block";
|
||||
document.getElementById("subscribersTable").style.display = "none";
|
||||
document.getElementById("pagination").style.display = "none";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: 50,
|
||||
sort_by: currentFilters.sort_by,
|
||||
sort_order: currentFilters.sort_order,
|
||||
});
|
||||
|
||||
if (currentFilters.email) {
|
||||
params.append("email", currentFilters.email);
|
||||
}
|
||||
|
||||
fetch(`/admin/api/newsletter?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubscribers(data.subscribers);
|
||||
updatePagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading subscribers: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading subscribers", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubscribers(subscribers) {
|
||||
const tbody = document.getElementById("subscribersBody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align: center; padding: 40px; color: #666;">No subscribers found</td></tr>';
|
||||
} else {
|
||||
subscribers.forEach((subscriber) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(subscriber.email)}</td>
|
||||
<td>${new Date(
|
||||
subscriber.subscribed_at
|
||||
).toLocaleDateString()}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-danger" onclick="unsubscribe('${escapeHtml(
|
||||
subscriber.email
|
||||
)}')">Unsubscribe</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("subscribersTable").style.display = "table";
|
||||
}
|
||||
|
||||
function updatePagination(pagination) {
|
||||
const pageInfo = document.getElementById("pageInfo");
|
||||
const prevBtn = document.getElementById("prevBtn");
|
||||
const nextBtn = document.getElementById("nextBtn");
|
||||
|
||||
pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`;
|
||||
|
||||
prevBtn.disabled = pagination.page <= 1;
|
||||
nextBtn.disabled = pagination.page >= pagination.pages;
|
||||
|
||||
document.getElementById("pagination").style.display = "flex";
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function unsubscribe(email) {
|
||||
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/newsletter", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: email }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Subscriber unsubscribed successfully", "success");
|
||||
loadSubscribers(); // Reload the list
|
||||
} else {
|
||||
showMessage(
|
||||
"Error unsubscribing: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error unsubscribing subscriber", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,458 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Newsletter</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.newsletter-preview {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.newsletter-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
}
|
||||
.newsletter-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/newsletter">Subscribers</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Create Newsletter</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="newsletter-stats">
|
||||
<div class="stat-card">
|
||||
<h4>Total Subscribers</h4>
|
||||
<div class="number" id="totalSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Active Subscribers</h4>
|
||||
<div class="number" id="activeSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Last Sent</h4>
|
||||
<div class="number" id="lastSent">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="newsletterForm">
|
||||
<div class="form-section">
|
||||
<h2>Newsletter Details</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject Line *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
placeholder="Enter newsletter subject"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="senderName">Sender Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="senderName"
|
||||
name="sender_name"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
required
|
||||
placeholder="Write your newsletter content here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sendDate">Send Date (optional)</label>
|
||||
<input type="datetime-local" id="sendDate" name="send_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sent">Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Actions</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="previewNewsletter()"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDraft()">
|
||||
Save Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick="sendNewsletter()"
|
||||
>
|
||||
Send Newsletter
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="previewSection" class="newsletter-preview hidden">
|
||||
<h3>Newsletter Preview</h3>
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let newsletterStats = {};
|
||||
|
||||
// Load newsletter stats on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadNewsletterStats();
|
||||
});
|
||||
|
||||
function loadNewsletterStats() {
|
||||
// Load subscriber count
|
||||
fetch("/admin/api/newsletter?page=1&per_page=1")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
document.getElementById("totalSubscribers").textContent =
|
||||
data.pagination.total;
|
||||
document.getElementById("activeSubscribers").textContent =
|
||||
data.pagination.total; // For now, assume all are active
|
||||
newsletterStats.totalSubscribers = data.pagination.total;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading subscriber stats:", error);
|
||||
});
|
||||
|
||||
// For now, set last sent as N/A
|
||||
document.getElementById("lastSent").textContent = "N/A";
|
||||
}
|
||||
|
||||
function previewNewsletter() {
|
||||
const subject = document.getElementById("subject").value.trim();
|
||||
const content = document.getElementById("content").value.trim();
|
||||
const senderName = document.getElementById("senderName").value.trim();
|
||||
|
||||
if (!subject || !content) {
|
||||
showMessage("Subject and content are required for preview.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
previewContent.innerHTML = `
|
||||
<h2>${escapeHtml(subject)}</h2>
|
||||
${
|
||||
senderName
|
||||
? `<p><strong>From:</strong> ${escapeHtml(senderName)}</p>`
|
||||
: ""
|
||||
}
|
||||
<div style="margin-top: 20px; line-height: 1.6;">
|
||||
${content.replace(/\n/g, "<br>")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("previewSection").classList.remove("hidden");
|
||||
showMessage("Newsletter preview generated.", "info");
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
const formData = new FormData(document.getElementById('newsletterForm'));
|
||||
const newsletterData = {
|
||||
subject: formData.get('subject'),
|
||||
content: formData.get('content'),
|
||||
sender_name: formData.get('sender_name'),
|
||||
send_date: formData.get('send_date'),
|
||||
status: 'draft'
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
showMessage('Subject and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/admin/api/newsletters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newsletterData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
showMessage('Newsletter draft saved successfully!', 'success');
|
||||
} else {
|
||||
showMessage(data.message || 'Failed to save draft.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving draft:', error);
|
||||
showMessage('Failed to save draft.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function sendNewsletter() {
|
||||
const formData = new FormData(document.getElementById('newsletterForm'));
|
||||
const newsletterData = {
|
||||
subject: formData.get('subject'),
|
||||
content: formData.get('content'),
|
||||
sender_name: formData.get('sender_name'),
|
||||
send_date: formData.get('send_date'),
|
||||
status: 'sent'
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
showMessage('Subject and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to send this newsletter to ${newsletterStats.totalSubscribers || 0} subscribers?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First save the newsletter
|
||||
fetch('/admin/api/newsletters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newsletterData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
const newsletterId = data.newsletter_id;
|
||||
// Now send it
|
||||
return fetch(`/admin/api/newsletters/${newsletterId}/send`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to save newsletter.');
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
showMessage(`Newsletter sent successfully to ${data.sent_count} subscribers!`, 'success');
|
||||
} else {
|
||||
showMessage(data.message || 'Failed to send newsletter.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sending newsletter:', error);
|
||||
showMessage('Failed to send newsletter.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear the form? All unsaved changes will be lost."
|
||||
)
|
||||
) {
|
||||
document.getElementById("newsletterForm").reset();
|
||||
document.getElementById("previewSection").classList.add("hidden");
|
||||
showMessage("Form cleared.", "info");
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,337 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Settings</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
.setting strong {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
.iframe-code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logout">
|
||||
<a
|
||||
href="/admin/"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/submissions"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>View Submissions</a
|
||||
>
|
||||
<a
|
||||
href="/admin/embeds"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Embeds</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
style="color: #007bff; text-decoration: none"
|
||||
>Logout</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1>Application Settings</h1>
|
||||
|
||||
{% for category, category_settings in settings.items() %}
|
||||
<div class="setting-group">
|
||||
<h2>{{ category }}</h2>
|
||||
{% for key, value in category_settings.items() %}
|
||||
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Dynamic Settings Management</h2>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="settings-list" id="settingsList">
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<h3>Add New Setting</h3>
|
||||
<form id="addSettingForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="newKey">Setting Key:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newKey"
|
||||
name="key"
|
||||
required
|
||||
placeholder="e.g., maintenance_mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newValue">Setting Value:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newValue"
|
||||
name="value"
|
||||
required
|
||||
placeholder="e.g., false"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add Setting</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Embeddable Forms</h2>
|
||||
<p>
|
||||
Manage embeddable forms on the <a href="/admin/embeds">Embeds page</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Email Templates</h2>
|
||||
<div id="emailTemplates">
|
||||
<p>Loading email templates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let appSettings = {};
|
||||
|
||||
// Load settings on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
// Handle add setting form
|
||||
document
|
||||
.getElementById("addSettingForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
const key = formData.get("key").trim();
|
||||
const value = formData.get("value").trim();
|
||||
|
||||
if (!key || !value) {
|
||||
showMessage("Both key and value are required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
addSetting(key, value);
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
fetch("/admin/api/settings")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings = data.settings;
|
||||
displaySettings();
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading settings: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading settings", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function displaySettings() {
|
||||
const container = document.getElementById("settingsList");
|
||||
|
||||
if (Object.keys(appSettings).length === 0) {
|
||||
container.innerHTML = "<p>No dynamic settings configured.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsHtml = Object.entries(appSettings)
|
||||
.map(([key, value]) => {
|
||||
const isTemplate = key === "newsletter_confirmation_template";
|
||||
const inputType = isTemplate ? "textarea" : "input";
|
||||
const inputAttrs = isTemplate
|
||||
? 'rows="10" cols="50"'
|
||||
: 'type="text"';
|
||||
return `
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<strong>${escapeHtml(key)}:</strong> ${
|
||||
isTemplate
|
||||
? "<em>HTML template</em>"
|
||||
: escapeHtml(value.substring(0, 50)) +
|
||||
(value.length > 50 ? "..." : "")
|
||||
}
|
||||
</div>
|
||||
<div class="setting-actions">
|
||||
<button class="btn btn-secondary" onclick="editSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Edit</button>
|
||||
<button class="btn btn-danger" onclick="deleteSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-form" id="edit-${escapeHtml(key)}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Key:</label>
|
||||
<input type="text" value="${escapeHtml(key)}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Value:</label>
|
||||
<${inputType} id="edit-value-${escapeHtml(
|
||||
key
|
||||
)}" ${inputAttrs} required>${escapeHtml(value)}</${inputType}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" onclick="updateSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Update</button>
|
||||
<button class="btn btn-secondary" onclick="cancelEdit('${escapeHtml(
|
||||
key
|
||||
)}')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
container.innerHTML = settingsHtml;
|
||||
}
|
||||
|
||||
function addSetting(key, value) {
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ value: value }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings[key] = value;
|
||||
displaySettings();
|
||||
document.getElementById("addSettingForm").reset();
|
||||
showMessage("Setting added successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error adding setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error adding setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function editSetting(key) {
|
||||
document.getElementById(`edit-${key}`).style.display = "block";
|
||||
}
|
||||
|
||||
function cancelEdit(key) {
|
||||
document.getElementById(`edit-${key}`).style.display = "none";
|
||||
}
|
||||
|
||||
function updateSetting(key) {
|
||||
const newValue = document
|
||||
.getElementById(`edit-value-${key}`)
|
||||
.value.trim();
|
||||
|
||||
if (!newValue) {
|
||||
showMessage("Value cannot be empty.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ value: newValue }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings[key] = newValue;
|
||||
displaySettings();
|
||||
cancelEdit(key);
|
||||
showMessage("Setting updated successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error updating setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error updating setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSetting(key) {
|
||||
if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
delete appSettings[key];
|
||||
displaySettings();
|
||||
showMessage("Setting deleted successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error deleting setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error deleting setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,303 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Contact Submissions</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.filters {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Contact Form Submissions</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<form id="filterForm">
|
||||
<div>
|
||||
<label for="email">Email Filter:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Filter by email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from">Date From:</label>
|
||||
<input type="date" id="date_from" name="date_from" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to">Date To:</label>
|
||||
<input type="date" id="date_to" name="date_to" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="per_page">Items per page:</label>
|
||||
<select id="per_page" name="per_page">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit">Apply Filters</button>
|
||||
<button type="button" class="clear-btn" onclick="clearFilters()">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none">Loading...</div>
|
||||
|
||||
<table id="submissionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="id">ID</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="email">Email</th>
|
||||
<th data-sort="company">Company</th>
|
||||
<th>Message</th>
|
||||
<th data-sort="created_at">Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissionsBody">
|
||||
<tr>
|
||||
<td colspan="7" class="no-data">Loading submissions...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let currentSortBy = "created_at";
|
||||
let currentSortOrder = "desc";
|
||||
|
||||
// Load submissions on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
// Handle filter form submission
|
||||
document
|
||||
.getElementById("filterForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
currentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
// Handle table header sorting
|
||||
document.querySelectorAll("th[data-sort]").forEach((header) => {
|
||||
header.addEventListener("click", function () {
|
||||
const sortBy = this.dataset.sort;
|
||||
if (currentSortBy === sortBy) {
|
||||
currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
currentSortBy = sortBy;
|
||||
currentSortOrder = "asc";
|
||||
}
|
||||
currentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById("email").value = "";
|
||||
document.getElementById("date_from").value = "";
|
||||
document.getElementById("date_to").value = "";
|
||||
document.getElementById("per_page").value = "50";
|
||||
currentPage = 1;
|
||||
currentSortBy = "created_at";
|
||||
currentSortOrder = "desc";
|
||||
loadSubmissions();
|
||||
}
|
||||
|
||||
function loadSubmissions() {
|
||||
const loading = document.getElementById("loading");
|
||||
const table = document.getElementById("submissionsTable");
|
||||
|
||||
loading.style.display = "block";
|
||||
table.style.opacity = "0.5";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: document.getElementById("per_page").value,
|
||||
sort_by: currentSortBy,
|
||||
sort_order: currentSortOrder,
|
||||
email: document.getElementById("email").value,
|
||||
date_from: document.getElementById("date_from").value,
|
||||
date_to: document.getElementById("date_to").value,
|
||||
});
|
||||
|
||||
fetch(`/api/contact?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubmissions(data.submissions);
|
||||
displayPagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading submissions: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading submissions", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
loading.style.display = "none";
|
||||
table.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubmissions(submissions) {
|
||||
const tbody = document.getElementById("submissionsBody");
|
||||
|
||||
if (submissions.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="no-data">No submissions found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = submissions
|
||||
.map(
|
||||
(submission) => `
|
||||
<tr>
|
||||
<td>${submission.id}</td>
|
||||
<td>${escapeHtml(submission.name)}</td>
|
||||
<td>${escapeHtml(submission.email)}</td>
|
||||
<td>${escapeHtml(submission.company || "")}</td>
|
||||
<td class="submission-details">${escapeHtml(
|
||||
submission.message
|
||||
)}</td>
|
||||
<td>${new Date(submission.created_at).toLocaleString()}</td>
|
||||
<td><button class="delete-btn" onclick="deleteSubmission(${
|
||||
submission.id
|
||||
})">Delete</button></td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function displayPagination(pagination) {
|
||||
const paginationDiv = document.getElementById("pagination");
|
||||
|
||||
if (pagination.pages <= 1) {
|
||||
paginationDiv.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let buttons = [];
|
||||
|
||||
// Previous button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page <= 1 ? "disabled" : ""
|
||||
} onclick="changePage(${pagination.page - 1})">Previous</button>`
|
||||
);
|
||||
|
||||
// Page numbers
|
||||
const startPage = Math.max(1, pagination.page - 2);
|
||||
const endPage = Math.min(pagination.pages, pagination.page + 2);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons.push(
|
||||
`<button class="${
|
||||
i === pagination.page ? "active" : ""
|
||||
}" onclick="changePage(${i})">${i}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page >= pagination.pages ? "disabled" : ""
|
||||
} onclick="changePage(${pagination.page + 1})">Next</button>`
|
||||
);
|
||||
|
||||
paginationDiv.innerHTML = buttons.join("");
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadSubmissions();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function deleteSubmission(id) {
|
||||
if (!confirm("Are you sure you want to delete this submission?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/contact/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Submission deleted successfully", "success");
|
||||
loadSubmissions(); // Reload the current page
|
||||
} else {
|
||||
showMessage(
|
||||
"Error deleting submission: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error deleting submission", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,7 +32,7 @@
|
||||
If you unsubscribed by mistake or have any questions, please feel free
|
||||
to contact us.
|
||||
</p>
|
||||
<a href="/" class="back-link">Return to Homepage</a>
|
||||
<a href="/newsletter/manage" class="back-link">Return to Newsletter</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
tests/test_email_templates_api.py
Normal file
43
tests/test_email_templates_api.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
server_app_module = importlib.import_module("server.app")
|
||||
app = server_app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_get_settings_returns_dict(client):
|
||||
# Login as admin first
|
||||
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
|
||||
resp = client.get('/admin/api/settings')
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body['status'] == 'ok'
|
||||
assert isinstance(body.get('settings'), dict)
|
||||
|
||||
|
||||
def test_update_and_get_newsletter_template(client):
|
||||
key = 'newsletter_confirmation_template'
|
||||
sample = '<p>Thanks for subscribing, {{email}}</p>'
|
||||
|
||||
# Update via PUT
|
||||
# Login as admin first
|
||||
client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
|
||||
|
||||
resp = client.put(f'/admin/api/settings/{key}', json={'value': sample})
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body['status'] == 'ok'
|
||||
|
||||
# Retrieve via GET and ensure the value is present
|
||||
resp = client.get('/admin/api/settings')
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body['status'] == 'ok'
|
||||
settings = body.get('settings') or {}
|
||||
assert settings.get(key) == sample
|
||||
Reference in New Issue
Block a user