Initial dev export (exclude uploads/runtime)
This commit is contained in:
944
plugins/support/SupportController.php
Normal file
944
plugins/support/SupportController.php
Normal file
@@ -0,0 +1,944 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Support;
|
||||
|
||||
use Core\Http\Response;
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Database;
|
||||
use Core\Services\Mailer;
|
||||
use Core\Services\Settings;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class SupportController
|
||||
{
|
||||
private View $view;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->view = new View(__DIR__ . '/views');
|
||||
}
|
||||
|
||||
public function contactForm(): Response
|
||||
{
|
||||
$supportTypes = $this->supportTypes();
|
||||
return new Response($this->view->render('site/contact.php', [
|
||||
'title' => 'Contact',
|
||||
'error' => (string)($_GET['error'] ?? ''),
|
||||
'ok' => (string)($_GET['ok'] ?? ''),
|
||||
'support_types' => $supportTypes,
|
||||
]));
|
||||
}
|
||||
|
||||
public function contactSubmit(): Response
|
||||
{
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
$subject = trim((string)($_POST['subject'] ?? ''));
|
||||
$message = trim((string)($_POST['message'] ?? ''));
|
||||
$supportType = trim((string)($_POST['support_type'] ?? 'general'));
|
||||
$supportTypes = $this->supportTypes();
|
||||
$supportTypesMap = [];
|
||||
foreach ($supportTypes as $type) {
|
||||
$supportTypesMap[(string)$type['key']] = $type;
|
||||
}
|
||||
if (!isset($supportTypesMap[$supportType])) {
|
||||
$supportType = (string)($supportTypes[0]['key'] ?? 'general');
|
||||
}
|
||||
$typeMeta = $supportTypesMap[$supportType] ?? ['label' => 'General', 'fields' => []];
|
||||
$requiredFields = is_array($typeMeta['fields'] ?? null) ? $typeMeta['fields'] : [];
|
||||
$extraValuesRaw = $_POST['support_extra'] ?? [];
|
||||
$extraValues = [];
|
||||
if (is_array($extraValuesRaw)) {
|
||||
foreach ($extraValuesRaw as $key => $value) {
|
||||
$safeKey = $this->slugifyKey((string)$key);
|
||||
$extraValues[$safeKey] = trim((string)$value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($name === '' || $email === '' || $subject === '' || $message === '') {
|
||||
return new Response('', 302, ['Location' => '/contact?error=Please+complete+all+fields']);
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return new Response('', 302, ['Location' => '/contact?error=Please+enter+a+valid+email']);
|
||||
}
|
||||
foreach ($requiredFields as $requiredField) {
|
||||
if (($extraValues[(string)$requiredField] ?? '') === '') {
|
||||
return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]);
|
||||
}
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO) {
|
||||
return new Response('', 302, ['Location' => '/contact?error=Database+unavailable']);
|
||||
}
|
||||
if (!$this->tablesReady($db)) {
|
||||
return new Response('', 302, ['Location' => '/contact?error=Support+module+not+initialized']);
|
||||
}
|
||||
|
||||
$publicId = $this->newTicketId();
|
||||
$ip = $this->clientIp();
|
||||
$typeLabel = (string)($typeMeta['label'] ?? 'General');
|
||||
$subjectWithType = '[' . $typeLabel . '] ' . $subject;
|
||||
|
||||
$metaLines = [];
|
||||
$metaLines[] = 'Type: ' . $typeLabel;
|
||||
$fieldLabels = [];
|
||||
foreach ((array)($typeMeta['field_labels'] ?? []) as $row) {
|
||||
if (is_array($row) && isset($row['key'], $row['label'])) {
|
||||
$fieldLabels[(string)$row['key']] = (string)$row['label'];
|
||||
}
|
||||
}
|
||||
foreach ($requiredFields as $fieldKey) {
|
||||
$fieldKey = (string)$fieldKey;
|
||||
$val = trim((string)($extraValues[$fieldKey] ?? ''));
|
||||
if ($val === '') {
|
||||
continue;
|
||||
}
|
||||
$fieldLabel = $fieldLabels[$fieldKey] ?? $fieldKey;
|
||||
$metaLines[] = $fieldLabel . ': ' . $val;
|
||||
}
|
||||
$fullBody = $message;
|
||||
if ($metaLines) {
|
||||
$fullBody .= "\n\n---\n" . implode("\n", $metaLines);
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_support_tickets (ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at)
|
||||
VALUES (:ticket_no, :subject, 'open', :customer_name, :customer_email, :customer_ip, NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
':ticket_no' => $publicId,
|
||||
':subject' => $subjectWithType,
|
||||
':customer_name' => $name,
|
||||
':customer_email' => $email,
|
||||
':customer_ip' => $ip,
|
||||
]);
|
||||
$ticketId = (int)$db->lastInsertId();
|
||||
|
||||
$msgStmt = $db->prepare("
|
||||
INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source)
|
||||
VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'web')
|
||||
");
|
||||
$msgStmt->execute([
|
||||
':ticket_id' => $ticketId,
|
||||
':sender_name' => $name,
|
||||
':sender_email' => $email,
|
||||
':body_text' => $fullBody,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return new Response('', 302, ['Location' => '/contact?error=Could+not+create+ticket']);
|
||||
}
|
||||
|
||||
return new Response('', 302, ['Location' => '/contact?ok=Ticket+created:+'.urlencode($publicId)]);
|
||||
}
|
||||
|
||||
public function adminIndex(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager', 'editor'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$db = Database::get();
|
||||
$tablesReady = false;
|
||||
$tickets = [];
|
||||
if ($db instanceof PDO) {
|
||||
$tablesReady = $this->tablesReady($db);
|
||||
if ($tablesReady) {
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($q !== '') {
|
||||
$where = "WHERE ticket_no LIKE :q OR customer_email LIKE :q OR subject LIKE :q";
|
||||
$params[':q'] = '%' . $q . '%';
|
||||
}
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, ticket_no, subject, status, customer_name, customer_email, customer_ip, last_message_at, created_at
|
||||
FROM ac_support_tickets
|
||||
{$where}
|
||||
ORDER BY last_message_at DESC, id DESC
|
||||
LIMIT 100
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$tickets = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
}
|
||||
return new Response($this->view->render('admin/index.php', [
|
||||
'title' => 'Support',
|
||||
'tables_ready' => $tablesReady,
|
||||
'tickets' => $tickets,
|
||||
'q' => (string)($_GET['q'] ?? ''),
|
||||
]));
|
||||
}
|
||||
|
||||
public function adminTicket(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager', 'editor'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO || $id <= 0) {
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
$ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1");
|
||||
$ticketStmt->execute([':id' => $id]);
|
||||
$ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
if (!$ticket) {
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
$messagesStmt = $db->prepare("SELECT * FROM ac_support_messages WHERE ticket_id = :id ORDER BY id ASC");
|
||||
$messagesStmt->execute([':id' => $id]);
|
||||
$messages = $messagesStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
return new Response($this->view->render('admin/ticket.php', [
|
||||
'title' => 'Ticket ' . (string)($ticket['ticket_no'] ?? ''),
|
||||
'ticket' => $ticket,
|
||||
'messages' => $messages,
|
||||
'saved' => (string)($_GET['saved'] ?? ''),
|
||||
'error' => (string)($_GET['error'] ?? ''),
|
||||
]));
|
||||
}
|
||||
|
||||
public function adminReply(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager', 'editor'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$ticketId = (int)($_POST['ticket_id'] ?? 0);
|
||||
$body = trim((string)($_POST['body'] ?? ''));
|
||||
if ($ticketId <= 0 || $body === '') {
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO) {
|
||||
return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&error=Database+unavailable']);
|
||||
}
|
||||
|
||||
$ticketStmt = $db->prepare("SELECT * FROM ac_support_tickets WHERE id = :id LIMIT 1");
|
||||
$ticketStmt->execute([':id' => $ticketId]);
|
||||
$ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
if (!$ticket) {
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source)
|
||||
VALUES (:ticket_id, 'admin', :sender_name, :sender_email, :body_text, 'admin')
|
||||
");
|
||||
$stmt->execute([
|
||||
':ticket_id' => $ticketId,
|
||||
':sender_name' => Auth::name(),
|
||||
':sender_email' => Settings::get('smtp_from_email', ''),
|
||||
':body_text' => $body,
|
||||
]);
|
||||
$db->prepare("UPDATE ac_support_tickets SET last_message_at = NOW(), status = 'pending' WHERE id = :id")->execute([':id' => $ticketId]);
|
||||
|
||||
$email = (string)($ticket['customer_email'] ?? '');
|
||||
if ($email !== '') {
|
||||
$subject = '[AC-TICKET-' . (string)($ticket['ticket_no'] ?? $ticketId) . '] ' . (string)($ticket['subject'] ?? 'Support reply');
|
||||
$safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES, 'UTF-8'));
|
||||
$html = '<p>Hello ' . htmlspecialchars((string)($ticket['customer_name'] ?? 'there'), ENT_QUOTES, 'UTF-8') . ',</p>'
|
||||
. '<p>' . $safeBody . '</p>'
|
||||
. '<p>Reply to this email to continue ticket <strong>' . htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') . '</strong>.</p>';
|
||||
$smtp = [
|
||||
'smtp_host' => Settings::get('smtp_host', ''),
|
||||
'smtp_port' => Settings::get('smtp_port', '587'),
|
||||
'smtp_user' => Settings::get('smtp_user', ''),
|
||||
'smtp_pass' => Settings::get('smtp_pass', ''),
|
||||
'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
|
||||
'smtp_from_email' => Settings::get('smtp_from_email', ''),
|
||||
'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore Support'),
|
||||
];
|
||||
Mailer::send($email, $subject, $html, $smtp);
|
||||
}
|
||||
|
||||
return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']);
|
||||
}
|
||||
|
||||
public function adminSetStatus(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager', 'editor'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$ticketId = (int)($_POST['ticket_id'] ?? 0);
|
||||
$status = trim((string)($_POST['status'] ?? 'open'));
|
||||
if (!in_array($status, ['open', 'pending', 'closed'], true)) {
|
||||
$status = 'open';
|
||||
}
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO && $ticketId > 0) {
|
||||
$stmt = $db->prepare("UPDATE ac_support_tickets SET status = :status WHERE id = :id");
|
||||
$stmt->execute([':status' => $status, ':id' => $ticketId]);
|
||||
}
|
||||
return new Response('', 302, ['Location' => '/admin/support/ticket?id=' . $ticketId . '&saved=1']);
|
||||
}
|
||||
|
||||
public function adminDeleteTicket(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$ticketId = (int)($_POST['ticket_id'] ?? 0);
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO && $ticketId > 0) {
|
||||
$stmt = $db->prepare("DELETE FROM ac_support_tickets WHERE id = :id");
|
||||
$stmt->execute([':id' => $ticketId]);
|
||||
}
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
public function adminSettings(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
||||
$baseUrl = $host !== '' ? ($scheme . '://' . $host) : '';
|
||||
$cronKey = $this->getCronKey();
|
||||
$cronUrl = $baseUrl !== '' ? ($baseUrl . '/support/imap-sync?key=' . urlencode($cronKey)) : '/support/imap-sync?key=' . urlencode($cronKey);
|
||||
$cronCommand = "*/5 * * * * /usr/bin/curl -fsS '" . $cronUrl . "' >/dev/null 2>&1";
|
||||
|
||||
return new Response($this->view->render('admin/settings.php', [
|
||||
'title' => 'Support Settings',
|
||||
'saved' => (string)($_GET['saved'] ?? ''),
|
||||
'regenerated' => (string)($_GET['regenerated'] ?? ''),
|
||||
'imap_test' => (string)($_GET['imap_test'] ?? ''),
|
||||
'imap_error' => (string)($_GET['imap_error'] ?? ''),
|
||||
'sync_result' => (string)($_GET['sync_result'] ?? ''),
|
||||
'imap_host' => Settings::get('support_imap_host', ''),
|
||||
'imap_port' => Settings::get('support_imap_port', '993'),
|
||||
'imap_encryption' => Settings::get('support_imap_encryption', 'ssl'),
|
||||
'imap_user' => Settings::get('support_imap_user', ''),
|
||||
'imap_pass' => Settings::get('support_imap_pass', ''),
|
||||
'imap_folder' => Settings::get('support_imap_folder', 'INBOX'),
|
||||
'support_from_email' => Settings::get('support_from_email', Settings::get('smtp_from_email', '')),
|
||||
'support_ticket_prefix' => Settings::get('support_ticket_prefix', 'TCK'),
|
||||
'support_type_rows' => $this->supportTypes(),
|
||||
'cron_command' => $cronCommand,
|
||||
'cron_key' => $cronKey,
|
||||
]));
|
||||
}
|
||||
|
||||
public function adminSaveSettings(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
Settings::set('support_imap_host', trim((string)($_POST['support_imap_host'] ?? '')));
|
||||
Settings::set('support_imap_port', trim((string)($_POST['support_imap_port'] ?? '993')));
|
||||
Settings::set('support_imap_encryption', trim((string)($_POST['support_imap_encryption'] ?? 'ssl')));
|
||||
Settings::set('support_imap_user', trim((string)($_POST['support_imap_user'] ?? '')));
|
||||
Settings::set('support_imap_pass', trim((string)($_POST['support_imap_pass'] ?? '')));
|
||||
Settings::set('support_imap_folder', trim((string)($_POST['support_imap_folder'] ?? 'INBOX')));
|
||||
Settings::set('support_from_email', trim((string)($_POST['support_from_email'] ?? '')));
|
||||
$ticketPrefix = $this->slugifyTicketPrefix((string)($_POST['support_ticket_prefix'] ?? 'TCK'));
|
||||
Settings::set('support_ticket_prefix', $ticketPrefix);
|
||||
Settings::set('support_cron_key', trim((string)($_POST['support_cron_key'] ?? $this->getCronKey())));
|
||||
$titles = $_POST['support_type_title'] ?? [];
|
||||
$optionLabelsMap = $_POST['support_type_option_labels'] ?? [];
|
||||
$optionKeysMap = $_POST['support_type_option_keys'] ?? [];
|
||||
$rows = [];
|
||||
if (is_array($titles)) {
|
||||
foreach ($titles as $idx => $titleRaw) {
|
||||
$title = trim((string)$titleRaw);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $this->slugifyKey($title);
|
||||
$selected = [];
|
||||
$fieldLabels = [];
|
||||
$usedKeys = [];
|
||||
$labels = [];
|
||||
$keys = [];
|
||||
if (is_array($optionLabelsMap) && isset($optionLabelsMap[$idx]) && is_array($optionLabelsMap[$idx])) {
|
||||
$labels = $optionLabelsMap[$idx];
|
||||
}
|
||||
if (is_array($optionKeysMap) && isset($optionKeysMap[$idx]) && is_array($optionKeysMap[$idx])) {
|
||||
$keys = $optionKeysMap[$idx];
|
||||
}
|
||||
foreach ($labels as $optIndex => $labelRaw) {
|
||||
$safeLabel = trim((string)$labelRaw);
|
||||
if ($safeLabel === '') {
|
||||
continue;
|
||||
}
|
||||
$preferredKey = '';
|
||||
if (isset($keys[$optIndex])) {
|
||||
$preferredKey = $this->slugifyKey((string)$keys[$optIndex]);
|
||||
}
|
||||
if ($preferredKey === '') {
|
||||
$preferredKey = $this->slugifyKey($safeLabel);
|
||||
}
|
||||
$safeKey = $preferredKey;
|
||||
$suffix = 2;
|
||||
while (isset($usedKeys[$safeKey])) {
|
||||
$safeKey = $preferredKey . '_' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
$usedKeys[$safeKey] = true;
|
||||
$selected[] = $safeKey;
|
||||
$fieldLabels[] = ['key' => $safeKey, 'label' => $safeLabel];
|
||||
}
|
||||
$rows[] = [
|
||||
'key' => $key,
|
||||
'label' => $title,
|
||||
'fields' => $selected,
|
||||
'field_labels' => $fieldLabels,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$rows) {
|
||||
$rows = $this->supportTypesFromConfig($this->defaultSupportTypesConfig());
|
||||
}
|
||||
Settings::set('support_types_config', json_encode($rows, JSON_UNESCAPED_SLASHES));
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?saved=1']);
|
||||
}
|
||||
|
||||
public function adminRunImapSync(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$result = $this->runImapSync();
|
||||
$status = $result['ok'] ? 'ok' : ('error:' . $result['error']);
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?sync_result=' . urlencode($status)]);
|
||||
}
|
||||
|
||||
public function adminRegenerateCronKey(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$newKey = bin2hex(random_bytes(16));
|
||||
Settings::set('support_cron_key', $newKey);
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?regenerated=1']);
|
||||
}
|
||||
|
||||
public function cronImapSync(): Response
|
||||
{
|
||||
$key = trim((string)($_GET['key'] ?? ''));
|
||||
if ($key === '' || !hash_equals($this->getCronKey(), $key)) {
|
||||
return new Response('Unauthorized', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
$result = $this->runImapSync();
|
||||
$body = $result['ok']
|
||||
? ('OK imported=' . (string)$result['imported'] . ' scanned=' . (string)$result['scanned'])
|
||||
: ('ERROR ' . (string)$result['error']);
|
||||
return new Response($body, $result['ok'] ? 200 : 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
|
||||
public function adminTestImap(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
|
||||
if (!function_exists('imap_open')) {
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('PHP IMAP extension is not enabled on server')]);
|
||||
}
|
||||
|
||||
$host = trim((string)($_POST['support_imap_host'] ?? ''));
|
||||
$port = (int)trim((string)($_POST['support_imap_port'] ?? '993'));
|
||||
$enc = strtolower(trim((string)($_POST['support_imap_encryption'] ?? 'ssl')));
|
||||
$user = trim((string)($_POST['support_imap_user'] ?? ''));
|
||||
$pass = (string)($_POST['support_imap_pass'] ?? '');
|
||||
$folder = trim((string)($_POST['support_imap_folder'] ?? 'INBOX'));
|
||||
|
||||
if ($host === '' || $port <= 0 || $user === '' || $pass === '') {
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode('Host, port, username and password are required')]);
|
||||
}
|
||||
|
||||
if (defined('IMAP_OPENTIMEOUT')) {
|
||||
@imap_timeout(IMAP_OPENTIMEOUT, 12);
|
||||
}
|
||||
if (defined('IMAP_READTIMEOUT')) {
|
||||
@imap_timeout(IMAP_READTIMEOUT, 12);
|
||||
}
|
||||
|
||||
$imapFlags = '/imap';
|
||||
if ($enc === 'ssl') {
|
||||
$imapFlags .= '/ssl';
|
||||
} elseif ($enc === 'tls') {
|
||||
$imapFlags .= '/tls';
|
||||
} else {
|
||||
$imapFlags .= '/notls';
|
||||
}
|
||||
$mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder;
|
||||
$mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder;
|
||||
|
||||
$conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1);
|
||||
if (!$conn) {
|
||||
// Many hosts use self-signed/chain-issue certs; retry with novalidate-cert.
|
||||
$conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1);
|
||||
}
|
||||
if (!$conn) {
|
||||
$err = (string)imap_last_error();
|
||||
if ($err === '') {
|
||||
$err = 'Connection failed';
|
||||
}
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?imap_error=' . urlencode($err)]);
|
||||
}
|
||||
|
||||
@imap_close($conn);
|
||||
return new Response('', 302, ['Location' => '/admin/support/settings?imap_test=ok']);
|
||||
}
|
||||
|
||||
public function adminInstall(): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return new Response('', 302, ['Location' => '/admin/login']);
|
||||
}
|
||||
if (!Auth::hasRole(['admin', 'manager'])) {
|
||||
return new Response('', 302, ['Location' => '/admin']);
|
||||
}
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
$this->createTables($db);
|
||||
}
|
||||
return new Response('', 302, ['Location' => '/admin/support']);
|
||||
}
|
||||
|
||||
private function tablesReady(PDO $db): bool
|
||||
{
|
||||
try {
|
||||
$ticketCheck = $db->query("SHOW TABLES LIKE 'ac_support_tickets'");
|
||||
$msgCheck = $db->query("SHOW TABLES LIKE 'ac_support_messages'");
|
||||
return (bool)($ticketCheck && $ticketCheck->fetch(PDO::FETCH_ASSOC))
|
||||
&& (bool)($msgCheck && $msgCheck->fetch(PDO::FETCH_ASSOC));
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function createTables(PDO $db): void
|
||||
{
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_support_tickets (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_no VARCHAR(32) NOT NULL UNIQUE,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
status ENUM('open','pending','closed') NOT NULL DEFAULT 'open',
|
||||
customer_name VARCHAR(160) NULL,
|
||||
customer_email VARCHAR(190) NOT NULL,
|
||||
customer_ip VARCHAR(45) NULL,
|
||||
last_message_at DATETIME NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_support_messages (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT UNSIGNED NOT NULL,
|
||||
sender_type ENUM('customer','admin') NOT NULL,
|
||||
sender_name VARCHAR(160) NULL,
|
||||
sender_email VARCHAR(190) NULL,
|
||||
body_text MEDIUMTEXT NOT NULL,
|
||||
source ENUM('web','email','admin') NOT NULL DEFAULT 'web',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_ticket_id (ticket_id),
|
||||
CONSTRAINT fk_support_ticket FOREIGN KEY (ticket_id) REFERENCES ac_support_tickets(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_support_inbound_log (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
message_uid VARCHAR(190) NOT NULL UNIQUE,
|
||||
ticket_id INT UNSIGNED NULL,
|
||||
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,error:string,imported:int,scanned:int}
|
||||
*/
|
||||
private function runImapSync(): array
|
||||
{
|
||||
if (!function_exists('imap_open')) {
|
||||
return ['ok' => false, 'error' => 'IMAP extension missing', 'imported' => 0, 'scanned' => 0];
|
||||
}
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO || !$this->tablesReady($db)) {
|
||||
return ['ok' => false, 'error' => 'Support tables not ready', 'imported' => 0, 'scanned' => 0];
|
||||
}
|
||||
|
||||
$host = Settings::get('support_imap_host', '');
|
||||
$port = (int)Settings::get('support_imap_port', '993');
|
||||
$enc = strtolower(Settings::get('support_imap_encryption', 'ssl'));
|
||||
$user = Settings::get('support_imap_user', '');
|
||||
$pass = Settings::get('support_imap_pass', '');
|
||||
$folder = Settings::get('support_imap_folder', 'INBOX');
|
||||
if ($host === '' || $port <= 0 || $user === '' || $pass === '') {
|
||||
return ['ok' => false, 'error' => 'IMAP settings incomplete', 'imported' => 0, 'scanned' => 0];
|
||||
}
|
||||
|
||||
$imapFlags = '/imap';
|
||||
if ($enc === 'ssl') {
|
||||
$imapFlags .= '/ssl';
|
||||
} elseif ($enc === 'tls') {
|
||||
$imapFlags .= '/tls';
|
||||
} else {
|
||||
$imapFlags .= '/notls';
|
||||
}
|
||||
$mailbox = '{' . $host . ':' . $port . $imapFlags . '}' . $folder;
|
||||
$mailboxNoValidate = '{' . $host . ':' . $port . $imapFlags . '/novalidate-cert}' . $folder;
|
||||
|
||||
$conn = @imap_open($mailbox, $user, $pass, OP_READONLY, 1);
|
||||
if (!$conn) {
|
||||
$conn = @imap_open($mailboxNoValidate, $user, $pass, OP_READONLY, 1);
|
||||
}
|
||||
if (!$conn) {
|
||||
$err = (string)imap_last_error();
|
||||
return ['ok' => false, 'error' => ($err !== '' ? $err : 'IMAP connection failed'), 'imported' => 0, 'scanned' => 0];
|
||||
}
|
||||
|
||||
$msgNos = @imap_search($conn, 'UNSEEN') ?: [];
|
||||
if (!$msgNos) {
|
||||
$msgNos = @imap_search($conn, 'ALL') ?: [];
|
||||
if (count($msgNos) > 100) {
|
||||
$msgNos = array_slice($msgNos, -100);
|
||||
}
|
||||
}
|
||||
|
||||
$imported = 0;
|
||||
$scanned = 0;
|
||||
$supportFrom = strtolower(Settings::get('support_from_email', Settings::get('smtp_from_email', '')));
|
||||
|
||||
foreach ($msgNos as $msgNoRaw) {
|
||||
$msgNo = (int)$msgNoRaw;
|
||||
if ($msgNo <= 0) {
|
||||
continue;
|
||||
}
|
||||
$scanned++;
|
||||
$overview = @imap_fetch_overview($conn, (string)$msgNo, 0);
|
||||
if (!$overview || empty($overview[0])) {
|
||||
continue;
|
||||
}
|
||||
$ov = $overview[0];
|
||||
$subject = isset($ov->subject) ? imap_utf8((string)$ov->subject) : '';
|
||||
$fromRaw = isset($ov->from) ? (string)$ov->from : '';
|
||||
$messageId = isset($ov->message_id) ? trim((string)$ov->message_id) : '';
|
||||
if ($messageId === '') {
|
||||
$uid = @imap_uid($conn, $msgNo);
|
||||
$messageId = 'uid-' . (string)$uid;
|
||||
}
|
||||
|
||||
$existsStmt = $db->prepare("SELECT id FROM ac_support_inbound_log WHERE message_uid = :uid LIMIT 1");
|
||||
$existsStmt->execute([':uid' => $messageId]);
|
||||
if ($existsStmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$token = $this->extractTicketToken($subject);
|
||||
if ($token === '') {
|
||||
$this->logInbound($db, $messageId, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
$ticketStmt = $db->prepare("SELECT id FROM ac_support_tickets WHERE ticket_no = :ticket_no LIMIT 1");
|
||||
$ticketStmt->execute([':ticket_no' => $token]);
|
||||
$ticket = $ticketStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$ticket) {
|
||||
$this->logInbound($db, $messageId, null);
|
||||
continue;
|
||||
}
|
||||
$ticketId = (int)$ticket['id'];
|
||||
|
||||
$fromEmail = $this->extractEmailFromHeader($fromRaw);
|
||||
if ($fromEmail !== '' && $supportFrom !== '' && strtolower($fromEmail) === $supportFrom) {
|
||||
$this->logInbound($db, $messageId, $ticketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
$body = $this->fetchMessageBody($conn, $msgNo);
|
||||
if ($body === '') {
|
||||
$this->logInbound($db, $messageId, $ticketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertMsg = $db->prepare("
|
||||
INSERT INTO ac_support_messages (ticket_id, sender_type, sender_name, sender_email, body_text, source)
|
||||
VALUES (:ticket_id, 'customer', :sender_name, :sender_email, :body_text, 'email')
|
||||
");
|
||||
$insertMsg->execute([
|
||||
':ticket_id' => $ticketId,
|
||||
':sender_name' => '',
|
||||
':sender_email' => $fromEmail !== '' ? $fromEmail : $fromRaw,
|
||||
':body_text' => $body,
|
||||
]);
|
||||
$db->prepare("UPDATE ac_support_tickets SET status = 'open', last_message_at = NOW() WHERE id = :id")->execute([':id' => $ticketId]);
|
||||
$this->logInbound($db, $messageId, $ticketId);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
@imap_close($conn);
|
||||
return ['ok' => true, 'error' => '', 'imported' => $imported, 'scanned' => $scanned];
|
||||
}
|
||||
|
||||
private function logInbound(PDO $db, string $uid, ?int $ticketId): void
|
||||
{
|
||||
$stmt = $db->prepare("INSERT INTO ac_support_inbound_log (message_uid, ticket_id) VALUES (:uid, :ticket_id)");
|
||||
$stmt->execute([
|
||||
':uid' => $uid,
|
||||
':ticket_id' => $ticketId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function extractTicketToken(string $subject): string
|
||||
{
|
||||
if (preg_match('/\\[AC-TICKET-([A-Z0-9\\-]+)\\]/i', $subject, $m)) {
|
||||
return strtoupper(trim((string)$m[1]));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractEmailFromHeader(string $from): string
|
||||
{
|
||||
if (preg_match('/<([^>]+)>/', $from, $m)) {
|
||||
return trim((string)$m[1]);
|
||||
}
|
||||
return filter_var(trim($from), FILTER_VALIDATE_EMAIL) ? trim($from) : '';
|
||||
}
|
||||
|
||||
private function fetchMessageBody($conn, int $msgNo): string
|
||||
{
|
||||
$structure = @imap_fetchstructure($conn, $msgNo);
|
||||
if (!$structure) {
|
||||
$raw = (string)@imap_body($conn, $msgNo);
|
||||
return $this->sanitizeEmailBody($raw);
|
||||
}
|
||||
$body = '';
|
||||
if (isset($structure->parts) && is_array($structure->parts)) {
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
$partNo = (string)($i + 1);
|
||||
$isText = ((int)($part->type ?? -1) === 0);
|
||||
$subtype = strtolower((string)($part->subtype ?? ''));
|
||||
if ($isText && $subtype === 'plain') {
|
||||
$body = (string)@imap_fetchbody($conn, $msgNo, $partNo);
|
||||
$body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($body === '') {
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
$partNo = (string)($i + 1);
|
||||
$isText = ((int)($part->type ?? -1) === 0);
|
||||
if ($isText) {
|
||||
$body = (string)@imap_fetchbody($conn, $msgNo, $partNo);
|
||||
$body = $this->decodeImapBodyByEncoding($body, (int)($part->encoding ?? 0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($body === '') {
|
||||
$raw = (string)@imap_body($conn, $msgNo);
|
||||
$body = $this->decodeImapBodyByEncoding($raw, (int)($structure->encoding ?? 0));
|
||||
}
|
||||
return $this->sanitizeEmailBody($body);
|
||||
}
|
||||
|
||||
private function decodeImapBodyByEncoding(string $body, int $encoding): string
|
||||
{
|
||||
return match ($encoding) {
|
||||
3 => base64_decode($body, true) ?: $body,
|
||||
4 => quoted_printable_decode($body),
|
||||
default => $body,
|
||||
};
|
||||
}
|
||||
|
||||
private function sanitizeEmailBody(string $body): string
|
||||
{
|
||||
$body = str_replace("\r\n", "\n", $body);
|
||||
$body = preg_replace('/\nOn .*wrote:\n.*/s', '', $body) ?? $body;
|
||||
$body = trim($body);
|
||||
if (strlen($body) > 8000) {
|
||||
$body = substr($body, 0, 8000);
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function getCronKey(): string
|
||||
{
|
||||
$key = trim(Settings::get('support_cron_key', ''));
|
||||
if ($key === '') {
|
||||
$key = bin2hex(random_bytes(16));
|
||||
Settings::set('support_cron_key', $key);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function newTicketId(): string
|
||||
{
|
||||
$prefix = $this->slugifyTicketPrefix(Settings::get('support_ticket_prefix', 'TCK'));
|
||||
return $prefix . '-' . date('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
|
||||
}
|
||||
|
||||
private function slugifyTicketPrefix(string $value): string
|
||||
{
|
||||
$value = strtoupper(trim($value));
|
||||
$value = preg_replace('/[^A-Z0-9]+/', '-', $value) ?? $value;
|
||||
$value = trim($value, '-');
|
||||
if ($value === '') {
|
||||
return 'TCK';
|
||||
}
|
||||
return substr($value, 0, 16);
|
||||
}
|
||||
|
||||
private function defaultSupportTypesConfig(): string
|
||||
{
|
||||
return json_encode([
|
||||
['key' => 'general', 'label' => 'General', 'fields' => [], 'field_labels' => []],
|
||||
['key' => 'order_issue', 'label' => 'Order Issue', 'fields' => ['order_no'], 'field_labels' => [['key' => 'order_no', 'label' => 'Order Number']]],
|
||||
['key' => 'billing', 'label' => 'Billing', 'fields' => ['billing_ref'], 'field_labels' => [['key' => 'billing_ref', 'label' => 'Billing Reference']]],
|
||||
['key' => 'technical', 'label' => 'Technical', 'fields' => ['page_url', 'browser_info'], 'field_labels' => [['key' => 'page_url', 'label' => 'Page URL'], ['key' => 'browser_info', 'label' => 'Browser / Device']]],
|
||||
['key' => 'dmca', 'label' => 'DMCA', 'fields' => ['infringing_url', 'rights_owner', 'proof_url'], 'field_labels' => [['key' => 'infringing_url', 'label' => 'Infringing URL'], ['key' => 'rights_owner', 'label' => 'Rights Owner'], ['key' => 'proof_url', 'label' => 'Proof URL']]],
|
||||
['key' => 'other', 'label' => 'Other', 'fields' => [], 'field_labels' => []],
|
||||
], JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,fields:array<int,string>}>
|
||||
*/
|
||||
private function supportTypes(): array
|
||||
{
|
||||
$raw = Settings::get('support_types_config', $this->defaultSupportTypesConfig());
|
||||
return $this->supportTypesFromConfig($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,label:string,fields:array<int,string>}>
|
||||
*/
|
||||
private function supportTypesFromConfig(string $raw): array
|
||||
{
|
||||
$types = [];
|
||||
$decodedJson = json_decode($raw, true);
|
||||
if (is_array($decodedJson)) {
|
||||
foreach ($decodedJson as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = $this->slugifyKey((string)($row['key'] ?? $row['label'] ?? ''));
|
||||
$label = trim((string)($row['label'] ?? ''));
|
||||
$rawFields = is_array($row['fields'] ?? null) ? $row['fields'] : [];
|
||||
$rawFieldLabels = is_array($row['field_labels'] ?? null) ? $row['field_labels'] : [];
|
||||
if ($key === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$fields = [];
|
||||
foreach ($rawFields as $field) {
|
||||
$field = trim((string)$field);
|
||||
if ($field !== '') {
|
||||
$fields[] = $field;
|
||||
}
|
||||
}
|
||||
$fieldLabels = [];
|
||||
foreach ($rawFieldLabels as $fr) {
|
||||
if (!is_array($fr)) {
|
||||
continue;
|
||||
}
|
||||
$fk = $this->slugifyKey((string)($fr['key'] ?? ''));
|
||||
$fl = trim((string)($fr['label'] ?? ''));
|
||||
if ($fk !== '' && $fl !== '') {
|
||||
$fieldLabels[] = ['key' => $fk, 'label' => $fl];
|
||||
}
|
||||
}
|
||||
$types[] = [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'fields' => array_values(array_unique($fields)),
|
||||
'field_labels' => $fieldLabels,
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($types) {
|
||||
return $types;
|
||||
}
|
||||
|
||||
foreach (preg_split('/\r?\n/', $raw) as $line) {
|
||||
$line = trim((string)$line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
$parts = array_map('trim', explode('|', $line));
|
||||
$key = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($parts[0] ?? '')));
|
||||
$label = (string)($parts[1] ?? '');
|
||||
$fieldRaw = (string)($parts[2] ?? '');
|
||||
if ($key === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
$fields = [];
|
||||
foreach (array_map('trim', explode(',', $fieldRaw)) as $field) {
|
||||
if ($field !== '') {
|
||||
$fields[] = $field;
|
||||
}
|
||||
}
|
||||
$types[] = ['key' => $key, 'label' => $label, 'fields' => $fields];
|
||||
}
|
||||
if (!$types) {
|
||||
return [
|
||||
['key' => 'general', 'label' => 'General', 'fields' => []],
|
||||
];
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
|
||||
private function slugifyKey(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9]+/', '_', $value) ?? $value;
|
||||
$value = trim($value, '_');
|
||||
return $value !== '' ? substr($value, 0, 48) : 'type_' . substr(bin2hex(random_bytes(2)), 0, 4);
|
||||
}
|
||||
|
||||
private function clientIp(): string
|
||||
{
|
||||
$ip = (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '');
|
||||
if (strpos($ip, ',') !== false) {
|
||||
$parts = explode(',', $ip);
|
||||
$ip = trim((string)$parts[0]);
|
||||
}
|
||||
return substr($ip, 0, 45);
|
||||
}
|
||||
}
|
||||
14
plugins/support/plugin.json
Normal file
14
plugins/support/plugin.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Support",
|
||||
"version": "0.1.0",
|
||||
"description": "Contact form and support ticket system.",
|
||||
"author": "AudioCore",
|
||||
"admin_nav": {
|
||||
"label": "Support",
|
||||
"url": "/admin/support",
|
||||
"roles": ["admin", "manager", "editor"],
|
||||
"icon": "fa-solid fa-life-ring"
|
||||
},
|
||||
"entry": "plugin.php",
|
||||
"default_enabled": false
|
||||
}
|
||||
36
plugins/support/plugin.php
Normal file
36
plugins/support/plugin.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Http\Router;
|
||||
use Core\Services\Shortcodes;
|
||||
use Plugins\Support\SupportController;
|
||||
|
||||
require_once __DIR__ . '/SupportController.php';
|
||||
|
||||
Shortcodes::register('support-link', static function (array $attrs = []): string {
|
||||
$label = trim((string)($attrs['label'] ?? 'Support'));
|
||||
if ($label === '') {
|
||||
$label = 'Support';
|
||||
}
|
||||
return '<a class="ac-shortcode-link ac-shortcode-link-support" href="/contact">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a>';
|
||||
});
|
||||
|
||||
return function (Router $router): void {
|
||||
$controller = new SupportController();
|
||||
|
||||
$router->get('/contact', [$controller, 'contactForm']);
|
||||
$router->post('/contact', [$controller, 'contactSubmit']);
|
||||
|
||||
$router->get('/admin/support', [$controller, 'adminIndex']);
|
||||
$router->post('/admin/support/install', [$controller, 'adminInstall']);
|
||||
$router->get('/admin/support/settings', [$controller, 'adminSettings']);
|
||||
$router->post('/admin/support/settings', [$controller, 'adminSaveSettings']);
|
||||
$router->post('/admin/support/settings/regenerate-key', [$controller, 'adminRegenerateCronKey']);
|
||||
$router->post('/admin/support/settings/test-imap', [$controller, 'adminTestImap']);
|
||||
$router->post('/admin/support/settings/run-sync', [$controller, 'adminRunImapSync']);
|
||||
$router->get('/support/imap-sync', [$controller, 'cronImapSync']);
|
||||
$router->get('/admin/support/ticket', [$controller, 'adminTicket']);
|
||||
$router->post('/admin/support/ticket/reply', [$controller, 'adminReply']);
|
||||
$router->post('/admin/support/ticket/status', [$controller, 'adminSetStatus']);
|
||||
$router->post('/admin/support/ticket/delete', [$controller, 'adminDeleteTicket']);
|
||||
};
|
||||
61
plugins/support/views/admin/index.php
Normal file
61
plugins/support/views/admin/index.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Support';
|
||||
$tablesReady = (bool)($tables_ready ?? false);
|
||||
$tickets = is_array($tickets ?? null) ? $tickets : [];
|
||||
$q = (string)($q ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
<div class="badge">Support</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
|
||||
<div>
|
||||
<h1 style="font-size:28px; margin:0;">Tickets</h1>
|
||||
<p style="color: var(--muted); margin-top:6px;">Contact form submissions and support conversations.</p>
|
||||
</div>
|
||||
<a href="/admin/support/settings" class="btn outline">Settings</a>
|
||||
</div>
|
||||
|
||||
<?php if (!$tablesReady): ?>
|
||||
<div class="admin-card" style="margin-top:16px; padding:16px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
|
||||
<div>
|
||||
<div style="font-weight:600;">Support tables not initialized</div>
|
||||
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create support tables before accepting contact tickets.</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/support/install">
|
||||
<button type="submit" class="btn small">Create Tables</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="get" action="/admin/support" style="margin-top:16px; display:flex; gap:8px;">
|
||||
<input class="input" type="text" name="q" value="<?= htmlspecialchars($q, ENT_QUOTES, 'UTF-8') ?>" placeholder="Search ticket no, subject, or email">
|
||||
<button class="btn small" type="submit">Search</button>
|
||||
<a href="/admin/support" class="btn outline small">Reset</a>
|
||||
</form>
|
||||
|
||||
<div class="admin-card" style="margin-top:12px; padding:12px;">
|
||||
<?php if (!$tickets): ?>
|
||||
<div style="color:var(--muted); font-size:13px;">No tickets yet.</div>
|
||||
<?php else: ?>
|
||||
<div style="display:grid; gap:10px;">
|
||||
<?php foreach ($tickets as $ticket): ?>
|
||||
<a href="/admin/support/ticket?id=<?= (int)($ticket['id'] ?? 0) ?>" style="text-decoration:none; color:inherit;">
|
||||
<div class="admin-card" style="padding:12px; display:grid; grid-template-columns:1.1fr 2fr auto auto; gap:12px; align-items:center;">
|
||||
<div>
|
||||
<div style="font-weight:700;"><?= htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="color:var(--muted); font-size:12px; margin-top:2px;"><?= htmlspecialchars((string)($ticket['customer_email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
<div style="font-size:13px;"><?= htmlspecialchars((string)($ticket['subject'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="pill"><?= htmlspecialchars((string)($ticket['status'] ?? 'open'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:12px; color:var(--muted); white-space:nowrap;"><?= htmlspecialchars((string)($ticket['last_message_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../modules/admin/views/layout.php';
|
||||
|
||||
271
plugins/support/views/admin/settings.php
Normal file
271
plugins/support/views/admin/settings.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Support Settings';
|
||||
$saved = (string)($saved ?? '');
|
||||
$regenerated = (string)($regenerated ?? '');
|
||||
$imapTest = (string)($imap_test ?? '');
|
||||
$imapError = (string)($imap_error ?? '');
|
||||
$syncResult = (string)($sync_result ?? '');
|
||||
$supportTypeRows = is_array($support_type_rows ?? null) ? $support_type_rows : [];
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
<div class="badge">Support</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
|
||||
<div>
|
||||
<h1 style="font-size:28px; margin:0;">Support Settings</h1>
|
||||
<p style="color: var(--muted); margin-top:6px;">Configure ticket behavior, IMAP inbox sync, and request categories.</p>
|
||||
</div>
|
||||
<a href="/admin/support" class="btn outline">Back</a>
|
||||
</div>
|
||||
|
||||
<?php if ($saved !== ''): ?><div style="margin-top:12px; color:#9be7c6; font-size:13px;">Settings saved.</div><?php endif; ?>
|
||||
<?php if ($imapTest === 'ok'): ?><div style="margin-top:12px; color:#9be7c6; font-size:13px;">IMAP connection successful.</div><?php endif; ?>
|
||||
<?php if ($imapError !== ''): ?><div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($imapError, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
|
||||
<?php if ($syncResult !== ''): ?>
|
||||
<div style="margin-top:12px; color:<?= str_starts_with($syncResult, 'ok') ? '#9be7c6' : '#f3b0b0' ?>; font-size:13px;">
|
||||
Sync result: <?= htmlspecialchars($syncResult, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;">
|
||||
<button type="button" class="btn outline support-tab-btn active" data-tab="general">General</button>
|
||||
<button type="button" class="btn outline support-tab-btn" data-tab="email">Email</button>
|
||||
<button type="button" class="btn outline support-tab-btn" data-tab="categories">Categories</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/support/settings" style="margin-top:14px; display:grid; gap:12px;">
|
||||
<div class="support-tab-panel" data-panel="general">
|
||||
<div class="admin-card" style="padding:16px; display:grid; gap:10px;">
|
||||
<div>
|
||||
<div class="label">Ticket Prefix</div>
|
||||
<input class="input" name="support_ticket_prefix" value="<?= htmlspecialchars((string)($support_ticket_prefix ?? 'TCK'), ENT_QUOTES, 'UTF-8') ?>" placeholder="TCK">
|
||||
<div style="margin-top:6px; color:var(--muted); font-size:12px;">Example: TCK-20260221-ABC123</div>
|
||||
</div>
|
||||
<div style="margin-top:2px; color:var(--muted); font-size:12px;">Cron job keys and commands are now managed from <a href="/admin/crons" style="color:#9ff8d8;">Admin > Cron Jobs</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-tab-panel" data-panel="email style="display:none;">
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<div style="display:grid; grid-template-columns:1fr 130px 130px; gap:10px;">
|
||||
<div>
|
||||
<div class="label">IMAP Host</div>
|
||||
<input class="input" name="support_imap_host" value="<?= htmlspecialchars((string)($imap_host ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="mail.example.com">
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">IMAP Port</div>
|
||||
<input class="input" name="support_imap_port" value="<?= htmlspecialchars((string)($imap_port ?? '993'), ENT_QUOTES, 'UTF-8') ?>" placeholder="993">
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Encryption</div>
|
||||
<select class="input" name="support_imap_encryption">
|
||||
<?php $enc = (string)($imap_encryption ?? 'ssl'); ?>
|
||||
<option value="ssl" <?= $enc === 'ssl' ? 'selected' : '' ?>>SSL</option>
|
||||
<option value="tls" <?= $enc === 'tls' ? 'selected' : '' ?>>TLS</option>
|
||||
<option value="none" <?= $enc === 'none' ? 'selected' : '' ?>>None</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px;">
|
||||
<div>
|
||||
<div class="label">IMAP Username</div>
|
||||
<input class="input" name="support_imap_user" value="<?= htmlspecialchars((string)($imap_user ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="support@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">IMAP Password</div>
|
||||
<input class="input" type="password" name="support_imap_pass" value="<?= htmlspecialchars((string)($imap_pass ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px;">
|
||||
<div>
|
||||
<div class="label">Inbox Folder</div>
|
||||
<input class="input" name="support_imap_folder" value="<?= htmlspecialchars((string)($imap_folder ?? 'INBOX'), ENT_QUOTES, 'UTF-8') ?>" placeholder="INBOX">
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Support From Email (optional)</div>
|
||||
<input class="input" name="support_from_email" value="<?= htmlspecialchars((string)($support_from_email ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="support@example.com">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px; display:flex; flex-wrap:wrap; gap:8px;">
|
||||
<button class="btn outline" type="submit" formaction="/admin/support/settings/test-imap" formmethod="post">Test IMAP</button>
|
||||
<button class="btn outline" type="submit" formaction="/admin/support/settings/run-sync" formmethod="post">Run Sync Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-tab-panel" data-panel="categories" style="display:none;">
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<div class="label">Support Request Types</div>
|
||||
<button type="button" class="btn outline small" id="addSupportTypeBtn">Add Type</button>
|
||||
</div>
|
||||
<div id="supportTypeRows" style="display:grid; gap:10px; margin-top:8px;">
|
||||
<?php foreach ($supportTypeRows as $index => $row): ?>
|
||||
<div class="admin-card support-type-row" style="padding:10px;">
|
||||
<div style="display:grid; grid-template-columns:1fr auto; gap:10px; align-items:end;">
|
||||
<div>
|
||||
<div class="label" style="font-size:10px;">Title</div>
|
||||
<input class="input" name="support_type_title[]" value="<?= htmlspecialchars((string)($row['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Order Issue">
|
||||
</div>
|
||||
<button type="button" class="btn outline small removeSupportTypeBtn">Remove Type</button>
|
||||
</div>
|
||||
<div style="margin-top:10px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px;">
|
||||
<div class="label" style="font-size:10px;">Additional Options</div>
|
||||
<button type="button" class="btn outline small addFieldBtn">Add Option</button>
|
||||
</div>
|
||||
<div class="support-type-fields" style="display:grid; gap:8px;">
|
||||
<?php
|
||||
$labelsMap = [];
|
||||
foreach ((array)($row['field_labels'] ?? []) as $opt) {
|
||||
if (is_array($opt) && isset($opt['key'], $opt['label'])) {
|
||||
$labelsMap[(string)$opt['key']] = (string)$opt['label'];
|
||||
}
|
||||
}
|
||||
foreach ((array)($row['fields'] ?? []) as $fieldKey):
|
||||
$fieldKey = (string)$fieldKey;
|
||||
$label = $labelsMap[$fieldKey] ?? $fieldKey;
|
||||
?>
|
||||
<div class="support-field-row" style="display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center;">
|
||||
<input class="input option-label" name="support_type_option_labels[<?= (int)$index ?>][]" value="<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>" placeholder="Option title">
|
||||
<button type="button" class="btn outline small removeFieldBtn">Remove</button>
|
||||
<input type="hidden" class="option-key" name="support_type_option_keys[<?= (int)$index ?>][]" value="<?= htmlspecialchars($fieldKey, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:flex-end;">
|
||||
<button class="btn" type="submit">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<template id="supportTypeRowTemplate">
|
||||
<div class="admin-card support-type-row" style="padding:10px;">
|
||||
<div style="display:grid; grid-template-columns:1fr auto; gap:10px; align-items:end;">
|
||||
<div>
|
||||
<div class="label" style="font-size:10px;">Title</div>
|
||||
<input class="input support-type-title" name="support_type_title[]" value="" placeholder="New Support Type">
|
||||
</div>
|
||||
<button type="button" class="btn outline small removeSupportTypeBtn">Remove Type</button>
|
||||
</div>
|
||||
<div style="margin-top:10px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px;">
|
||||
<div class="label" style="font-size:10px;">Additional Options</div>
|
||||
<button type="button" class="btn outline small addFieldBtn">Add Option</button>
|
||||
</div>
|
||||
<div class="support-type-fields" style="display:grid; gap:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="supportFieldRowTemplate">
|
||||
<div class="support-field-row" style="display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center;">
|
||||
<input class="input option-label" value="" placeholder="Option title">
|
||||
<button type="button" class="btn outline small removeFieldBtn">Remove</button>
|
||||
<input type="hidden" class="option-key" value="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const tabButtons = document.querySelectorAll('.support-tab-btn');
|
||||
const tabPanels = document.querySelectorAll('.support-tab-panel');
|
||||
tabButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tab = btn.getAttribute('data-tab');
|
||||
tabButtons.forEach((b) => b.classList.toggle('active', b === btn));
|
||||
tabPanels.forEach((panel) => {
|
||||
panel.style.display = panel.getAttribute('data-panel') === tab ? '' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
const rowsWrap = document.getElementById('supportTypeRows');
|
||||
const addBtn = document.getElementById('addSupportTypeBtn');
|
||||
const rowTemplate = document.getElementById('supportTypeRowTemplate');
|
||||
const fieldTemplate = document.getElementById('supportFieldRowTemplate');
|
||||
if (!rowsWrap || !addBtn || !rowTemplate || !fieldTemplate) return;
|
||||
|
||||
const slugify = (value) => {
|
||||
const raw = (value || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
return raw || ('field_' + Math.random().toString(16).slice(2, 6));
|
||||
};
|
||||
|
||||
const normalizeIndexes = () => {
|
||||
rowsWrap.querySelectorAll('.support-type-row').forEach((row, index) => {
|
||||
row.querySelectorAll('.support-field-row').forEach((fieldRow) => {
|
||||
const keyInput = fieldRow.querySelector('.option-key');
|
||||
const labelInput = fieldRow.querySelector('.option-label');
|
||||
if (!keyInput || !labelInput) return;
|
||||
keyInput.name = `support_type_option_keys[${index}][]`;
|
||||
labelInput.name = `support_type_option_labels[${index}][]`;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const wireButtons = () => {
|
||||
rowsWrap.querySelectorAll('.removeSupportTypeBtn').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const row = btn.closest('.support-type-row');
|
||||
if (!row) return;
|
||||
row.remove();
|
||||
normalizeIndexes();
|
||||
};
|
||||
});
|
||||
rowsWrap.querySelectorAll('.addFieldBtn').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const row = btn.closest('.support-type-row');
|
||||
const wrap = row ? row.querySelector('.support-type-fields') : null;
|
||||
if (!wrap) return;
|
||||
const node = fieldTemplate.content.firstElementChild.cloneNode(true);
|
||||
const labelInput = node.querySelector('.option-label');
|
||||
const keyInput = node.querySelector('.option-key');
|
||||
keyInput.value = slugify('option');
|
||||
labelInput.value = '';
|
||||
wrap.appendChild(node);
|
||||
wireButtons();
|
||||
normalizeIndexes();
|
||||
};
|
||||
});
|
||||
rowsWrap.querySelectorAll('.removeFieldBtn').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const row = btn.closest('.support-field-row');
|
||||
if (!row) return;
|
||||
row.remove();
|
||||
normalizeIndexes();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
const node = rowTemplate.content.firstElementChild.cloneNode(true);
|
||||
rowsWrap.appendChild(node);
|
||||
normalizeIndexes();
|
||||
wireButtons();
|
||||
});
|
||||
|
||||
rowsWrap.querySelectorAll('.support-field-row').forEach((row, fieldIndex) => {
|
||||
const keyInput = row.querySelector('.option-key');
|
||||
if (!keyInput) return;
|
||||
if (!keyInput.value) {
|
||||
const labelInput = row.querySelector('.option-label');
|
||||
keyInput.value = slugify(labelInput ? labelInput.value : ('option_' + fieldIndex));
|
||||
}
|
||||
});
|
||||
|
||||
normalizeIndexes();
|
||||
wireButtons();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../modules/admin/views/layout.php';
|
||||
81
plugins/support/views/admin/ticket.php
Normal file
81
plugins/support/views/admin/ticket.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Ticket';
|
||||
$ticket = is_array($ticket ?? null) ? $ticket : [];
|
||||
$messages = is_array($messages ?? null) ? $messages : [];
|
||||
$saved = (string)($saved ?? '');
|
||||
$error = (string)($error ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
<div class="badge">Support</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
|
||||
<div>
|
||||
<h1 style="font-size:26px; margin:0;"><?= htmlspecialchars((string)($ticket['ticket_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<p style="color: var(--muted); margin-top:6px;"><?= htmlspecialchars((string)($ticket['subject'] ?? ''), ENT_QUOTES, 'UTF-8') ?></p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<form method="post" action="/admin/support/ticket/delete" onsubmit="return confirm('Delete this ticket and all messages? This cannot be undone.');">
|
||||
<input type="hidden" name="ticket_id" value="<?= (int)($ticket['id'] ?? 0) ?>">
|
||||
<button type="submit" class="btn outline danger">Delete Ticket</button>
|
||||
</form>
|
||||
<a href="/admin/support" class="btn outline">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($saved !== ''): ?>
|
||||
<div style="margin-top:10px; color:#9be7c6; font-size:13px;">Updated.</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div style="margin-top:10px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="admin-card" style="margin-top:12px; padding:12px; display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center;">
|
||||
<div style="font-size:13px; color:var(--muted);">
|
||||
<?= htmlspecialchars((string)($ticket['customer_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?> ·
|
||||
<?= htmlspecialchars((string)($ticket['customer_email'] ?? ''), ENT_QUOTES, 'UTF-8') ?> ·
|
||||
<?= htmlspecialchars((string)($ticket['customer_ip'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<form method="post" action="/admin/support/ticket/status" style="display:flex; gap:8px;">
|
||||
<input type="hidden" name="ticket_id" value="<?= (int)($ticket['id'] ?? 0) ?>">
|
||||
<select class="input" name="status" style="width:150px;">
|
||||
<?php $status = (string)($ticket['status'] ?? 'open'); ?>
|
||||
<option value="open" <?= $status === 'open' ? 'selected' : '' ?>>open</option>
|
||||
<option value="pending" <?= $status === 'pending' ? 'selected' : '' ?>>pending</option>
|
||||
<option value="closed" <?= $status === 'closed' ? 'selected' : '' ?>>closed</option>
|
||||
</select>
|
||||
<button class="btn small" type="submit">Set</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="margin-top:12px; padding:12px;">
|
||||
<div class="label" style="margin-bottom:8px;">Thread</div>
|
||||
<?php if (!$messages): ?>
|
||||
<div style="color:var(--muted); font-size:13px;">No messages yet.</div>
|
||||
<?php else: ?>
|
||||
<div style="display:grid; gap:8px;">
|
||||
<?php foreach ($messages as $msg): ?>
|
||||
<?php $isAdmin = (string)($msg['sender_type'] ?? '') === 'admin'; ?>
|
||||
<div class="admin-card" style="padding:10px; border-color:<?= $isAdmin ? 'rgba(34,242,165,.35)' : 'rgba(255,255,255,.1)' ?>;">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px; font-size:12px; color:var(--muted);">
|
||||
<span><?= $isAdmin ? 'Admin' : 'Customer' ?> · <?= htmlspecialchars((string)($msg['sender_email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span><?= htmlspecialchars((string)($msg['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</div>
|
||||
<div style="margin-top:8px; white-space:pre-wrap; line-height:1.6;"><?= htmlspecialchars((string)($msg['body_text'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/support/ticket/reply" style="margin-top:12px; display:grid; gap:10px;">
|
||||
<input type="hidden" name="ticket_id" value="<?= (int)($ticket['id'] ?? 0) ?>">
|
||||
<div class="label">Reply</div>
|
||||
<textarea class="input" name="body" rows="6" style="resize:vertical;" placeholder="Type reply..."></textarea>
|
||||
<div style="display:flex; justify-content:flex-end;">
|
||||
<button class="btn" type="submit">Send Reply</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../modules/admin/views/layout.php';
|
||||
197
plugins/support/views/site/contact.php
Normal file
197
plugins/support/views/site/contact.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Contact';
|
||||
$error = (string)($error ?? '');
|
||||
$ok = (string)($ok ?? '');
|
||||
$supportTypes = is_array($support_types ?? null) ? $support_types : [];
|
||||
$supportTypes = $supportTypes ?: [['key' => 'general', 'label' => 'General', 'fields' => []]];
|
||||
$supportTypesJson = json_encode($supportTypes, JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card" style="display:grid; gap:14px;">
|
||||
<div class="badge">Support</div>
|
||||
<h1 style="margin:0; font-size:34px;">Contact</h1>
|
||||
<p style="margin:0; color:var(--muted);">Send us a message and we will open a support ticket.</p>
|
||||
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="support-alert support-alert-error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($ok !== ''): ?>
|
||||
<div class="support-alert support-alert-ok"><?= htmlspecialchars($ok, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="/contact" class="support-form">
|
||||
<div class="support-grid-2">
|
||||
<div class="support-field">
|
||||
<label class="label support-label">Name</label>
|
||||
<input class="support-input" name="name" required>
|
||||
</div>
|
||||
<div class="support-field">
|
||||
<label class="label support-label">Email</label>
|
||||
<input class="support-input" type="email" name="email" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="support-field">
|
||||
<label class="label support-label">Support Type</label>
|
||||
<select class="support-input" id="supportType" name="support_type">
|
||||
<?php foreach ($supportTypes as $i => $type): ?>
|
||||
<option value="<?= htmlspecialchars((string)$type['key'], ENT_QUOTES, 'UTF-8') ?>" <?= $i === 0 ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars((string)$type['label'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="supportExtraFields" class="support-extra-fields"></div>
|
||||
|
||||
<div class="support-field">
|
||||
<label class="label support-label">Subject</label>
|
||||
<input class="support-input" name="subject" required>
|
||||
</div>
|
||||
<div class="support-field">
|
||||
<label class="label support-label">Message</label>
|
||||
<textarea class="support-input support-textarea" name="message" rows="7" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="support-actions">
|
||||
<button class="support-btn" type="submit">Create Ticket</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.support-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(8,10,16,0.38);
|
||||
}
|
||||
.support-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.support-label {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(236, 242, 255, 0.78);
|
||||
}
|
||||
.support-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.support-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(7,10,16,0.72);
|
||||
color: #ecf1ff;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
|
||||
}
|
||||
.support-input:focus {
|
||||
border-color: rgba(34,242,165,.55);
|
||||
box-shadow: 0 0 0 2px rgba(34,242,165,.14);
|
||||
background: rgba(9,13,20,0.9);
|
||||
}
|
||||
.support-textarea {
|
||||
min-height: 180px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
padding: 11px 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.support-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.support-extra-fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.support-btn {
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34,242,165,.85);
|
||||
background: linear-gradient(180deg, #2df5ae, #1ddf98);
|
||||
color: #08150f;
|
||||
padding: 0 20px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .18em;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.support-btn:hover { filter: brightness(1.04); }
|
||||
.support-alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.support-alert-error {
|
||||
border: 1px solid rgba(255,120,120,.4);
|
||||
color: #f3b0b0;
|
||||
background: rgba(80,20,20,.2);
|
||||
}
|
||||
.support-alert-ok {
|
||||
border: 1px solid rgba(34,242,165,.4);
|
||||
color: #9be7c6;
|
||||
background: rgba(16,64,52,.3);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.support-grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const supportTypes = <?= $supportTypesJson ?>;
|
||||
const typeSelect = document.getElementById('supportType');
|
||||
const extraWrap = document.getElementById('supportExtraFields');
|
||||
if (!typeSelect || !extraWrap) return;
|
||||
|
||||
const map = {};
|
||||
supportTypes.forEach((type) => {
|
||||
map[type.key] = type;
|
||||
});
|
||||
|
||||
const esc = (v) => String(v || '').replace(/[&<>"']/g, (m) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[m]));
|
||||
|
||||
const sync = () => {
|
||||
const selectedType = map[typeSelect.value] || { fields: [], field_labels: [] };
|
||||
const labels = {};
|
||||
(selectedType.field_labels || []).forEach((row) => {
|
||||
if (!row || !row.key) return;
|
||||
labels[row.key] = row.label || row.key;
|
||||
});
|
||||
const html = (selectedType.fields || []).map((fieldKey) => {
|
||||
const label = labels[fieldKey] || fieldKey;
|
||||
return `
|
||||
<div class="support-field">
|
||||
<label class="label support-label">${esc(label)}</label>
|
||||
<input class="support-input" name="support_extra[${esc(fieldKey)}]" placeholder="${esc(label)}" required>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
extraWrap.innerHTML = html;
|
||||
};
|
||||
typeSelect.addEventListener('change', sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../views/site/layout.php';
|
||||
Reference in New Issue
Block a user