Initial dev export (exclude uploads/runtime)

This commit is contained in:
AudioCore Bot
2026-03-04 20:46:11 +00:00
commit b2afadd539
120 changed files with 20410 additions and 0 deletions

1
plugins/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,540 @@
<?php
declare(strict_types=1);
namespace Plugins\Artists;
use Core\Http\Response;
use Core\Services\Auth;
use Core\Views\View;
use Core\Services\Database;
use PDO;
use Throwable;
class ArtistsController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function index(): Response
{
$db = Database::get();
$page = null;
$artists = [];
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = 'artists' AND is_published = 1 LIMIT 1");
$stmt->execute();
$page = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$listStmt = $db->query("
SELECT id, name, slug, country, avatar_url
FROM ac_artists
WHERE is_active = 1
ORDER BY created_at DESC
");
$artists = $listStmt ? $listStmt->fetchAll(PDO::FETCH_ASSOC) : [];
} catch (Throwable $e) {
}
}
return new Response($this->view->render('site/index.php', [
'title' => (string)($page['title'] ?? 'Artists'),
'content_html' => (string)($page['content_html'] ?? ''),
'artists' => $artists,
]));
}
public function show(): Response
{
$slug = trim((string)($_GET['slug'] ?? ''));
$artist = null;
$artistReleases = [];
if ($slug !== '') {
$db = Database::get();
if ($db instanceof PDO) {
$stmt = $db->prepare("SELECT * FROM ac_artists WHERE slug = :slug AND is_active = 1 LIMIT 1");
$stmt->execute([':slug' => $slug]);
$artist = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
if ($artist) {
try {
$artistIdReady = false;
$probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'");
if ($probe && $probe->fetch(PDO::FETCH_ASSOC)) {
$artistIdReady = true;
}
if ($artistIdReady) {
$relStmt = $db->prepare("
SELECT id, title, slug, release_date, cover_url, artist_name
FROM ac_releases
WHERE is_published = 1
AND (artist_id = :artist_id OR artist_name = :artist_name)
ORDER BY release_date DESC, created_at DESC
LIMIT 2
");
$relStmt->execute([
':artist_id' => (int)($artist['id'] ?? 0),
':artist_name' => (string)($artist['name'] ?? ''),
]);
} else {
$relStmt = $db->prepare("
SELECT id, title, slug, release_date, cover_url, artist_name
FROM ac_releases
WHERE is_published = 1
AND artist_name = :artist_name
ORDER BY release_date DESC, created_at DESC
LIMIT 2
");
$relStmt->execute([
':artist_name' => (string)($artist['name'] ?? ''),
]);
}
$artistReleases = $relStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
$artistReleases = [];
}
}
}
}
return new Response($this->view->render('site/show.php', [
'title' => $artist ? (string)$artist['name'] : 'Artist Profile',
'artist' => $artist,
'artist_releases' => $artistReleases,
]));
}
public function adminIndex(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$tableReady = $this->artistsTableReady();
$socialReady = $this->socialColumnReady();
$artists = [];
$pageId = 0;
$pagePublished = 0;
if ($tableReady) {
$db = Database::get();
if ($db instanceof PDO) {
$stmt = $db->query("SELECT id, name, slug, country, avatar_url, is_active FROM ac_artists ORDER BY created_at DESC");
$artists = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
$pageStmt = $db->prepare("SELECT id, is_published FROM ac_pages WHERE slug = 'artists' LIMIT 1");
$pageStmt->execute();
$pageRow = $pageStmt->fetch(PDO::FETCH_ASSOC);
if ($pageRow) {
$pageId = (int)($pageRow['id'] ?? 0);
$pagePublished = (int)($pageRow['is_published'] ?? 0);
}
}
}
return new Response($this->view->render('admin/index.php', [
'title' => 'Artists',
'table_ready' => $tableReady,
'social_ready' => $socialReady,
'artists' => $artists,
'page_id' => $pageId,
'page_published' => $pagePublished,
]));
}
public function adminNew(): Response
{
return $this->adminEdit(0);
}
public function adminEdit(int $id = 0): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$artist = [
'id' => 0,
'name' => '',
'slug' => '',
'bio' => '',
'credits' => '',
'country' => '',
'avatar_url' => '',
'social_links' => '',
'is_active' => 1,
];
if ($id > 0) {
$db = Database::get();
if ($db instanceof PDO) {
$stmt = $db->prepare("SELECT * FROM ac_artists WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$artist = array_merge($artist, $row);
}
}
}
if (!empty($_GET['avatar_url']) && $artist['avatar_url'] === '') {
$artist['avatar_url'] = (string)$_GET['avatar_url'];
}
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Artist' : 'New Artist',
'artist' => $artist,
'error' => '',
]));
}
public function adminSave(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$id = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$slug = trim((string)($_POST['slug'] ?? ''));
$bio = trim((string)($_POST['bio'] ?? ''));
$credits = trim((string)($_POST['credits'] ?? ''));
$country = trim((string)($_POST['country'] ?? ''));
$avatarUrl = trim((string)($_POST['avatar_url'] ?? ''));
$socialLinks = [
'website' => $this->normalizeUrl(trim((string)($_POST['social_website'] ?? ''))),
'instagram' => $this->normalizeUrl(trim((string)($_POST['social_instagram'] ?? ''))),
'soundcloud' => $this->normalizeUrl(trim((string)($_POST['social_soundcloud'] ?? ''))),
'spotify' => $this->normalizeUrl(trim((string)($_POST['social_spotify'] ?? ''))),
'youtube' => $this->normalizeUrl(trim((string)($_POST['social_youtube'] ?? ''))),
'tiktok' => $this->normalizeUrl(trim((string)($_POST['social_tiktok'] ?? ''))),
'bandcamp' => $this->normalizeUrl(trim((string)($_POST['social_bandcamp'] ?? ''))),
'beatport' => $this->normalizeUrl(trim((string)($_POST['social_beatport'] ?? ''))),
'facebook' => $this->normalizeUrl(trim((string)($_POST['social_facebook'] ?? ''))),
'x' => $this->normalizeUrl(trim((string)($_POST['social_x'] ?? ''))),
];
$socialLinks = array_filter($socialLinks, static fn($value) => $value !== '');
$socialJson = $socialLinks ? json_encode($socialLinks, JSON_UNESCAPED_SLASHES) : null;
$socialReady = $this->socialColumnReady();
$creditsReady = $this->creditsColumnReady();
$isActive = isset($_POST['is_active']) ? 1 : 0;
if ($name === '') {
return $this->saveError($id, 'Name is required.');
}
$slug = $slug !== '' ? $this->slugify($slug) : $this->slugify($name);
$db = Database::get();
if (!$db instanceof PDO) {
return $this->saveError($id, 'Database unavailable.');
}
$dupStmt = $id > 0
? $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug AND id != :id LIMIT 1")
: $db->prepare("SELECT id FROM ac_artists WHERE slug = :slug LIMIT 1");
$params = $id > 0 ? [':slug' => $slug, ':id' => $id] : [':slug' => $slug];
$dupStmt->execute($params);
if ($dupStmt->fetch()) {
return $this->saveError($id, 'Slug already exists.');
}
try {
if ($id > 0) {
$stmt = $db->prepare("
UPDATE ac_artists
SET name = :name, slug = :slug, bio = :bio" . ($creditsReady ? ", credits = :credits" : "") . ", country = :country,
avatar_url = :avatar_url" . ($socialReady ? ", social_links = :social_links" : "") . ",
is_active = :is_active
WHERE id = :id
");
$params = [
':name' => $name,
':slug' => $slug,
':bio' => $bio !== '' ? $bio : null,
':country' => $country !== '' ? $country : null,
':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null,
':is_active' => $isActive,
':id' => $id,
];
if ($creditsReady) {
$params[':credits'] = $credits !== '' ? $credits : null;
}
if ($socialReady) {
$params[':social_links'] = $socialJson;
}
$stmt->execute($params);
} else {
$stmt = $db->prepare("
INSERT INTO ac_artists (name, slug, bio" . ($creditsReady ? ", credits" : "") . ", country, avatar_url" . ($socialReady ? ", social_links" : "") . ", is_active)
VALUES (:name, :slug, :bio" . ($creditsReady ? ", :credits" : "") . ", :country, :avatar_url" . ($socialReady ? ", :social_links" : "") . ", :is_active)
");
$params = [
':name' => $name,
':slug' => $slug,
':bio' => $bio !== '' ? $bio : null,
':country' => $country !== '' ? $country : null,
':avatar_url' => $avatarUrl !== '' ? $avatarUrl : null,
':is_active' => $isActive,
];
if ($creditsReady) {
$params[':credits'] = $credits !== '' ? $credits : null;
}
if ($socialReady) {
$params[':social_links'] = $socialJson;
}
$stmt->execute($params);
}
} catch (Throwable $e) {
error_log('AC artists save error: ' . $e->getMessage());
return $this->saveError($id, 'Unable to save artist. Check table columns and input.');
}
return new Response('', 302, ['Location' => '/admin/artists']);
}
public function adminDelete(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$id = (int)($_POST['id'] ?? 0);
$db = Database::get();
if ($db instanceof PDO && $id > 0) {
$stmt = $db->prepare("DELETE FROM ac_artists WHERE id = :id");
$stmt->execute([':id' => $id]);
}
return new Response('', 302, ['Location' => '/admin/artists']);
}
public function adminUpload(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$file = $_FILES['artist_avatar'] ?? null;
$artistId = (int)($_POST['artist_id'] ?? 0);
if (!$file || !isset($file['tmp_name'])) {
return $this->uploadRedirect($artistId);
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->uploadRedirect($artistId, $this->uploadErrorMessage((int)$file['error']));
}
$tmp = (string)$file['tmp_name'];
if ($tmp === '' || !is_uploaded_file($tmp)) {
return $this->uploadRedirect($artistId);
}
$info = getimagesize($tmp);
if ($info === false) {
return $this->uploadRedirect($artistId, 'Avatar must be an image.');
}
$ext = image_type_to_extension($info[2], false);
$allowed = ['jpg', 'jpeg', 'png', 'webp'];
if (!in_array($ext, $allowed, true)) {
return $this->uploadRedirect($artistId, 'Avatar must be JPG, PNG, or WEBP.');
}
$uploadDir = __DIR__ . '/../../uploads/media';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
return $this->uploadRedirect($artistId, 'Upload directory is not writable.');
}
if (!is_writable($uploadDir)) {
return $this->uploadRedirect($artistId, 'Upload directory is not writable.');
}
$baseName = preg_replace('~[^a-z0-9]+~', '-', strtolower((string)$file['name'])) ?? 'avatar';
$baseName = trim($baseName, '-');
$fileName = ($baseName !== '' ? $baseName : 'avatar') . '-' . date('YmdHis') . '.' . $ext;
$dest = $uploadDir . '/' . $fileName;
if (!move_uploaded_file($tmp, $dest)) {
return $this->uploadRedirect($artistId, 'Upload failed.');
}
$fileUrl = '/uploads/media/' . $fileName;
$fileType = (string)($file['type'] ?? '');
$fileSize = (int)($file['size'] ?? 0);
$db = Database::get();
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("
INSERT INTO ac_media (file_name, file_url, file_type, file_size, folder_id)
VALUES (:name, :url, :type, :size, NULL)
");
$stmt->execute([
':name' => (string)$file['name'],
':url' => $fileUrl,
':type' => $fileType,
':size' => $fileSize,
]);
} catch (Throwable $e) {
}
}
if ($artistId > 0 && $db instanceof PDO) {
$stmt = $db->prepare("UPDATE ac_artists SET avatar_url = :url WHERE id = :id");
$stmt->execute([':url' => $fileUrl, ':id' => $artistId]);
}
return $this->uploadRedirect($artistId, '', $fileUrl);
}
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) {
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_artists (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL UNIQUE,
bio MEDIUMTEXT NULL,
credits MEDIUMTEXT NULL,
country VARCHAR(120) NULL,
avatar_url VARCHAR(255) NULL,
social_links TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$db->exec("ALTER TABLE ac_artists ADD COLUMN credits MEDIUMTEXT NULL");
$db->exec("ALTER TABLE ac_artists ADD COLUMN social_links TEXT NULL");
} catch (Throwable $e) {
}
}
return new Response('', 302, ['Location' => '/admin/artists']);
}
private function artistsTableReady(): bool
{
$db = Database::get();
if (!$db instanceof PDO) {
return false;
}
try {
$stmt = $db->query("SELECT 1 FROM ac_artists LIMIT 1");
return $stmt !== false;
} catch (Throwable $e) {
return false;
}
}
private function socialColumnReady(): bool
{
$db = Database::get();
if (!$db instanceof PDO) {
return false;
}
try {
$stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'social_links'");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
return (bool)$row;
} catch (Throwable $e) {
return false;
}
}
private function creditsColumnReady(): bool
{
$db = Database::get();
if (!$db instanceof PDO) {
return false;
}
try {
$stmt = $db->query("SHOW COLUMNS FROM ac_artists LIKE 'credits'");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
return (bool)$row;
} catch (Throwable $e) {
return false;
}
}
private function saveError(int $id, string $message): Response
{
return new Response($this->view->render('admin/edit.php', [
'title' => $id > 0 ? 'Edit Artist' : 'New Artist',
'artist' => [
'id' => $id,
'name' => (string)($_POST['name'] ?? ''),
'slug' => (string)($_POST['slug'] ?? ''),
'bio' => (string)($_POST['bio'] ?? ''),
'credits' => (string)($_POST['credits'] ?? ''),
'country' => (string)($_POST['country'] ?? ''),
'avatar_url' => (string)($_POST['avatar_url'] ?? ''),
'social_links' => '',
'is_active' => isset($_POST['is_active']) ? 1 : 0,
],
'error' => $message,
]));
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'artist';
}
private function uploadRedirect(int $artistId, string $error = '', string $url = ''): Response
{
if ($artistId > 0) {
$target = '/admin/artists/edit?id=' . $artistId;
} else {
$target = '/admin/artists/new';
}
if ($url !== '') {
$target .= '&avatar_url=' . rawurlencode($url);
}
if ($error !== '') {
$target .= '&upload_error=' . rawurlencode($error);
}
return new Response('', 302, ['Location' => $target]);
}
private function uploadErrorMessage(int $code): string
{
$max = (string)ini_get('upload_max_filesize');
$map = [
UPLOAD_ERR_INI_SIZE => "File exceeds upload_max_filesize ({$max}).",
UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Missing temp upload directory.',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
UPLOAD_ERR_EXTENSION => 'Upload stopped by a PHP extension.',
UPLOAD_ERR_NO_FILE => 'No file uploaded.',
];
return $map[$code] ?? 'Upload failed.';
}
private function normalizeUrl(string $value): string
{
if ($value === '') {
return '';
}
if (preg_match('~^(https?://)~i', $value)) {
return $value;
}
return 'https://' . $value;
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "Artists",
"version": "0.1.0",
"description": "Public artist listings and profiles.",
"author": "AudioCore",
"admin_nav": {
"label": "Artists",
"url": "/admin/artists",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-user"
},
"pages": [
{
"slug": "artists",
"title": "Artists",
"content_html": "<section class=\"card\"><div class=\"badge\">Artists</div><h1 style=\"margin-top:16px; font-size:28px;\">Artists</h1><p style=\"color:var(--muted);\">Add your artist roster here.</p></section>"
}
],
"entry": "plugin.php",
"default_enabled": false
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Plugins\Artists\ArtistsController;
require_once __DIR__ . '/ArtistsController.php';
return function (Router $router): void {
$controller = new ArtistsController();
$router->get('/artists', [$controller, 'index']);
$router->get('/artist', [$controller, 'show']);
$router->get('/admin/artists', [$controller, 'adminIndex']);
$router->post('/admin/artists/install', [$controller, 'adminInstall']);
$router->get('/admin/artists/new', [$controller, 'adminNew']);
$router->get('/admin/artists/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
return $controller->adminEdit($id);
});
$router->post('/admin/artists/upload', [$controller, 'adminUpload']);
$router->post('/admin/artists/save', [$controller, 'adminSave']);
$router->post('/admin/artists/delete', [$controller, 'adminDelete']);
};

View File

@@ -0,0 +1,129 @@
<?php
$pageTitle = $title ?? 'Edit Artist';
$artist = $artist ?? [];
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$socialLinks = [];
if (!empty($artist['social_links'])) {
$decoded = json_decode((string)$artist['social_links'], true);
if (is_array($decoded)) {
$socialLinks = $decoded;
}
}
ob_start();
?>
<section class="admin-card">
<div class="badge">Artists</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;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Create or update an artist profile.</p>
</div>
<a href="/admin/artists" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/artists/upload" enctype="multipart/form-data" id="artistAvatarUpload" style="margin-top:18px;">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload avatar</div>
<input type="hidden" name="artist_id" value="<?= (int)($artist['id'] ?? 0) ?>">
<label for="artistAvatarFile" id="artistAvatarDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="artistAvatarFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="artistAvatarFile" name="artist_avatar" accept="image/*" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small">Upload</button>
</div>
</div>
</form>
<form method="post" action="/admin/artists/save" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($artist['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Name</label>
<input class="input" name="name" value="<?= htmlspecialchars((string)($artist['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist name">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($artist['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="artist-name">
<label class="label">Country</label>
<input class="input" name="country" value="<?= htmlspecialchars((string)($artist['country'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="UK">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Avatar URL</label>
<button type="button" class="btn outline small" data-media-picker="artist_avatar_url" data-media-picker-mode="url">Pick from Media</button>
</div>
<input class="input" id="artist_avatar_url" name="avatar_url" value="<?= htmlspecialchars((string)($artist['avatar_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<label class="label">Bio</label>
<textarea class="input" name="bio" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($artist['bio'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Artist Credits</label>
<textarea class="input" name="credits" rows="4" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;" placeholder="Written by..., Vocals by..., Produced by..."><?= htmlspecialchars((string)($artist['credits'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Social Links</div>
<div style="margin-top:10px; display:grid; gap:10px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
<input class="input" name="social_website" placeholder="Website URL" value="<?= htmlspecialchars((string)($socialLinks['website'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_instagram" placeholder="Instagram URL" value="<?= htmlspecialchars((string)($socialLinks['instagram'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_soundcloud" placeholder="SoundCloud URL" value="<?= htmlspecialchars((string)($socialLinks['soundcloud'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_spotify" placeholder="Spotify URL" value="<?= htmlspecialchars((string)($socialLinks['spotify'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_youtube" placeholder="YouTube URL" value="<?= htmlspecialchars((string)($socialLinks['youtube'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_tiktok" placeholder="TikTok URL" value="<?= htmlspecialchars((string)($socialLinks['tiktok'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_bandcamp" placeholder="Bandcamp URL" value="<?= htmlspecialchars((string)($socialLinks['bandcamp'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_beatport" placeholder="Beatport URL" value="<?= htmlspecialchars((string)($socialLinks['beatport'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_facebook" placeholder="Facebook URL" value="<?= htmlspecialchars((string)($socialLinks['facebook'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<input class="input" name="social_x" placeholder="X / Twitter URL" value="<?= htmlspecialchars((string)($socialLinks['x'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_active" value="1" <?= ((int)($artist['is_active'] ?? 1) === 1) ? 'checked' : '' ?>>
Active
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save artist</button>
</div>
</form>
</section>
<script>
(function () {
const dropzone = document.getElementById('artistAvatarDropzone');
const fileInput = document.getElementById('artistAvatarFile');
const fileName = document.getElementById('artistAvatarFileName');
if (!dropzone || !fileInput || !fileName) {
return;
}
dropzone.addEventListener('dragover', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'var(--accent)';
});
dropzone.addEventListener('dragleave', () => {
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
});
dropzone.addEventListener('drop', (event) => {
event.preventDefault();
dropzone.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
fileInput.files = event.dataTransfer.files;
fileName.textContent = event.dataTransfer.files[0].name;
}
});
fileInput.addEventListener('change', () => {
fileName.textContent = fileInput.files.length ? fileInput.files[0].name : 'No file selected';
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,97 @@
<?php
$pageTitle = 'Artists';
$tableReady = $table_ready ?? false;
$artists = $artists ?? [];
$pageId = (int)($page_id ?? 0);
$pagePublished = (int)($page_published ?? 0);
$socialReady = (bool)($social_ready ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Artists</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;">Artists</h1>
<p style="color: var(--muted); margin-top:6px;">Artists plugin admin placeholder.</p>
</div>
<a href="/admin/artists/new" class="btn">New Artist</a>
</div>
<?php if (!$tableReady): ?>
<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;">Database not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create the artists table before adding records.</div>
</div>
<form method="post" action="/admin/artists/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php else: ?>
<?php if (!$socialReady): ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Social links not enabled</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">Click create tables to add the social links column.</div>
</div>
<form method="post" action="/admin/artists/install">
<button type="submit" class="btn small">Update Table</button>
</form>
</div>
<?php endif; ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Artists page</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">
Slug: <code>artists</code>
<?php if ($pageId > 0): ?>
· Status: <?= $pagePublished === 1 ? 'Published' : 'Draft' ?>
<?php else: ?>
· Not created
<?php endif; ?>
</div>
</div>
<?php if ($pageId > 0): ?>
<a href="/admin/pages/edit?id=<?= $pageId ?>" class="btn outline small">Edit Page Content</a>
<?php else: ?>
<span class="pill">Re-enable plugin to create</span>
<?php endif; ?>
</div>
<?php if (!$artists): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No artists yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($artists as $artist): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:44px; height:44px; border-radius:12px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted);">N/A</span>
<?php endif; ?>
</div>
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)$artist['slug'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<?php if ((int)$artist['is_active'] !== 1): ?>
<span class="pill">Inactive</span>
<?php endif; ?>
<a href="/admin/artists/edit?id=<?= (int)$artist['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/artists/delete" onsubmit="return confirm('Delete this artist?');">
<input type="hidden" name="id" value="<?= (int)$artist['id'] ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,236 @@
<?php
$pageTitle = $title ?? 'Artists';
$artists = $artists ?? [];
function ac_country_code(string $country): string {
$code = strtoupper(trim($country));
if ($code === '') {
return '';
}
if ($code === 'UK') {
$code = 'GB';
}
if (!preg_match('/^[A-Z]{2}$/', $code)) {
return '';
}
return strtolower($code);
}
ob_start();
?>
<section class="card" style="display:grid; gap:18px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div class="badge">Artists</div>
<div class="view-toggle" role="group" aria-label="View toggle">
<button type="button" class="view-btn active" id="artistGridBtn" aria-label="Grid view">
<i class="fa-duotone fa-grid-round-2"></i>
<span>Grid</span>
</button>
<button type="button" class="view-btn" id="artistListBtn" aria-label="List view">
<i class="fa-duotone fa-list-ol"></i>
<span>List</span>
</button>
</div>
</div>
<?php if (!$artists): ?>
<div style="color:var(--muted); font-size:14px;">No artists published yet.</div>
<?php else: ?>
<div id="artistView" class="artist-grid">
<?php foreach ($artists as $artist): ?>
<a class="artist-card" href="/artist?slug=<?= htmlspecialchars((string)$artist['slug'], ENT_QUOTES, 'UTF-8') ?>">
<div class="artist-avatar">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<div class="artist-avatar-placeholder">AC</div>
<?php endif; ?>
</div>
<div class="artist-info">
<div class="artist-name">
<?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php $flag = ac_country_code((string)($artist['country'] ?? '')); ?>
<?php if ($flag !== ''): ?>
<div class="artist-meta">
<span class="fi fi-<?= htmlspecialchars($flag, ENT_QUOTES, 'UTF-8') ?>"></span>
</div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<style>
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.artist-list {
display: grid;
gap: 10px;
}
.artist-card {
border-radius: 18px;
padding: 6px;
background: transparent;
display: grid;
gap: 10px;
color: inherit;
}
.artist-card:hover .artist-avatar {
box-shadow: 0 12px 30px rgba(0,0,0,0.35);
transform: scale(1.02);
}
.artist-card .artist-avatar {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.artist-avatar {
width: 140px;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: rgba(255,255,255,0.06);
display: grid;
place-items: center;
}
.artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-avatar-placeholder {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.2em;
}
.artist-info {
display: none;
gap: 6px;
}
.artist-name {
font-weight: 600;
font-size: 15px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.artist-meta {
font-size: 12px;
color: var(--muted);
display: inline-flex;
gap: 8px;
align-items: center;
}
.view-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px;
border-radius: 999px;
background: rgba(10,10,12,0.6);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04);
}
.view-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid transparent;
background: transparent;
color: rgba(255,255,255,0.7);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
cursor: pointer;
}
.view-btn i {
font-size: 12px;
}
.view-btn.active {
background: linear-gradient(135deg, rgba(34,242,165,0.2), rgba(34,167,255,0.12));
border-color: rgba(34,242,165,0.35);
color: #f3fff9;
box-shadow: 0 6px 16px rgba(34,242,165,0.2);
}
.view-btn:not(.active):hover {
color: rgba(255,255,255,0.95);
background: rgba(255,255,255,0.06);
}
.flag {
font-size: 14px;
}
.artist-list .artist-card {
grid-template-columns: 64px 1fr;
align-items: center;
gap: 16px;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(12,12,14,0.5);
}
.artist-list .artist-avatar {
width: 64px;
height: 64px;
}
.artist-list .artist-info {
display: grid;
}
@media (max-width: 700px) {
.artist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.artist-card {
padding: 4px;
}
.artist-avatar {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
}
.view-btn span {
display: none;
}
.view-btn {
width: 34px;
height: 34px;
padding: 0;
justify-content: center;
}
.artist-list .artist-card {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
</style>
<script>
(function () {
const gridBtn = document.getElementById('artistGridBtn');
const listBtn = document.getElementById('artistListBtn');
const view = document.getElementById('artistView');
if (!gridBtn || !listBtn || !view) {
return;
}
gridBtn.addEventListener('click', () => {
view.classList.add('artist-grid');
view.classList.remove('artist-list');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
});
listBtn.addEventListener('click', () => {
view.classList.add('artist-list');
view.classList.remove('artist-grid');
listBtn.classList.add('active');
gridBtn.classList.remove('active');
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,239 @@
<?php
use Core\Services\Settings;
$pageTitle = $title ?? 'Artist';
$artist = $artist ?? null;
$proUrl = Settings::get('fontawesome_pro_url', '');
$faUrl = $proUrl !== '' ? $proUrl : Settings::get('fontawesome_url', '');
$hasPro = $proUrl !== '';
$hasIcons = $faUrl !== '';
$artistReleases = is_array($artist_releases ?? null) ? $artist_releases : [];
$socialLinks = [];
if ($artist && !empty($artist['social_links'])) {
$decoded = json_decode((string)$artist['social_links'], true);
if (is_array($decoded)) {
$socialLinks = $decoded;
}
}
$iconMap = [
'website' => ['label' => 'Website', 'icon' => 'fa-duotone fa-globe-pointer'],
'instagram' => ['label' => 'Instagram', 'icon' => 'fa-brands fa-instagram'],
'soundcloud' => ['label' => 'SoundCloud', 'icon' => 'fa-brands fa-soundcloud'],
'spotify' => ['label' => 'Spotify', 'icon' => 'fa-brands fa-spotify'],
'youtube' => ['label' => 'YouTube', 'icon' => 'fa-brands fa-youtube'],
'tiktok' => ['label' => 'TikTok', 'icon' => 'fa-brands fa-tiktok'],
'bandcamp' => ['label' => 'Bandcamp', 'icon' => 'fa-brands fa-bandcamp'],
'beatport' => ['label' => 'Beatport', 'icon' => 'fa-solid fa-music'],
'facebook' => ['label' => 'Facebook', 'icon' => 'fa-brands fa-facebook'],
'x' => ['label' => 'X', 'icon' => 'fa-brands fa-x-twitter'],
];
function ac_normalize_url(string $value): string {
if ($value === '') {
return '';
}
if (preg_match('~^(https?://)~i', $value)) {
return $value;
}
return 'https://' . $value;
}
ob_start();
?>
<section class="card" style="display:grid; gap:16px;">
<div class="badge">Artist</div>
<?php if (!$artist): ?>
<h1 style="margin:0; font-size:28px;">Artist not found</h1>
<p style="color:var(--muted);">This profile is unavailable.</p>
<?php else: ?>
<div style="display:grid; gap:18px;">
<div style="display:flex; align-items:center; gap:20px;">
<div style="width:160px; height:160px; border-radius:24px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($artist['avatar_url'])): ?>
<img src="<?= htmlspecialchars((string)$artist['avatar_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted); letter-spacing:0.2em;">AC</span>
<?php endif; ?>
</div>
<div>
<div class="badge">Profile</div>
<h1 style="margin:6px 0 0; font-size:34px;"><?= htmlspecialchars((string)$artist['name'], ENT_QUOTES, 'UTF-8') ?></h1>
<?php if (!empty($artist['country'])): ?>
<?php
$code = strtolower((string)$artist['country']);
if ($code === 'uk') { $code = 'gb'; }
?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">
<span class="fi fi-<?= htmlspecialchars($code, ENT_QUOTES, 'UTF-8') ?>"></span>
</div>
<?php endif; ?>
</div>
</div>
<?php if (!empty($artist['bio'])): ?>
<div style="color:var(--muted); line-height:1.7;">
<?= nl2br(htmlspecialchars((string)$artist['bio'], ENT_QUOTES, 'UTF-8')) ?>
</div>
<?php endif; ?>
<?php if (!empty($artist['credits'])): ?>
<div style="padding:14px; border-radius:18px; border:1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.25);">
<div class="badge">Credits</div>
<div style="margin-top:8px; color:var(--muted); line-height:1.7;">
<?= nl2br(htmlspecialchars((string)$artist['credits'], ENT_QUOTES, 'UTF-8')) ?>
</div>
</div>
<?php endif; ?>
<?php if ($socialLinks): ?>
<div style="display:flex; flex-wrap:wrap; gap:10px;">
<?php foreach ($socialLinks as $key => $url): ?>
<?php
$meta = $iconMap[$key] ?? ['label' => ucfirst($key), 'icon' => 'fa-solid fa-link'];
$label = $meta['label'];
$icon = $meta['icon'];
$normalized = ac_normalize_url((string)$url);
$safeUrl = htmlspecialchars($normalized, ENT_QUOTES, 'UTF-8');
?>
<?php if ($hasIcons && $icon !== ''): ?>
<a class="pill social-icon" href="<?= $safeUrl ?>" target="_blank" rel="noopener" aria-label="<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>">
<i class="<?= htmlspecialchars($icon, ENT_QUOTES, 'UTF-8') ?>"></i>
</a>
<?php else: ?>
<a class="pill" href="<?= $safeUrl ?>" target="_blank" rel="noopener">
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($artistReleases): ?>
<div class="artist-release-panel">
<div class="artist-release-panel-head">
<div class="badge">Latest Releases</div>
<a href="/releases?artist=<?= urlencode((string)($artist['name'] ?? '')) ?>" class="badge artist-view-all-link">View all</a>
</div>
<div class="artist-release-grid">
<?php foreach ($artistReleases as $release): ?>
<a class="artist-release-card" href="/release?slug=<?= htmlspecialchars((string)($release['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="artist-release-cover">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<span>AC</span>
<?php endif; ?>
</div>
<div class="artist-release-meta">
<div class="artist-release-title"><?= htmlspecialchars((string)($release['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($release['release_date'])): ?>
<div class="artist-release-date"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<style>
.social-icon {
color: var(--text);
}
.social-icon i {
font-size: 14px;
}
.artist-release-panel {
display: grid;
gap: 12px;
margin-top: 6px;
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.24);
}
.artist-release-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.artist-view-all-link {
text-decoration: none;
color: rgba(255,255,255,0.72);
transition: color .18s ease;
}
.artist-view-all-link:hover {
color: #ffffff;
}
.artist-release-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.artist-release-card {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.22);
padding: 8px;
transition: transform .18s ease, border-color .18s ease, background .18s ease;
}
.artist-release-card:hover {
transform: translateY(-2px);
border-color: rgba(255,255,255,0.18);
background: rgba(0,0,0,0.28);
}
.artist-release-cover {
width: 58px;
height: 58px;
flex: 0 0 58px;
border-radius: 9px;
overflow: hidden;
background: rgba(255,255,255,0.06);
display: grid;
place-items: center;
color: var(--muted);
font-size: 12px;
letter-spacing: .14em;
}
.artist-release-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-release-title {
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.artist-release-date {
margin-top: 4px;
font-size: 10px;
color: var(--muted);
}
.artist-release-meta {
min-width: 0;
}
@media (max-width: 900px) {
.artist-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.artist-release-grid {
grid-template-columns: 1fr;
}
.artist-release-card {
padding: 7px;
}
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "Releases",
"version": "0.1.0",
"description": "Release listings and detail pages.",
"author": "AudioCore",
"admin_nav": {
"label": "Releases",
"url": "/admin/releases",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-compact-disc"
},
"pages": [
{
"slug": "releases",
"title": "Releases",
"content_html": "<section class=\"card\"><div class=\"badge\">Releases</div><h1 style=\"margin-top:16px; font-size:28px;\">Releases</h1><p style=\"color:var(--muted);\">Latest drops from the label.</p></section>"
}
],
"entry": "plugin.php",
"default_enabled": false
}

109
plugins/releases/plugin.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Database;
use Core\Services\Shortcodes;
use Plugins\Releases\ReleasesController;
require_once __DIR__ . '/ReleasesController.php';
Shortcodes::register('releases', static function (array $attrs = []): string {
$limit = max(1, min(20, (int)($attrs['limit'] ?? 8)));
$db = Database::get();
if (!($db instanceof \PDO)) {
return '';
}
try {
$artistJoinReady = false;
try {
$probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'");
$artistJoinReady = (bool)($probe && $probe->fetch(\PDO::FETCH_ASSOC));
} catch (\Throwable $e) {
$artistJoinReady = false;
}
if ($artistJoinReady) {
$stmt = $db->prepare("
SELECT r.title, r.slug, r.release_date, r.cover_url, COALESCE(r.artist_name, a.name) AS artist_name
FROM ac_releases r
LEFT JOIN ac_artists a ON a.id = r.artist_id
WHERE r.is_published = 1
ORDER BY r.release_date DESC, r.created_at DESC
LIMIT :limit
");
} else {
$stmt = $db->prepare("
SELECT title, slug, release_date, cover_url, artist_name
FROM ac_releases
WHERE is_published = 1
ORDER BY release_date DESC, created_at DESC
LIMIT :limit
");
}
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {
return '';
}
if (!$rows) {
return '<div class="ac-shortcode-empty">No releases published yet.</div>';
}
$cards = '';
foreach ($rows as $row) {
$title = htmlspecialchars((string)($row['title'] ?? ''), ENT_QUOTES, 'UTF-8');
$slug = rawurlencode((string)($row['slug'] ?? ''));
$artist = htmlspecialchars(trim((string)($row['artist_name'] ?? '')), ENT_QUOTES, 'UTF-8');
$date = htmlspecialchars((string)($row['release_date'] ?? ''), ENT_QUOTES, 'UTF-8');
$cover = trim((string)($row['cover_url'] ?? ''));
$coverHtml = $cover !== ''
? '<img src="' . htmlspecialchars($cover, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
: '<div class="ac-shortcode-cover-fallback">AC</div>';
$cards .= '<a class="ac-shortcode-release-card" href="/release?slug=' . $slug . '">'
. '<div class="ac-shortcode-release-cover">' . $coverHtml . '</div>'
. '<div class="ac-shortcode-release-meta">'
. '<div class="ac-shortcode-release-title">' . $title . '</div>'
. ($artist !== '' ? '<div class="ac-shortcode-release-artist">' . $artist . '</div>' : '')
. ($date !== '' ? '<div class="ac-shortcode-release-date">' . $date . '</div>' : '')
. '</div>'
. '</a>';
}
return '<section class="ac-shortcode-releases"><div class="ac-shortcode-release-grid">' . $cards . '</div></section>';
});
return function (Router $router): void {
$controller = new ReleasesController();
$router->get('/releases', [$controller, 'index']);
$router->get('/release', [$controller, 'show']);
$router->get('/admin/releases', [$controller, 'adminIndex']);
$router->post('/admin/releases/install', [$controller, 'adminInstall']);
$router->get('/admin/releases/new', [$controller, 'adminNew']);
$router->get('/admin/releases/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
return $controller->adminEdit($id);
});
$router->get('/admin/releases/tracks', function () use ($controller): Core\Http\Response {
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTracks($releaseId);
});
$router->get('/admin/releases/tracks/new', function () use ($controller): Core\Http\Response {
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTrackEdit(0, $releaseId);
});
$router->get('/admin/releases/tracks/edit', function () use ($controller): Core\Http\Response {
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$releaseId = isset($_GET['release_id']) ? (int)$_GET['release_id'] : 0;
return $controller->adminTrackEdit($id, $releaseId);
});
$router->post('/admin/releases/save', [$controller, 'adminSave']);
$router->post('/admin/releases/delete', [$controller, 'adminDelete']);
$router->post('/admin/releases/upload', [$controller, 'adminUpload']);
$router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']);
$router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']);
$router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']);
};

View File

@@ -0,0 +1,168 @@
<?php
$pageTitle = $title ?? 'Edit Release';
$release = $release ?? [];
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</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;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">Create or update a release.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<?php if ((int)($release['id'] ?? 0) > 0): ?>
<a href="/admin/releases/tracks?release_id=<?= (int)$release['id'] ?>" class="btn outline">Manage Tracks</a>
<?php endif; ?>
<a href="/admin/releases" class="btn outline">Back</a>
</div>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/releases/save" enctype="multipart/form-data" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($release['id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($release['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Release title">
<label class="label">Artist</label>
<input class="input" name="artist_name" value="<?= htmlspecialchars((string)($release['artist_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist name">
<label class="label">Slug</label>
<input class="input" name="slug" value="<?= htmlspecialchars((string)($release['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="release-title">
<label class="label">Release Date</label>
<input class="input" type="date" name="release_date" value="<?= htmlspecialchars((string)($release['release_date'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label class="label">Catalog Number</label>
<input class="input" name="catalog_no" value="<?= htmlspecialchars((string)($release['catalog_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="CAT-001">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload cover</div>
<input type="hidden" name="release_id" value="<?= (int)($release['id'] ?? 0) ?>">
<label for="releaseCoverFile" id="releaseCoverDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="releaseCoverFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="releaseCoverFile" name="release_cover" accept="image/*" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/upload" formmethod="post" formenctype="multipart/form-data" name="upload_type" value="cover">Upload</button>
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<label class="label" style="margin:0;">Cover URL</label>
<button type="button" class="btn outline small" data-media-picker="release_cover_url" data-media-picker-mode="url">Pick from Media</button>
</div>
<input class="input" id="release_cover_url" name="cover_url" value="<?= htmlspecialchars((string)($release['cover_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload sample (MP3)</div>
<input type="hidden" name="release_id" value="<?= (int)($release['id'] ?? 0) ?>">
<label for="releaseSampleFile" id="releaseSampleDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="releaseSampleFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="releaseSampleFile" name="release_sample" accept="audio/mpeg" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/upload" formmethod="post" formenctype="multipart/form-data" name="upload_type" value="sample">Upload</button>
</div>
</div>
<label class="label">Sample URL (MP3)</label>
<input class="input" name="sample_url" value="<?= htmlspecialchars((string)($release['sample_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<label class="label">Description</label>
<textarea class="input" name="description" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($release['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Release Credits</label>
<textarea class="input" name="credits" rows="4" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;" placeholder="Written by..., Produced by..., Mastered by..."><?= htmlspecialchars((string)($release['credits'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<?php if ($storePluginEnabled): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label" style="margin-bottom:10px;">Store Options</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_enabled" value="1" <?= ((int)($release['store_enabled'] ?? 0) === 1) ? 'checked' : '' ?>>
Enable release purchase
</label>
<div style="display:grid; grid-template-columns:1fr 120px; gap:10px; margin-top:10px;">
<div>
<label class="label">Bundle Price</label>
<input class="input" name="bundle_price" value="<?= htmlspecialchars((string)($release['bundle_price'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="3.99">
</div>
<div>
<label class="label">Currency</label>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($release['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
</div>
</div>
<label class="label" style="margin-top:10px;">Button Label (optional)</label>
<input class="input" name="purchase_label" value="<?= htmlspecialchars((string)($release['purchase_label'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Buy Release">
</div>
<?php endif; ?>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="is_published" value="1" <?= ((int)($release['is_published'] ?? 1) === 1) ? 'checked' : '' ?>>
Published
</label>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save release</button>
</div>
</form>
</section>
<script>
(function () {
const coverDrop = document.getElementById('releaseCoverDropzone');
const coverFile = document.getElementById('releaseCoverFile');
const coverName = document.getElementById('releaseCoverFileName');
if (coverDrop && coverFile && coverName) {
coverDrop.addEventListener('dragover', (event) => {
event.preventDefault();
coverDrop.style.borderColor = 'var(--accent)';
});
coverDrop.addEventListener('dragleave', () => {
coverDrop.style.borderColor = 'rgba(255,255,255,0.2)';
});
coverDrop.addEventListener('drop', (event) => {
event.preventDefault();
coverDrop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
coverFile.files = event.dataTransfer.files;
coverName.textContent = event.dataTransfer.files[0].name;
}
});
coverFile.addEventListener('change', () => {
coverName.textContent = coverFile.files.length ? coverFile.files[0].name : 'No file selected';
});
}
const sampleDrop = document.getElementById('releaseSampleDropzone');
const sampleFile = document.getElementById('releaseSampleFile');
const sampleName = document.getElementById('releaseSampleFileName');
if (sampleDrop && sampleFile && sampleName) {
sampleDrop.addEventListener('dragover', (event) => {
event.preventDefault();
sampleDrop.style.borderColor = 'var(--accent)';
});
sampleDrop.addEventListener('dragleave', () => {
sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)';
});
sampleDrop.addEventListener('drop', (event) => {
event.preventDefault();
sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
sampleFile.files = event.dataTransfer.files;
sampleName.textContent = event.dataTransfer.files[0].name;
}
});
sampleFile.addEventListener('change', () => {
sampleName.textContent = sampleFile.files.length ? sampleFile.files[0].name : 'No file selected';
});
}
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,87 @@
<?php
$pageTitle = 'Releases';
$releases = $releases ?? [];
$tableReady = $table_ready ?? false;
$pageId = (int)($page_id ?? 0);
$pagePublished = (int)($page_published ?? 0);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</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;">Releases</h1>
<p style="color: var(--muted); margin-top:6px;">Manage singles, EPs, and albums.</p>
</div>
<a href="/admin/releases/new" class="btn">New Release</a>
</div>
<?php if (!$tableReady): ?>
<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;">Database not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create the releases table before adding records.</div>
</div>
<form method="post" action="/admin/releases/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php else: ?>
<div class="admin-card" style="margin-top:16px; padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div>
<div style="font-weight:600;">Releases page</div>
<div style="color: var(--muted); font-size:12px; margin-top:4px;">
Slug: <code>releases</code>
<?php if ($pageId > 0): ?>
- Status: <?= $pagePublished === 1 ? 'Published' : 'Draft' ?>
<?php else: ?>
- Not created
<?php endif; ?>
</div>
</div>
<?php if ($pageId > 0): ?>
<a href="/admin/pages/edit?id=<?= $pageId ?>" class="btn outline small">Edit Page Content</a>
<?php else: ?>
<span class="pill">Re-enable plugin to create</span>
<?php endif; ?>
</div>
<?php if (!$releases): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No releases yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($releases as $release): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:44px; height:44px; border-radius:12px; overflow:hidden; background:rgba(255,255,255,0.06); display:grid; place-items:center;">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<span style="font-size:12px; color:var(--muted);">N/A</span>
<?php endif; ?>
</div>
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)$release['slug'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<?php if ((int)$release['is_published'] !== 1): ?>
<span class="pill">Draft</span>
<?php endif; ?>
<a href="/admin/releases/tracks?release_id=<?= (int)$release['id'] ?>" class="btn outline small">Tracks</a>
<a href="/admin/releases/edit?id=<?= (int)$release['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/releases/delete" onsubmit="return confirm('Delete this release?');">
<input type="hidden" name="id" value="<?= (int)$release['id'] ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,161 @@
<?php
$pageTitle = $title ?? 'Edit Track';
$track = $track ?? [];
$release = $release ?? null;
$error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? '');
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</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;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<p style="color: var(--muted); margin-top:6px;">
<?= $release ? htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') : 'Track details' ?>
</p>
</div>
<a href="/admin/releases/tracks?release_id=<?= (int)($track['release_id'] ?? 0) ?>" class="btn outline">Back</a>
</div>
<?php if ($error): ?>
<div style="margin-top:16px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($uploadError !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($uploadError, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="/admin/releases/tracks/save" enctype="multipart/form-data" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="track_id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="release_id" value="<?= (int)($track['release_id'] ?? 0) ?>">
<div class="admin-card" style="padding:16px;">
<div style="display:grid; gap:12px;">
<label class="label">Track #</label>
<input class="input" name="track_no" value="<?= htmlspecialchars((string)($track['track_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="1">
<label class="label">Title</label>
<input class="input" name="title" value="<?= htmlspecialchars((string)($track['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Track title">
<label class="label">Mix Name</label>
<input class="input" name="mix_name" value="<?= htmlspecialchars((string)($track['mix_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="Extended Mix">
<label class="label">Duration</label>
<input class="input" name="duration" value="<?= htmlspecialchars((string)($track['duration'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="6:12">
<label class="label">BPM</label>
<input class="input" name="bpm" value="<?= htmlspecialchars((string)($track['bpm'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="140">
<label class="label">Key</label>
<input class="input" name="key_signature" value="<?= htmlspecialchars((string)($track['key_signature'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="B Minor">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload sample (MP3)</div>
<label for="trackSampleFile" id="trackSampleDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="trackSampleFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="trackSampleFile" name="track_sample" accept="audio/mpeg" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/tracks/upload" formmethod="post" formenctype="multipart/form-data" name="upload_kind" value="sample">Upload</button>
</div>
</div>
<label class="label">Sample URL (MP3)</label>
<input class="input" name="sample_url" value="<?= htmlspecialchars((string)($track['sample_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
<?php if ($storePluginEnabled): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Upload full track (MP3)</div>
<label for="trackFullFile" id="trackFullDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag &amp; Drop</div>
<div style="font-size:13px; color:var(--text);">or click to upload</div>
<div id="trackFullFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label>
<input class="input" type="file" id="trackFullFile" name="track_sample" accept="audio/mpeg" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
<button type="submit" class="btn small" formaction="/admin/releases/tracks/upload" formmethod="post" formenctype="multipart/form-data" name="upload_kind" value="full">Upload Full</button>
</div>
</div>
<label class="label">Full File URL (Store Download)</label>
<input class="input" name="full_file_url" value="<?= htmlspecialchars((string)($track['full_file_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="/uploads/media/track-full.mp3">
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label" style="margin-bottom:10px;">Store Options</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_enabled" value="1" <?= ((int)($track['store_enabled'] ?? 0) === 1) ? 'checked' : '' ?>>
Enable track purchase
</label>
<div style="display:grid; grid-template-columns:1fr 120px; gap:10px; margin-top:10px;">
<div>
<label class="label">Track Price</label>
<input class="input" name="track_price" value="<?= htmlspecialchars((string)($track['track_price'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="1.49">
</div>
<div>
<label class="label">Currency</label>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($track['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:12px; align-items:center;">
<button type="submit" class="btn">Save track</button>
</div>
</form>
</section>
<script>
(function () {
const drop = document.getElementById('trackSampleDropzone');
const file = document.getElementById('trackSampleFile');
const name = document.getElementById('trackSampleFileName');
if (drop && file && name) {
drop.addEventListener('dragover', (event) => {
event.preventDefault();
drop.style.borderColor = 'var(--accent)';
});
drop.addEventListener('dragleave', () => {
drop.style.borderColor = 'rgba(255,255,255,0.2)';
});
drop.addEventListener('drop', (event) => {
event.preventDefault();
drop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
file.files = event.dataTransfer.files;
name.textContent = event.dataTransfer.files[0].name;
}
});
file.addEventListener('change', () => {
name.textContent = file.files.length ? file.files[0].name : 'No file selected';
});
}
const fullDrop = document.getElementById('trackFullDropzone');
const fullFile = document.getElementById('trackFullFile');
const fullName = document.getElementById('trackFullFileName');
if (fullDrop && fullFile && fullName) {
fullDrop.addEventListener('dragover', (event) => {
event.preventDefault();
fullDrop.style.borderColor = 'var(--accent)';
});
fullDrop.addEventListener('dragleave', () => {
fullDrop.style.borderColor = 'rgba(255,255,255,0.2)';
});
fullDrop.addEventListener('drop', (event) => {
event.preventDefault();
fullDrop.style.borderColor = 'rgba(255,255,255,0.2)';
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
fullFile.files = event.dataTransfer.files;
fullName.textContent = event.dataTransfer.files[0].name;
}
});
fullFile.addEventListener('change', () => {
fullName.textContent = fullFile.files.length ? fullFile.files[0].name : 'No file selected';
});
}
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,69 @@
<?php
$pageTitle = 'Release Tracks';
$release = $release ?? null;
$tracks = $tracks ?? [];
$tableReady = $table_ready ?? false;
$releaseId = (int)($release_id ?? 0);
ob_start();
?>
<section class="admin-card">
<div class="badge">Releases</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;">Tracks</h1>
<p style="color: var(--muted); margin-top:6px;">
<?= $release ? htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') : 'Select a release to manage tracks.' ?>
</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<a href="/admin/releases" class="btn outline">Back</a>
<?php if ($releaseId > 0): ?>
<a href="/admin/releases/tracks/new?release_id=<?= $releaseId ?>" class="btn">New Track</a>
<?php endif; ?>
</div>
</div>
<?php if (!$tableReady): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">Tracks table is not available. Run Releases ? Create Tables.</div>
<?php elseif (!$release): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">Release not found.</div>
<?php elseif (!$tracks): ?>
<div style="margin-top:18px; color: var(--muted); font-size:13px;">No tracks yet.</div>
<?php else: ?>
<div style="margin-top:18px; display:grid; gap:12px;">
<?php foreach ($tracks as $track): ?>
<div class="admin-card" style="padding:14px; display:flex; align-items:center; justify-content:space-between; gap:16px;">
<div style="display:flex; gap:12px; align-items:center;">
<div style="width:38px; height:38px; border-radius:10px; background:rgba(255,255,255,0.06); display:grid; place-items:center; font-size:12px; color:var(--muted);">
<?= (int)($track['track_no'] ?? 0) > 0 ? (int)$track['track_no'] : '<27>' ?>
</div>
<div>
<div style="font-weight:600;">
<?= htmlspecialchars((string)$track['title'], ENT_QUOTES, 'UTF-8') ?>
<?php if (!empty($track['mix_name'])): ?>
<span style="color:var(--muted); font-weight:400;">(<?= htmlspecialchars((string)$track['mix_name'], ENT_QUOTES, 'UTF-8') ?>)</span>
<?php endif; ?>
</div>
<div style="font-size:12px; color:var(--muted);">
<?= htmlspecialchars((string)($track['duration'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
<?= !empty($track['bpm']) ? ' <20> ' . htmlspecialchars((string)$track['bpm'], ENT_QUOTES, 'UTF-8') . ' BPM' : '' ?>
<?= !empty($track['key_signature']) ? ' <20> ' . htmlspecialchars((string)$track['key_signature'], ENT_QUOTES, 'UTF-8') : '' ?>
</div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<a href="/admin/releases/tracks/edit?release_id=<?= $releaseId ?>&id=<?= (int)$track['id'] ?>" class="btn outline small">Edit</a>
<form method="post" action="/admin/releases/tracks/delete" onsubmit="return confirm('Delete this track?');">
<input type="hidden" name="id" value="<?= (int)$track['id'] ?>">
<input type="hidden" name="release_id" value="<?= $releaseId ?>">
<button type="submit" class="btn outline small">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,366 @@
<?php
$pageTitle = $title ?? 'Releases';
$releases = is_array($releases ?? null) ? $releases : [];
$releaseCount = (int)($total_releases ?? count($releases));
$artistFilter = trim((string)($artist_filter ?? ''));
$artistOptions = is_array($artist_options ?? null) ? $artist_options : [];
$search = trim((string)($search ?? ''));
$sort = trim((string)($sort ?? 'newest'));
$currentPage = max(1, (int)($current_page ?? 1));
$totalPages = max(1, (int)($total_pages ?? 1));
$buildReleaseUrl = static function (int $page) use ($search, $artistFilter, $sort): string {
$params = [];
if ($search !== '') {
$params['q'] = $search;
}
if ($artistFilter !== '') {
$params['artist'] = $artistFilter;
}
if ($sort !== 'newest') {
$params['sort'] = $sort;
}
if ($page > 1) {
$params['p'] = $page;
}
$qs = http_build_query($params);
return '/releases' . ($qs !== '' ? ('?' . $qs) : '');
};
ob_start();
?>
<div class="ac-releases-page">
<section class="card ac-releases-shell">
<div class="ac-releases-header">
<div class="badge">Releases</div>
<h1>Latest Drops</h1>
<p>Singles, EPs, and albums from the AudioCore catalog.</p>
</div>
<form method="get" action="/releases" class="ac-release-controls">
<div class="ac-search-wrap">
<span class="ac-search-icon"><i class="fa-solid fa-magnifying-glass"></i></span>
<input class="ac-search-input" type="text" name="q" value="<?= htmlspecialchars($search, ENT_QUOTES, 'UTF-8') ?>" placeholder="Search releases, artists, catalog number">
</div>
<select class="ac-control-input" name="artist">
<option value="">All artists</option>
<?php foreach ($artistOptions as $artist): ?>
<option value="<?= htmlspecialchars((string)$artist, ENT_QUOTES, 'UTF-8') ?>" <?= $artistFilter === (string)$artist ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$artist, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
<select class="ac-control-input" name="sort">
<option value="newest" <?= $sort === 'newest' ? 'selected' : '' ?>>Newest first</option>
<option value="oldest" <?= $sort === 'oldest' ? 'selected' : '' ?>>Oldest first</option>
<option value="title_asc" <?= $sort === 'title_asc' ? 'selected' : '' ?>>Title A-Z</option>
<option value="title_desc" <?= $sort === 'title_desc' ? 'selected' : '' ?>>Title Z-A</option>
</select>
<button type="submit" class="ac-btn ac-btn-primary">Apply</button>
<a href="/releases" class="ac-btn ac-btn-ghost">Reset</a>
</form>
<?php if ($artistFilter !== '' || $search !== '' || $sort !== 'newest'): ?>
<div class="ac-active-filters">
<?php if ($artistFilter !== ''): ?><div class="ac-chip">Artist: <?= htmlspecialchars($artistFilter, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($search !== ''): ?><div class="ac-chip">Search: <?= htmlspecialchars($search, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($sort !== 'newest'): ?><div class="ac-chip">Sort: <?= htmlspecialchars(str_replace('_', ' ', $sort), ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$releases): ?>
<div class="ac-empty">No releases published yet.</div>
<?php else: ?>
<div class="ac-release-grid">
<?php foreach ($releases as $release): ?>
<a class="ac-release-card" href="/release?slug=<?= htmlspecialchars((string)$release['slug'], ENT_QUOTES, 'UTF-8') ?>">
<div class="ac-release-cover">
<?php if (!empty($release['cover_url'])): ?>
<img src="<?= htmlspecialchars((string)$release['cover_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php else: ?>
<div class="ac-release-placeholder">AC</div>
<?php endif; ?>
<?php if (!empty($release['release_date'])): ?>
<span class="ac-release-date"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
<div class="ac-release-meta">
<div class="ac-release-title"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></div>
<?php if (!empty($release['artist_name'])): ?>
<div class="ac-release-artist"><?= htmlspecialchars((string)$release['artist_name'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($totalPages > 1): ?>
<nav class="ac-release-pagination">
<?php $prevPage = max(1, $currentPage - 1); ?>
<?php $nextPage = min($totalPages, $currentPage + 1); ?>
<a class="ac-btn ac-btn-ghost<?= $currentPage <= 1 ? ' is-disabled' : '' ?>" href="<?= $currentPage <= 1 ? '#' : htmlspecialchars($buildReleaseUrl($prevPage), ENT_QUOTES, 'UTF-8') ?>">Prev</a>
<div class="ac-pagination-meta">Page <?= $currentPage ?> of <?= $totalPages ?> · <?= $releaseCount ?> total</div>
<a class="ac-btn ac-btn-ghost<?= $currentPage >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $currentPage >= $totalPages ? '#' : htmlspecialchars($buildReleaseUrl($nextPage), ENT_QUOTES, 'UTF-8') ?>">Next</a>
</nav>
<?php endif; ?>
</section>
</div>
<style>
.ac-releases-page .ac-releases-shell {
margin-top: 14px;
display: grid;
gap: 14px;
}
.ac-releases-page .ac-releases-header {
border-bottom: 1px solid rgba(255,255,255,0.06);
padding-bottom: 10px;
}
.ac-releases-page .ac-releases-header h1 {
margin: 8px 0 0;
font-size: 52px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.ac-releases-page .ac-releases-header p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
}
.ac-releases-page .ac-release-controls {
display: grid;
grid-template-columns: minmax(260px, 1fr) 180px 180px auto auto;
gap: 10px;
align-items: center;
padding: 8px;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
background: rgba(8, 12, 19, 0.16);
}
.ac-releases-page .ac-search-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
background: rgba(7,10,16,0.10);
height: 36px;
padding: 0 10px;
}
.ac-releases-page .ac-search-icon {
color: rgba(255,255,255,0.72);
font-size: 12px;
width: 16px;
display: inline-flex;
justify-content: center;
}
.ac-releases-page .ac-search-input,
.ac-releases-page .ac-control-input {
width: 100%;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.07);
background: rgba(7,10,16,0.10);
color: rgba(233,237,247,0.94);
padding: 0 10px;
font-size: 13px;
outline: none;
}
.ac-releases-page .ac-search-input {
border: 0;
background: transparent;
padding: 0;
min-width: 0;
}
.ac-releases-page .ac-search-input::placeholder {
color: rgba(220,228,245,.45);
}
.ac-releases-page .ac-search-input:focus,
.ac-releases-page .ac-control-input:focus {
box-shadow: 0 0 0 2px rgba(34,242,165,.12);
border-color: rgba(34,242,165,.38);
}
.ac-releases-page .ac-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.10);
text-decoration: none;
font-size: 10px;
text-transform: uppercase;
letter-spacing: .12em;
font-weight: 700;
cursor: pointer;
transition: all .2s ease;
}
.ac-releases-page .ac-btn-primary {
background: rgba(34,242,165,.14);
color: #87f2cb;
border-color: rgba(34,242,165,.34);
}
.ac-releases-page .ac-btn-primary:hover {
background: rgba(34,242,165,.20);
}
.ac-releases-page .ac-btn-ghost {
color: #a1acc4;
background: transparent;
border-color: rgba(255,255,255,.10);
}
.ac-releases-page .ac-btn-ghost:hover {
color: #e5ebf7;
border-color: rgba(255,255,255,.18);
}
.ac-releases-page .ac-btn.is-disabled {
opacity: .45;
pointer-events: none;
}
.ac-releases-page .ac-active-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ac-releases-page .ac-chip {
border-radius: 999px;
padding: 6px 10px;
border: 1px solid rgba(255,255,255,.12);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
}
.ac-releases-page .ac-release-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 240px));
justify-content: start;
gap: 16px;
}
.ac-releases-page .ac-release-card {
display: grid;
gap: 10px;
color: inherit;
text-decoration: none;
padding: 10px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(0,0,0,.14);
transition: border-color .2s ease, transform .2s ease;
}
.ac-releases-page .ac-release-card:hover {
border-color: rgba(255,255,255,.18);
transform: translateY(-2px);
}
.ac-releases-page .ac-release-cover {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 16px;
overflow: hidden;
position: relative;
background: rgba(255,255,255,.03);
}
.ac-releases-page .ac-release-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ac-releases-page .ac-release-date {
position: absolute;
left: 10px;
bottom: 10px;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
color: #fff;
background: rgba(0,0,0,.56);
border: 1px solid rgba(255,255,255,.18);
}
.ac-releases-page .ac-release-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--muted);
letter-spacing: .16em;
}
.ac-releases-page .ac-release-meta {
display: grid;
gap: 6px;
}
.ac-releases-page .ac-release-title {
font-weight: 600;
font-size: 18px;
line-height: 1.2;
}
.ac-releases-page .ac-release-artist {
font-size: 13px;
color: var(--muted);
}
.ac-releases-page .ac-empty {
padding: 16px;
border: 1px solid rgba(255,255,255,.08);
border-radius: 12px;
color: var(--muted);
}
.ac-releases-page .ac-release-pagination {
margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.ac-releases-page .ac-pagination-meta {
font-size: 12px;
color: var(--muted);
}
@media (max-width: 1180px) {
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr 160px 160px auto auto;
}
.ac-releases-page .ac-release-grid {
grid-template-columns: repeat(auto-fill, minmax(210px, 230px));
}
}
@media (max-width: 900px) {
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr 1fr 1fr;
}
.ac-releases-page .ac-release-controls .ac-search-wrap {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.ac-releases-page .ac-releases-header h1 {
font-size: 40px;
}
.ac-releases-page .ac-release-controls {
grid-template-columns: 1fr;
}
.ac-releases-page .ac-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-content: stretch;
}
}
@media (max-width: 520px) {
.ac-releases-page .ac-release-grid {
grid-template-columns: 1fr;
}
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Release';
$release = $release ?? null;
$tracks = $tracks ?? [];
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
$releaseCover = (string)($release['cover_url'] ?? '');
$returnUrl = (string)($_SERVER['REQUEST_URI'] ?? '/releases');
$releaseStoreEnabled = (int)($release['store_enabled'] ?? 0) === 1;
$bundlePrice = (float)($release['bundle_price'] ?? 0);
$bundleCurrency = (string)($release['store_currency'] ?? 'GBP');
$bundleLabel = trim((string)($release['purchase_label'] ?? ''));
$bundleLabel = $bundleLabel !== '' ? $bundleLabel : 'Buy Release';
ob_start();
?>
<section class="card" style="display:grid; gap:18px; padding-bottom:110px;">
<div class="badge">Release</div>
<?php if (!$release): ?>
<h1 style="margin:0; font-size:28px;">Release not found</h1>
<p style="color:var(--muted);">This release is unavailable.</p>
<?php else: ?>
<div class="release-wrap" style="display:grid; gap:18px;">
<div class="release-hero" style="display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:22px; align-items:start;">
<div class="release-meta" style="display:grid; gap:14px;">
<h1 style="margin:0; font-size:46px; line-height:1.06;"><?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?></h1>
<?php if (!empty($release['artist_name'])): ?>
<div style="font-size:14px; color:var(--muted);">
By
<a href="/releases?artist=<?= rawurlencode((string)$release['artist_name']) ?>" style="color:#dfe7fb; text-decoration:none; border-bottom:1px solid rgba(223,231,251,.35);">
<?= htmlspecialchars((string)$release['artist_name'], ENT_QUOTES, 'UTF-8') ?>
</a>
</div>
<?php endif; ?>
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; max-width:640px;">
<?php if (!empty($release['catalog_no'])): ?>
<div style="padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22);">
<div class="badge" style="font-size:9px;">Catalog</div>
<div style="margin-top:6px; font-size:14px;"><?= htmlspecialchars((string)$release['catalog_no'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
<?php if (!empty($release['release_date'])): ?>
<div style="padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22);">
<div class="badge" style="font-size:9px;">Release Date</div>
<div style="margin-top:6px; font-size:14px;"><?= htmlspecialchars((string)$release['release_date'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
</div>
<?php if (!empty($release['description'])): ?>
<div style="color:var(--muted); line-height:1.75; max-width:760px;">
<?= nl2br(htmlspecialchars((string)$release['description'], ENT_QUOTES, 'UTF-8')) ?>
</div>
<?php endif; ?>
<?php if ($storePluginEnabled && $releaseStoreEnabled && $bundlePrice > 0): ?>
<div style="margin-top:4px;">
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="release">
<input type="hidden" name="item_id" value="<?= (int)($release['id'] ?? 0) ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars((string)$release['title'], ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($bundleCurrency, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($bundlePrice, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="qty" value="1">
<input type="hidden" name="return_url" value="<?= htmlspecialchars($returnUrl, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="track-buy-btn">
<i class="fa-solid fa-cart-plus"></i>
<span><?= htmlspecialchars($bundleLabel, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($bundleCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($bundlePrice, 2) ?></span>
</button>
</form>
</div>
<?php endif; ?>
</div>
<div class="release-cover-box" style="border-radius:20px; overflow:hidden; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.1); aspect-ratio:1/1;">
<?php if ($releaseCover !== ''): ?>
<img id="releaseCoverMain" src="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<div id="releaseCoverMain" style="height:100%; display:grid; place-items:center; color:var(--muted); letter-spacing:0.3em; font-size:12px;">AUDIOCORE</div>
<?php endif; ?>
</div>
</div>
<?php if ($tracks): ?>
<div style="padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.22); display:grid; gap:10px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div class="badge">Tracklist</div>
<div style="display:flex; align-items:center; gap:10px;">
<div id="releasePlayerNow" style="font-size:12px; color:var(--muted);">Select a track to play sample</div>
</div>
</div>
<div style="display:grid; gap:8px;">
<?php foreach ($tracks as $track): ?>
<?php
$sample = (string)($track['sample_url'] ?? '');
$trackTitle = (string)($track['title'] ?? 'Track');
$mix = (string)($track['mix_name'] ?? '');
$fullTitle = $mix !== '' ? ($trackTitle . ' (' . $mix . ')') : $trackTitle;
?>
<div class="track-row" style="display:grid; grid-template-columns:92px minmax(0,1fr) auto; gap:12px; align-items:center; padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.26);">
<button type="button"
class="track-play-btn"
data-src="<?= htmlspecialchars($sample, ENT_QUOTES, 'UTF-8') ?>"
data-title="<?= htmlspecialchars($fullTitle, ENT_QUOTES, 'UTF-8') ?>"
data-cover="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>"
<?= $sample === '' ? 'disabled' : '' ?>>
<i class="fa-solid fa-play"></i> <span>Play</span>
</button>
<div style="min-width:0;">
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<?= htmlspecialchars($trackTitle, ENT_QUOTES, 'UTF-8') ?>
<?php if ($mix !== ''): ?>
<span style="color:var(--muted); font-weight:400;">(<?= htmlspecialchars($mix, ENT_QUOTES, 'UTF-8') ?>)</span>
<?php endif; ?>
</div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">
<?= (int)($track['track_no'] ?? 0) > 0 ? '#' . (int)$track['track_no'] : 'Track' ?>
<?= !empty($track['duration']) ? ' - ' . htmlspecialchars((string)$track['duration'], ENT_QUOTES, 'UTF-8') : '' ?>
<?= !empty($track['bpm']) ? ' - ' . htmlspecialchars((string)$track['bpm'], ENT_QUOTES, 'UTF-8') . ' BPM' : '' ?>
<?= !empty($track['key_signature']) ? ' - ' . htmlspecialchars((string)$track['key_signature'], ENT_QUOTES, 'UTF-8') : '' ?>
</div>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<?php
$trackStoreEnabled = (int)($track['store_enabled'] ?? 0) === 1;
$trackPrice = (float)($track['track_price'] ?? 0);
$trackCurrency = (string)($track['store_currency'] ?? 'GBP');
?>
<?php if ($storePluginEnabled && $trackStoreEnabled && $trackPrice > 0): ?>
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="track">
<input type="hidden" name="item_id" value="<?= (int)($track['id'] ?? 0) ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars($fullTitle, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars($releaseCover, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($trackCurrency, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($trackPrice, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="qty" value="1">
<input type="hidden" name="return_url" value="<?= htmlspecialchars($returnUrl, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit" class="track-buy-btn">
<i class="fa-solid fa-cart-plus"></i>
<span>Buy <?= htmlspecialchars($trackCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($trackPrice, 2) ?></span>
</button>
</form>
<?php else: ?>
<div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:0.18em;">
<?= $sample !== '' ? 'Sample' : 'No sample' ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($release['credits'])): ?>
<div style="padding:12px 14px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); background:rgba(0,0,0,0.2);">
<div class="badge" style="font-size:9px;">Credits</div>
<div style="margin-top:6px; color:var(--muted); line-height:1.55; font-size:13px;">
<?= nl2br(htmlspecialchars((string)$release['credits'], ENT_QUOTES, 'UTF-8')) ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<div id="acDock" class="ac-dock" hidden>
<div class="ac-dock-inner">
<div class="ac-dock-meta">
<div id="acDockArt" class="ac-dock-art">AC</div>
<div class="ac-dock-title-wrap">
<div class="badge" style="font-size:9px;">Now Playing</div>
<div id="acDockTitle" class="ac-dock-title">Track sample</div>
</div>
</div>
<button type="button" id="acDockToggle" class="ac-dock-toggle"><i class="fa-solid fa-play"></i> <span>Play</span></button>
<input id="acDockSeek" class="ac-seek" type="range" min="0" max="100" value="0" step="0.1">
<div class="ac-dock-time"><span id="acDockCurrent">0:00</span> / <span id="acDockDuration">0:00</span></div>
<input id="acDockVolume" class="ac-volume" type="range" min="0" max="1" value="1" step="0.01">
<button type="button" id="acDockClose" class="ac-dock-close" aria-label="Close player">X</button>
<audio id="acDockAudio" preload="none"></audio>
</div>
</div>
<style>
.release-wrap {
min-width: 0;
}
.track-row.is-active { border-color: rgba(34,242,165,.45)!important; background: rgba(34,242,165,.08)!important; }
.track-play-btn,.ac-dock-toggle{height:34px;border:1px solid rgba(34,242,165,.35);border-radius:999px;background:rgba(34,242,165,.12);color:#bffff0;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px}
.track-buy-btn{height:34px;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:#e9eefc;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px;font-size:12px;white-space:nowrap}
.track-buy-btn:hover{background:rgba(255,255,255,.14)}
.track-play-btn[disabled]{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:var(--muted);cursor:not-allowed}
.ac-dock{position:fixed;left:14px;right:14px;bottom:14px;z-index:50}
.ac-dock-inner{display:grid;grid-template-columns:minmax(210px,280px) 96px minmax(160px,1fr) 110px 110px 44px;gap:10px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,11,15,.95)}
.ac-dock-meta{display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-width:0}
.ac-dock-art{width:44px;height:44px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);overflow:hidden;display:grid;place-items:center;font-size:10px;color:var(--muted)}
.ac-dock-art img{width:100%;height:100%;object-fit:cover;display:block}
.ac-dock-title-wrap{min-width:0}.ac-dock-title{margin-top:4px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ac-dock-time{font-size:12px;color:var(--muted);font-family:'IBM Plex Mono',monospace}
.ac-dock-close{height:34px;border-radius:10px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer}
.ac-seek,.ac-volume{-webkit-appearance:none;appearance:none;height:6px;border-radius:999px;background:rgba(255,255,255,.2);outline:none}
.ac-seek::-webkit-slider-thumb,.ac-volume::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#22f2a5;border:1px solid rgba(0,0,0,.45)}
@media (max-width: 980px) {
.release-hero {
grid-template-columns: 1fr !important;
gap: 16px !important;
}
.release-cover-box {
max-width: 420px;
}
}
@media (max-width: 700px) {
.release-meta h1 {
font-size: 32px !important;
line-height: 1.12 !important;
}
.track-row {
grid-template-columns: 88px minmax(0,1fr) !important;
gap: 10px !important;
}
.track-buy-btn { font-size: 11px; padding: 0 10px; }
.ac-dock {
left: 8px;
right: 8px;
bottom: 8px;
}
.ac-dock-inner {
grid-template-columns: minmax(0,1fr) 96px auto 44px !important;
grid-template-areas:
"meta toggle time close"
"seek seek seek seek";
row-gap: 8px;
padding: 10px;
border-radius: 12px;
}
.ac-dock-meta { grid-area: meta; gap: 8px; }
.ac-dock-art { width: 38px; height: 38px; border-radius: 8px; }
.ac-dock-title { font-size: 12px; }
.ac-dock-close { grid-area: close; width: 44px; justify-self: end; }
.ac-dock-toggle {
grid-area: toggle;
height: 32px;
padding: 0 8px;
border-radius: 999px;
justify-self: center;
width: 96px;
min-width: 96px;
font-size: 12px;
}
.ac-dock-time {
grid-area: time;
text-align: right;
white-space: nowrap;
font-size: 12px;
align-self: center;
}
.ac-seek { grid-area: seek; margin-top: 0; align-self: center; }
.ac-volume { display: none; }
}
</style>
<script>
(function () {
const dock = document.getElementById('acDock');
const audio = document.getElementById('acDockAudio');
const toggle = document.getElementById('acDockToggle');
const seek = document.getElementById('acDockSeek');
const volume = document.getElementById('acDockVolume');
const current = document.getElementById('acDockCurrent');
const duration = document.getElementById('acDockDuration');
const titleEl = document.getElementById('acDockTitle');
const dockArt = document.getElementById('acDockArt');
const closeBtn = document.getElementById('acDockClose');
const status = document.getElementById('releasePlayerNow');
const mainCover = document.getElementById('releaseCoverMain');
if (!dock || !audio || !toggle || !seek || !volume || !current || !duration || !titleEl || !closeBtn) return;
const mainCoverSrc = mainCover && mainCover.tagName === 'IMG' ? (mainCover.getAttribute('src') || '') : '';
const defaultCover = mainCoverSrc || <?= json_encode($releaseCover, JSON_UNESCAPED_SLASHES) ?> || '';
let activeBtn = null;
function fmt(sec){ if(!isFinite(sec)||sec<0) return '0:00'; const m=Math.floor(sec/60), s=Math.floor(sec%60); return m+':'+String(s).padStart(2,'0'); }
function setPlayState(btn,playing){ if(!btn) return; btn.innerHTML = playing ? '<i class="fa-solid fa-pause"></i> <span>Pause</span>' : '<i class="fa-solid fa-play"></i> <span>Play</span>'; }
function setDockArt(src){ if(!dockArt) return; const finalSrc = src || defaultCover; dockArt.innerHTML = finalSrc ? ('<img src="'+finalSrc.replace(/"/g,'&quot;')+'" alt="">') : 'AC'; }
function setActive(btn,title){
document.querySelectorAll('.track-play-btn').forEach((b)=>{ setPlayState(b,false); const row=b.closest('.track-row'); if(row) row.classList.remove('is-active'); });
activeBtn = btn;
if(btn){ setPlayState(btn,true); const row=btn.closest('.track-row'); if(row) row.classList.add('is-active'); }
titleEl.textContent = title || 'Track sample';
if(status) status.textContent = title ? ('Now Playing: '+title) : 'Select a track to play sample';
}
function openAndPlay(src,title,btn,cover){
if(!src) return;
if(dock.hidden) dock.hidden = false;
setDockArt(cover || '');
if(audio.getAttribute('src') === src){ if(audio.paused) audio.play().catch(()=>{}); else audio.pause(); return; }
audio.setAttribute('src', src);
setActive(btn,title);
audio.play().catch(()=>{ if(btn) setPlayState(btn,false); });
}
document.querySelectorAll('.track-play-btn').forEach((btn)=>{
btn.addEventListener('click', ()=>{
openAndPlay(btn.getAttribute('data-src') || '', btn.getAttribute('data-title') || 'Track sample', btn, btn.getAttribute('data-cover') || '');
});
});
toggle.addEventListener('click', ()=>{ if(!audio.getAttribute('src')) return; if(audio.paused) audio.play().catch(()=>{}); else audio.pause(); });
closeBtn.addEventListener('click', ()=>{ audio.pause(); audio.removeAttribute('src'); seek.value='0'; current.textContent='0:00'; duration.textContent='0:00'; setPlayState(toggle,false); setActive(null,''); setDockArt(''); dock.hidden=true; });
volume.addEventListener('input', ()=>{ audio.volume = Number(volume.value); });
seek.addEventListener('input', ()=>{ if(!isFinite(audio.duration)||audio.duration<=0) return; audio.currentTime = (Number(seek.value)/100)*audio.duration; });
audio.addEventListener('loadedmetadata', ()=>{ duration.textContent = fmt(audio.duration); });
audio.addEventListener('timeupdate', ()=>{ current.textContent = fmt(audio.currentTime); if(isFinite(audio.duration)&&audio.duration>0){ seek.value = String((audio.currentTime/audio.duration)*100); } });
audio.addEventListener('play', ()=>{ setPlayState(toggle,true); if(activeBtn) setPlayState(activeBtn,true); });
audio.addEventListener('pause', ()=>{ setPlayState(toggle,false); if(activeBtn) setPlayState(activeBtn,false); });
audio.addEventListener('ended', ()=>{ setPlayState(toggle,false); if(activeBtn) setPlayState(activeBtn,false); seek.value='0'; current.textContent='0:00'; });
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
<?php
$pageTitle = $title ?? 'Store Customers';
$customers = $customers ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Customers</h1>
<p style="color: var(--muted); margin-top:6px;">Registered and purchasing customers.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if (!$customers): ?>
<div style="margin-top:16px; color:var(--muted); font-size:13px;">No customers yet.</div>
<?php else: ?>
<div style="margin-top:16px; display:grid; gap:10px;">
<?php foreach ($customers as $customer): ?>
<div class="admin-card" style="padding:14px; display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center;">
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($customer['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:2px;">
<?= htmlspecialchars((string)($customer['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<div class="pill"><?= ((int)($customer['is_active'] ?? 1) === 1) ? 'Active' : 'Inactive' ?></div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($customer['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
interface GatewayInterface
{
public function key(): string;
public function label(): string;
public function isEnabled(array $settings): bool;
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
final class Gateways
{
/**
* @return GatewayInterface[]
*/
public static function all(): array
{
return [
new PaypalGateway(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
class PaypalGateway implements GatewayInterface
{
public function key(): string
{
return 'paypal';
}
public function label(): string
{
return 'PayPal';
}
public function isEnabled(array $settings): bool
{
return (string)($settings['store_paypal_enabled'] ?? '0') === '1';
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Plugins\Store\Gateways;
class StripeGateway implements GatewayInterface
{
public function key(): string
{
return 'stripe';
}
public function label(): string
{
return 'Stripe';
}
public function isEnabled(array $settings): bool
{
return (string)($settings['store_stripe_enabled'] ?? '0') === '1';
}
}

52
plugins/store/index.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
$pageTitle = $title ?? 'Store';
$tablesReady = (bool)($tables_ready ?? false);
$privateRoot = (string)($private_root ?? '');
$privateRootReady = (bool)($private_root_ready ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Store</h1>
<p style="color: var(--muted); margin-top:6px;">Commerce layer for releases/tracks.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<a href="/admin/store/settings" class="btn outline">Settings</a>
<a href="/admin/store/orders" class="btn outline">Orders</a>
<a href="/admin/store/customers" class="btn outline">Customers</a>
</div>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</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;">Store tables not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create store tables before configuring products and checkout.</div>
</div>
<form method="post" action="/admin/store/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php endif; ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="font-weight:600;">Private download root</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px; font-family:'IBM Plex Mono', monospace;">
<?= htmlspecialchars($privateRoot, ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="margin-top:8px; font-size:12px; color: <?= $privateRootReady ? '#9be7c6' : '#f3b0b0' ?>;">
<?= $privateRootReady ? 'Ready' : 'Missing or not writable' ?>
</div>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

45
plugins/store/orders.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
$pageTitle = $title ?? 'Store Orders';
$orders = $orders ?? [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Orders</h1>
<p style="color: var(--muted); margin-top:6px;">Order queue and payment status.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if (!$orders): ?>
<div style="margin-top:16px; color:var(--muted); font-size:13px;">No orders yet.</div>
<?php else: ?>
<div style="margin-top:16px; display:grid; gap:10px;">
<?php foreach ($orders as $order): ?>
<div class="admin-card" style="padding:14px; display:grid; grid-template-columns:minmax(0,1fr) auto auto auto; gap:10px; align-items:center;">
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($order['order_no'] ?? ('#' . (int)($order['id'] ?? 0))), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:2px;"><?= htmlspecialchars((string)($order['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div class="pill"><?= htmlspecialchars((string)($order['status'] ?? 'pending'), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:13px; color:var(--text);">
<?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>
<?= number_format((float)($order['total'] ?? 0), 2) ?>
</div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($order['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

14
plugins/store/plugin.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "Store",
"version": "0.1.0",
"description": "Commerce layer for releases and tracks.",
"author": "AudioCore",
"admin_nav": {
"label": "Store",
"url": "/admin/store",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-cart-shopping"
},
"entry": "plugin.php",
"default_enabled": false
}

283
plugins/store/plugin.php Normal file
View File

@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Core\Services\Database;
use Core\Services\Settings;
use Core\Services\Shortcodes;
use Plugins\Store\StoreController;
require_once __DIR__ . '/StoreController.php';
require_once __DIR__ . '/gateways/GatewayInterface.php';
require_once __DIR__ . '/gateways/PaypalGateway.php';
require_once __DIR__ . '/gateways/Gateways.php';
Shortcodes::register('sale-chart', static function (array $attrs = []): string {
$defaultLimit = max(1, min(50, (int)Settings::get('store_sales_chart_limit', '10')));
$defaultScope = strtolower(trim((string)Settings::get('store_sales_chart_default_scope', 'tracks')));
if (!in_array($defaultScope, ['tracks', 'releases'], true)) {
$defaultScope = 'tracks';
}
$defaultWindow = strtolower(trim((string)Settings::get('store_sales_chart_default_window', 'latest')));
if (!in_array($defaultWindow, ['latest', 'weekly', 'all_time'], true)) {
$defaultWindow = 'latest';
}
$limit = max(1, min(50, (int)($attrs['limit'] ?? $defaultLimit)));
$scope = strtolower(trim((string)($attrs['type'] ?? $attrs['scope'] ?? $defaultScope)));
if (!in_array($scope, ['tracks', 'releases'], true)) {
$scope = $defaultScope;
}
$window = strtolower(trim((string)($attrs['mode'] ?? $attrs['window'] ?? $defaultWindow)));
if (!in_array($window, ['latest', 'weekly', 'all_time'], true)) {
$window = $defaultWindow;
}
$db = Database::get();
if (!($db instanceof \PDO)) {
return '';
}
$rows = [];
try {
$latestPaid = (string)($db->query("SELECT MAX(updated_at) FROM ac_store_orders WHERE status = 'paid'")->fetchColumn() ?? '');
$latestCache = (string)($db->query("SELECT MAX(updated_at) FROM ac_store_sales_chart_cache")->fetchColumn() ?? '');
if ($latestPaid !== '' && ($latestCache === '' || strcmp($latestPaid, $latestCache) > 0)) {
(new StoreController())->rebuildSalesChartCache();
}
} catch (\Throwable $e) {
}
try {
$stmt = $db->prepare("
SELECT item_key, item_label AS title, units, revenue
FROM ac_store_sales_chart_cache
WHERE chart_scope = :scope
AND chart_window = :window
ORDER BY rank_no ASC
LIMIT :limit
");
$stmt->bindValue(':scope', $scope, \PDO::PARAM_STR);
$stmt->bindValue(':window', $window, \PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {
$rows = [];
}
if (!$rows && $scope === 'tracks') {
try {
$stmt = $db->prepare("
SELECT item_key, item_label AS title, units, revenue
FROM ac_store_sales_chart_cache
WHERE chart_scope = 'releases'
AND chart_window = :window
ORDER BY rank_no ASC
LIMIT :limit
");
$stmt->bindValue(':window', $window, \PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
} catch (\Throwable $e) {
$rows = [];
}
}
if (!$rows) {
return '<div class="ac-shortcode-empty">No sales yet.</div>';
}
$releaseIds = [];
$trackIds = [];
foreach ($rows as $row) {
$itemKey = trim((string)($row['item_key'] ?? ''));
if (preg_match('/^release:(\d+)$/', $itemKey, $m)) {
$releaseIds[] = (int)$m[1];
} elseif (preg_match('/^track:(\d+)$/', $itemKey, $m)) {
$trackIds[] = (int)$m[1];
}
}
$releaseIds = array_values(array_unique(array_filter($releaseIds)));
$trackIds = array_values(array_unique(array_filter($trackIds)));
$releaseMap = [];
if ($releaseIds) {
try {
$in = implode(',', array_fill(0, count($releaseIds), '?'));
$stmt = $db->prepare("
SELECT id, title, slug, cover_url, COALESCE(artist_name, '') AS artist_name
FROM ac_releases
WHERE id IN ({$in})
");
foreach ($releaseIds as $i => $rid) {
$stmt->bindValue($i + 1, $rid, \PDO::PARAM_INT);
}
$stmt->execute();
$rels = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
foreach ($rels as $rel) {
$releaseMap['release:' . (int)$rel['id']] = $rel;
}
} catch (\Throwable $e) {
}
}
if ($trackIds) {
try {
$in = implode(',', array_fill(0, count($trackIds), '?'));
$stmt = $db->prepare("
SELECT t.id AS track_id, r.id, r.title, r.slug, r.cover_url, COALESCE(r.artist_name, '') AS artist_name
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
WHERE t.id IN ({$in})
");
foreach ($trackIds as $i => $tid) {
$stmt->bindValue($i + 1, $tid, \PDO::PARAM_INT);
}
$stmt->execute();
$rels = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
foreach ($rels as $rel) {
$releaseMap['track:' . (int)$rel['track_id']] = $rel;
}
} catch (\Throwable $e) {
}
}
$list = '';
$position = 1;
foreach ($rows as $row) {
$itemKey = trim((string)($row['item_key'] ?? ''));
$rel = $releaseMap[$itemKey] ?? null;
$titleRaw = $rel ? (string)($rel['title'] ?? '') : (string)($row['title'] ?? '');
$artistRaw = $rel ? trim((string)($rel['artist_name'] ?? '')) : '';
$slugRaw = $rel ? trim((string)($rel['slug'] ?? '')) : '';
$coverRaw = $rel ? trim((string)($rel['cover_url'] ?? '')) : '';
$title = htmlspecialchars($titleRaw !== '' ? $titleRaw : ((string)($row['title'] ?? 'Release')), ENT_QUOTES, 'UTF-8');
$artist = htmlspecialchars($artistRaw, ENT_QUOTES, 'UTF-8');
$href = $slugRaw !== '' ? '/release?slug=' . rawurlencode($slugRaw) : '#';
$thumbHtml = $coverRaw !== ''
? '<img src="' . htmlspecialchars($coverRaw, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
: '<div class="ac-shortcode-cover-fallback">AC</div>';
$copy = '<span class="ac-shortcode-sale-copy">'
. '<span class="ac-shortcode-sale-title">' . $title . '</span>'
. ($artist !== '' ? '<span class="ac-shortcode-sale-artist">' . $artist . '</span>' : '')
. '</span>'
. '<span class="ac-shortcode-sale-rank">' . $position . '</span>';
$content = '<span class="ac-shortcode-sale-thumb">' . $thumbHtml . '</span>' . $copy;
if ($href !== '#') {
$content = '<a class="ac-shortcode-sale-link" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '">' . $content . '</a>';
}
$list .= '<li class="ac-shortcode-sale-item">' . $content . '</li>';
$position++;
}
$heading = htmlspecialchars((string)($attrs['title'] ?? 'Top Sellers'), ENT_QUOTES, 'UTF-8');
return '<section class="ac-shortcode-sale-chart"><header class="ac-shortcode-sale-head"><h3>' . $heading . '</h3></header><ol class="ac-shortcode-sale-list">' . $list . '</ol></section>';
});
Shortcodes::register('login-link', static function (array $attrs = []): string {
$label = trim((string)($attrs['label'] ?? 'Login'));
if ($label === '') {
$label = 'Login';
}
return '<a class="ac-shortcode-link ac-shortcode-link-login" href="/account">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a>';
});
Shortcodes::register('account-link', static function (array $attrs = []): string {
$label = trim((string)($attrs['label'] ?? 'My Account'));
if ($label === '') {
$label = 'My Account';
}
return '<a class="ac-shortcode-link ac-shortcode-link-account" href="/account">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a>';
});
Shortcodes::register('checkout-link', static function (array $attrs = []): string {
$label = trim((string)($attrs['label'] ?? 'Checkout'));
if ($label === '') {
$label = 'Checkout';
}
return '<a class="ac-shortcode-link ac-shortcode-link-checkout" href="/checkout">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a>';
});
Shortcodes::register('cart-link', static function (array $attrs = []): string {
$showCount = ((string)($attrs['show_count'] ?? '1')) !== '0';
$showTotal = ((string)($attrs['show_total'] ?? '1')) !== '0';
$label = trim((string)($attrs['label'] ?? 'Cart'));
if ($label === '') {
$label = 'Cart';
}
$count = 0;
$amount = 0.0;
$currency = strtoupper(trim((string)Settings::get('store_currency', 'GBP')));
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
$currency = 'GBP';
}
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$cart = is_array($_SESSION['ac_cart'] ?? null) ? $_SESSION['ac_cart'] : [];
foreach ($cart as $item) {
if (!is_array($item)) {
continue;
}
$qty = max(1, (int)($item['qty'] ?? 1));
$price = (float)($item['price'] ?? 0);
$count += $qty;
$amount += ($price * $qty);
}
$parts = [htmlspecialchars($label, ENT_QUOTES, 'UTF-8')];
if ($showCount) {
$parts[] = '<span class="ac-shortcode-cart-count">' . $count . '</span>';
}
if ($showTotal) {
$parts[] = '<span class="ac-shortcode-cart-total">' . htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') . ' ' . number_format($amount, 2) . '</span>';
}
return '<a class="ac-shortcode-link ac-shortcode-link-cart" href="/cart">' . implode(' ', $parts) . '</a>';
});
return function (Router $router): void {
$controller = new StoreController();
$router->get('/cart', [$controller, 'cartIndex']);
$router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']);
$router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']);
$router->get('/checkout', [$controller, 'checkoutIndex']);
$router->get('/account', [$controller, 'accountIndex']);
$router->post('/account/request-login', [$controller, 'accountRequestLogin']);
$router->get('/account/login', [$controller, 'accountLogin']);
$router->get('/account/logout', [$controller, 'accountLogout']);
$router->post('/checkout/place', [$controller, 'checkoutPlace']);
$router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']);
$router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']);
$router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']);
$router->get('/store/download', [$controller, 'download']);
$router->post('/cart/remove', [$controller, 'cartRemove']);
$router->get('/store/sales-chart/rebuild', [$controller, 'salesChartCron']);
$router->get('/admin/store', [$controller, 'adminIndex']);
$router->post('/admin/store/install', [$controller, 'adminInstall']);
$router->get('/admin/store/settings', [$controller, 'adminSettings']);
$router->post('/admin/store/settings', [$controller, 'adminSaveSettings']);
$router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']);
$router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']);
$router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']);
$router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']);
$router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']);
$router->get('/admin/store/customers', [$controller, 'adminCustomers']);
$router->get('/admin/store/orders', [$controller, 'adminOrders']);
$router->post('/admin/store/orders/create', [$controller, 'adminOrderCreate']);
$router->post('/admin/store/orders/status', [$controller, 'adminOrderStatus']);
$router->post('/admin/store/orders/refund', [$controller, 'adminOrderRefund']);
$router->post('/admin/store/orders/delete', [$controller, 'adminOrderDelete']);
$router->get('/admin/store/order', [$controller, 'adminOrderView']);
$router->post('/store/cart/add', [$controller, 'cartAdd']);
};

View File

@@ -0,0 +1,90 @@
<?php
$pageTitle = $title ?? 'Store Settings';
$settings = $settings ?? [];
$error = (string)($error ?? '');
$saved = (string)($saved ?? '');
$privateRootReady = (bool)($private_root_ready ?? false);
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Store Settings</h1>
<p style="color: var(--muted); margin-top:6px;">Payment, private downloads, and order defaults.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if ($error !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($saved !== ''): ?>
<div style="margin-top:12px; color:#9be7c6; font-size:13px;">Settings saved.</div>
<?php endif; ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<div class="admin-card" style="padding:16px;">
<div class="label">Currency</div>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($settings['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
<div class="label" style="margin-top:12px;">Private Download Root (outside public_html)</div>
<input class="input" name="store_private_root" value="<?= htmlspecialchars((string)($settings['store_private_root'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="/home/audiocore.site/private_downloads">
<div style="margin-top:8px; font-size:12px; color: <?= $privateRootReady ? '#9be7c6' : '#f3b0b0' ?>;">
<?= $privateRootReady ? 'Path is writable' : 'Path missing or not writable' ?>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px;">
<div>
<div class="label">Download Limit</div>
<input class="input" name="store_download_limit" value="<?= htmlspecialchars((string)($settings['store_download_limit'] ?? '5'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<div class="label">Expiry Days</div>
<input class="input" name="store_download_expiry_days" value="<?= htmlspecialchars((string)($settings['store_download_expiry_days'] ?? '30'), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
</div>
<div class="admin-card" style="padding:16px;">
<div class="label" style="margin-bottom:10px;">Payment Provider (Stripe)</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_test_mode" value="1" <?= ((string)($settings['store_test_mode'] ?? '1') === '1') ? 'checked' : '' ?>>
Test mode
</label>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em; margin-top:8px;">
<input type="checkbox" name="store_stripe_enabled" value="1" <?= ((string)($settings['store_stripe_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
Enable Stripe
</label>
<div class="label" style="margin-top:10px;">Stripe Public Key</div>
<input class="input" name="store_stripe_public_key" value="<?= htmlspecialchars((string)($settings['store_stripe_public_key'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="label" style="margin-top:10px;">Stripe Secret Key</div>
<input class="input" name="store_stripe_secret_key" value="<?= htmlspecialchars((string)($settings['store_stripe_secret_key'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="admin-card" style="padding:16px;">
<div class="label" style="margin-bottom:10px;">Payment Provider (PayPal)</div>
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_paypal_enabled" value="1" <?= ((string)($settings['store_paypal_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
Enable PayPal
</label>
<div class="label" style="margin-top:10px;">PayPal Client ID</div>
<input class="input" name="store_paypal_client_id" value="<?= htmlspecialchars((string)($settings['store_paypal_client_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="label" style="margin-top:10px;">PayPal Secret</div>
<input class="input" name="store_paypal_secret" value="<?= htmlspecialchars((string)($settings['store_paypal_secret'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn" type="submit">Save Settings</button>
</div>
</form>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,224 @@
<?php
$pageTitle = $title ?? 'Store Customers';
$customers = $customers ?? [];
$currency = (string)($currency ?? 'GBP');
$q = (string)($q ?? '');
ob_start();
?>
<section class="admin-card customers-page">
<div class="badge">Store</div>
<div class="customers-header">
<div>
<h1 class="customers-title">Customers</h1>
<p class="customers-sub">Customer activity, value, and latest order access.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div class="customers-tabs">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<form method="get" action="/admin/store/customers" class="customers-search">
<input type="text" name="q" value="<?= htmlspecialchars($q, ENT_QUOTES, 'UTF-8') ?>" placeholder="Search by email, name, or order number">
<button type="submit" class="btn small">Search</button>
<?php if ($q !== ''): ?>
<a href="/admin/store/customers" class="btn outline small">Clear</a>
<?php endif; ?>
</form>
<?php if (!$customers): ?>
<div class="customers-empty">No customers yet.</div>
<?php else: ?>
<div class="customers-table-wrap">
<table class="customers-table">
<thead>
<tr>
<th>Customer</th>
<th>Orders</th>
<th>Revenue</th>
<th>Latest Order</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
<?php foreach ($customers as $customer): ?>
<?php
$ips = is_array($customer['ips'] ?? null) ? $customer['ips'] : [];
$email = (string)($customer['email'] ?? '');
$lastOrderNo = (string)($customer['last_order_no'] ?? '');
$lastOrderId = (int)($customer['last_order_id'] ?? 0);
$lastSeen = (string)($customer['last_order_at'] ?? $customer['created_at'] ?? '');
?>
<tr>
<td>
<div class="customer-email"><?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?></div>
<?php if ($ips): ?>
<div class="customer-ips">
<?php foreach ($ips as $entry): ?>
<?php
$ip = (string)($entry['ip'] ?? '');
$ipLastSeen = (string)($entry['last_seen'] ?? '');
if ($ip === '') { continue; }
?>
<span class="ip-chip" title="<?= htmlspecialchars($ipLastSeen, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
<td class="num"><?= (int)($customer['order_count'] ?? 0) ?></td>
<td class="num"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($customer['revenue'] ?? 0), 2) ?></td>
<td>
<?php if ($lastOrderId > 0): ?>
<a href="/admin/store/order?id=<?= $lastOrderId ?>" class="order-link">
<?= htmlspecialchars($lastOrderNo !== '' ? $lastOrderNo : ('#' . $lastOrderId), ENT_QUOTES, 'UTF-8') ?>
</a>
<?php else: ?>
<span class="muted">No orders</span>
<?php endif; ?>
</td>
<td class="muted"><?= htmlspecialchars($lastSeen, ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<style>
.customers-page { display:grid; gap:14px; }
.customers-header { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-top:14px; }
.customers-title { margin:0; font-size:28px; line-height:1.1; }
.customers-sub { margin:6px 0 0; color:var(--muted); font-size:14px; }
.customers-tabs { display:flex; flex-wrap:wrap; gap:8px; }
.customers-search { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.customers-search input {
height:36px;
min-width:280px;
border-radius:10px;
border:1px solid rgba(255,255,255,.15);
background:rgba(255,255,255,.04);
color:#fff;
padding:0 12px;
}
.customers-empty {
padding:16px;
border-radius:12px;
border:1px solid rgba(255,255,255,.08);
background:rgba(0,0,0,.15);
color:var(--muted);
font-size:14px;
}
.customers-table-wrap {
border:1px solid rgba(255,255,255,.1);
border-radius:14px;
overflow:hidden;
background:rgba(0,0,0,.2);
}
.customers-table {
width:100%;
border-collapse:separate;
border-spacing:0;
table-layout:fixed;
}
.customers-table th {
text-align:left;
font-size:11px;
letter-spacing:.16em;
text-transform:uppercase;
color:var(--muted);
padding:14px 16px;
background:rgba(255,255,255,.03);
border-bottom:1px solid rgba(255,255,255,.1);
}
.customers-table td {
padding:14px 16px;
vertical-align:top;
border-bottom:1px solid rgba(255,255,255,.06);
font-size:14px;
}
.customers-table tbody tr:last-child td { border-bottom:none; }
.customers-table tbody tr:hover { background:rgba(255,255,255,.03); }
.customer-email {
font-size:16px;
font-weight:600;
line-height:1.25;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.customer-name {
margin-top:4px;
color:var(--muted);
font-size:13px;
}
.customer-ips {
display:flex;
flex-wrap:wrap;
gap:6px;
margin-top:8px;
}
.ip-chip {
display:inline-flex;
align-items:center;
padding:3px 8px;
border-radius:999px;
border:1px solid rgba(255,255,255,.14);
background:rgba(255,255,255,.04);
color:#d8def1;
font-size:11px;
line-height:1;
}
.num {
font-weight:600;
white-space:nowrap;
}
.order-link {
display:inline-flex;
max-width:100%;
color:#dce8ff;
text-decoration:none;
border-bottom:1px dashed rgba(220,232,255,.4);
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.order-link:hover { color:#fff; border-bottom-color:rgba(255,255,255,.8); }
.muted { color:var(--muted); }
@media (max-width: 980px) {
.customers-table th:nth-child(3),
.customers-table td:nth-child(3),
.customers-table th:nth-child(5),
.customers-table td:nth-child(5) { display:none; }
}
@media (max-width: 700px) {
.customers-header { flex-direction:column; }
.customers-table { table-layout:auto; }
.customers-table th:nth-child(2),
.customers-table td:nth-child(2) { display:none; }
.customer-email { font-size:15px; }
.customers-search input { min-width:100%; width:100%; }
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,124 @@
<?php
$pageTitle = $title ?? 'Store';
$tablesReady = (bool)($tables_ready ?? false);
$privateRoot = (string)($private_root ?? '');
$privateRootReady = (bool)($private_root_ready ?? false);
$stats = is_array($stats ?? null) ? $stats : [];
$recentOrders = is_array($recent_orders ?? null) ? $recent_orders : [];
$newCustomers = is_array($new_customers ?? null) ? $new_customers : [];
$currency = (string)($currency ?? 'GBP');
$totalOrders = (int)($stats['total_orders'] ?? 0);
$paidOrders = (int)($stats['paid_orders'] ?? 0);
$totalRevenue = (float)($stats['total_revenue'] ?? 0);
$totalCustomers = (int)($stats['total_customers'] ?? 0);
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Store</h1>
<p style="color: var(--muted); margin-top:6px;">Commerce layer for releases/tracks.</p>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<a href="/admin/store/settings" class="btn outline">Settings</a>
<a href="/admin/store/orders" class="btn outline">Orders</a>
<a href="/admin/store/customers" class="btn outline">Customers</a>
</div>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</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;">Store tables not initialized</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px;">Create store tables before configuring products and checkout.</div>
</div>
<form method="post" action="/admin/store/install">
<button type="submit" class="btn small">Create Tables</button>
</form>
</div>
<?php endif; ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="font-weight:600;">Private download root</div>
<div style="color: var(--muted); font-size:13px; margin-top:4px; font-family:'IBM Plex Mono', monospace;">
<?= htmlspecialchars($privateRoot, ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="margin-top:8px; font-size:12px; color: <?= $privateRootReady ? '#9be7c6' : '#f3b0b0' ?>;">
<?= $privateRootReady ? 'Ready' : 'Missing or not writable' ?>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:16px;">
<div class="admin-card" style="padding:14px;">
<div class="label">Total Orders</div>
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= $totalOrders ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">Paid: <?= $paidOrders ?></div>
</div>
<div class="admin-card" style="padding:14px;">
<div class="label">Revenue</div>
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($totalRevenue, 2) ?></div>
</div>
<div class="admin-card" style="padding:14px;">
<div class="label">Total Customers</div>
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= $totalCustomers ?></div>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:16px;">
<div class="admin-card" style="padding:14px;">
<div style="font-weight:600;">Last 5 purchases</div>
<?php if (!$recentOrders): ?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">No orders yet.</div>
<?php else: ?>
<div style="margin-top:10px; display:grid; gap:8px;">
<?php foreach ($recentOrders as $order): ?>
<div class="admin-card" style="padding:10px;">
<div style="display:flex; justify-content:space-between; gap:10px; font-size:13px;">
<strong><?= htmlspecialchars((string)($order['order_no'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= htmlspecialchars((string)($order['status'] ?? 'pending'), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div style="display:flex; justify-content:space-between; gap:10px; color:var(--muted); font-size:12px; margin-top:4px;">
<span><?= htmlspecialchars((string)($order['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span><?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></span>
</div>
<div style="margin-top:8px;">
<a href="/admin/store/order?id=<?= (int)($order['id'] ?? 0) ?>" class="btn outline small">View Order</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="admin-card" style="padding:14px;">
<div style="font-weight:600;">Top 5 new customers</div>
<?php if (!$newCustomers): ?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">No customers yet.</div>
<?php else: ?>
<div style="margin-top:10px; display:grid; gap:8px;">
<?php foreach ($newCustomers as $customer): ?>
<div class="admin-card" style="padding:10px;">
<div style="display:flex; justify-content:space-between; gap:10px; font-size:13px;">
<strong><?= htmlspecialchars((string)($customer['name'] ?? 'Customer'), ENT_QUOTES, 'UTF-8') ?></strong>
<span style="color:<?= (int)($customer['is_active'] ?? 0) === 1 ? '#9be7c6' : '#f3b0b0' ?>;">
<?= (int)($customer['is_active'] ?? 0) === 1 ? 'Active' : 'Disabled' ?>
</span>
</div>
<div style="color:var(--muted); font-size:12px; margin-top:4px;"><?= htmlspecialchars((string)($customer['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,123 @@
<?php
$pageTitle = $title ?? 'Order Detail';
$order = is_array($order ?? null) ? $order : [];
$items = is_array($items ?? null) ? $items : [];
$downloadsByItem = is_array($downloads_by_item ?? null) ? $downloads_by_item : [];
$downloadEvents = is_array($download_events ?? null) ? $download_events : [];
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Order Detail</h1>
<p style="color: var(--muted); margin-top:6px;">Full order breakdown, downloads, and IP history.</p>
</div>
<a href="/admin/store/orders" class="btn outline">Back to Orders</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<div class="admin-card" style="padding:14px; margin-top:16px;">
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px;">
<div>
<div class="label">Order Number</div>
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['order_no'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="label">Status</div>
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="label">Total</div>
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></div>
</div>
<div>
<div class="label">Customer Email</div>
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['email'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="label">Order IP</div>
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['customer_ip'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<div class="label">Created</div>
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['created_at'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
</div>
</div>
<div class="admin-card" style="padding:14px; margin-top:14px;">
<div style="font-weight:600;">Items</div>
<?php if (!$items): ?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">No items on this order.</div>
<?php else: ?>
<div style="margin-top:10px; display:grid; gap:10px;">
<?php foreach ($items as $item): ?>
<?php
$itemId = (int)($item['id'] ?? 0);
$downloadMeta = $downloadsByItem[$itemId] ?? ['count' => 0, 'ips' => []];
$ips = is_array($downloadMeta['ips'] ?? null) ? $downloadMeta['ips'] : [];
?>
<div class="admin-card" style="padding:12px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px;">
<div>
<div style="font-weight:700;"><?= htmlspecialchars((string)($item['title_snapshot'] ?? 'Item'), ENT_QUOTES, 'UTF-8') ?></div>
<div style="margin-top:4px; color:var(--muted); font-size:12px;">
<?= htmlspecialchars((string)($item['item_type'] ?? 'track'), ENT_QUOTES, 'UTF-8') ?> #<?= (int)($item['item_id'] ?? 0) ?>
| Qty <?= (int)($item['qty'] ?? 1) ?>
| <?= htmlspecialchars((string)($item['currency_snapshot'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($item['line_total'] ?? 0), 2) ?>
</div>
<?php if (!empty($item['file_name'])): ?>
<div style="margin-top:4px; color:var(--muted); font-size:12px;">File: <?= htmlspecialchars((string)$item['file_name'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
<div style="text-align:right;">
<div class="pill">Downloads <?= (int)($downloadMeta['count'] ?? 0) ?>/<?= (int)($item['download_limit'] ?? 0) ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:6px;">
Used <?= (int)($item['downloads_used'] ?? 0) ?><?= !empty($item['expires_at']) ? ' | Expires ' . htmlspecialchars((string)$item['expires_at'], ENT_QUOTES, 'UTF-8') : '' ?>
</div>
</div>
</div>
<?php if ($ips): ?>
<div style="display:flex; flex-wrap:wrap; gap:6px; margin-top:10px;">
<?php foreach ($ips as $ip): ?>
<span class="pill"><?= htmlspecialchars((string)$ip, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="admin-card" style="padding:14px; margin-top:14px;">
<div style="font-weight:600;">Download Activity</div>
<?php if (!$downloadEvents): ?>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">No download activity yet.</div>
<?php else: ?>
<div style="margin-top:10px; display:grid; gap:8px;">
<?php foreach ($downloadEvents as $event): ?>
<div class="admin-card" style="padding:10px; display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:10px; align-items:center;">
<div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($event['file_name'] ?? ('File #' . (int)($event['file_id'] ?? 0))), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:2px;">Item #<?= (int)($event['order_item_id'] ?? 0) ?> | <?= htmlspecialchars((string)($event['ip_address'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div style="font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($event['downloaded_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:11px; color:var(--muted); max-width:280px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
<?= htmlspecialchars((string)($event['user_agent'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,390 @@
<?php
$pageTitle = $title ?? 'Store Orders';
$orders = is_array($orders ?? null) ? $orders : [];
$q = (string)($q ?? '');
$saved = (string)($saved ?? '');
$error = (string)($error ?? '');
ob_start();
?>
<section class="admin-card store-orders">
<div class="badge">Store</div>
<div class="store-orders-head">
<div>
<h1>Orders</h1>
<p>Manage order status, refunds, and clean-up.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div class="store-orders-tabs">
<a href="/admin/store" class="btn outline small">Overview</a>
<a href="/admin/store/settings" class="btn outline small">Settings</a>
<a href="/admin/store/orders" class="btn small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a>
</div>
<?php if ($saved !== ''): ?>
<div class="store-orders-msg ok">Order update saved.</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="store-orders-msg error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<div class="store-orders-tools">
<form method="post" action="/admin/store/orders/create" class="store-orders-create" data-confirm="Create this manual order?">
<div class="label">Add Manual Order</div>
<div class="store-orders-create-grid">
<input name="email" type="email" required class="input" placeholder="customer@example.com">
<input name="currency" class="input" value="GBP" maxlength="3" placeholder="GBP">
<input name="total" class="input" value="0.00" placeholder="0.00">
<select name="status" class="input">
<option value="pending">pending</option>
<option value="paid">paid</option>
<option value="failed">failed</option>
<option value="refunded">refunded</option>
</select>
<button type="submit" class="btn small">Add Order</button>
</div>
</form>
<form method="get" action="/admin/store/orders" class="store-orders-search">
<input type="text" name="q" value="<?= htmlspecialchars($q, ENT_QUOTES, 'UTF-8') ?>" class="input" placeholder="Search by order number, email, status, or IP">
<button type="submit" class="btn small">Search</button>
<?php if ($q !== ''): ?>
<a href="/admin/store/orders" class="btn outline small">Clear</a>
<?php endif; ?>
</form>
</div>
<?php if (!$orders): ?>
<div class="store-orders-empty">No orders yet.</div>
<?php else: ?>
<div class="store-orders-list">
<?php foreach ($orders as $order): ?>
<?php
$status = (string)($order['status'] ?? 'pending');
?>
<article class="store-order-row">
<div class="store-order-main">
<a href="/admin/store/order?id=<?= (int)($order['id'] ?? 0) ?>" class="store-order-no">
<?= htmlspecialchars((string)($order['order_no'] ?? ('#' . (int)($order['id'] ?? 0))), ENT_QUOTES, 'UTF-8') ?>
</a>
<div class="store-order-meta">
<span><?= htmlspecialchars((string)($order['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span><?= htmlspecialchars((string)($order['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span><?= htmlspecialchars((string)($order['customer_ip'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></span>
</div>
</div>
<div class="store-order-amount">
<?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>
<?= number_format((float)($order['total'] ?? 0), 2) ?>
</div>
<div class="store-order-status pill"><?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></div>
<div class="store-order-actions">
<form method="post" action="/admin/store/orders/status" class="store-order-status-form" data-confirm="Update this order status?">
<input type="hidden" name="id" value="<?= (int)($order['id'] ?? 0) ?>">
<select name="status" class="input">
<option value="pending" <?= $status === 'pending' ? 'selected' : '' ?>>pending</option>
<option value="paid" <?= $status === 'paid' ? 'selected' : '' ?>>paid</option>
<option value="failed" <?= $status === 'failed' ? 'selected' : '' ?>>failed</option>
<option value="refunded" <?= $status === 'refunded' ? 'selected' : '' ?>>refunded</option>
</select>
<button class="btn outline small" type="submit">Update</button>
</form>
<form method="post" action="/admin/store/orders/refund" data-confirm="Refund this order now? Download access will be revoked.">
<input type="hidden" name="id" value="<?= (int)($order['id'] ?? 0) ?>">
<button class="btn outline small store-order-refund" type="submit">Refund</button>
</form>
<form method="post" action="/admin/store/orders/delete" data-confirm="Delete this order and all related downloads? This cannot be undone.">
<input type="hidden" name="id" value="<?= (int)($order['id'] ?? 0) ?>">
<button class="btn outline small store-order-delete" type="submit">Delete</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<div id="acConfirmModal" class="ac-confirm-modal" hidden>
<div class="ac-confirm-backdrop"></div>
<div class="ac-confirm-dialog">
<div class="badge">Confirm</div>
<p id="acConfirmText">Are you sure?</p>
<div class="ac-confirm-actions">
<button type="button" class="btn outline small" id="acConfirmCancel">Cancel</button>
<button type="button" class="btn small" id="acConfirmOk">Confirm</button>
</div>
</div>
</div>
<style>
.store-orders {
display: grid;
gap: 12px;
}
.store-orders-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.store-orders-head h1 {
margin: 0;
font-size: 28px;
}
.store-orders-head p {
margin: 6px 0 0;
color: var(--muted);
}
.store-orders-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.store-orders-msg {
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
}
.store-orders-msg.ok {
color: #9be7c6;
background: rgba(34, 242, 165, .08);
border: 1px solid rgba(34, 242, 165, .25);
}
.store-orders-msg.error {
color: #f3b0b0;
background: rgba(243, 176, 176, .08);
border: 1px solid rgba(243, 176, 176, .25);
}
.store-orders-tools {
display: grid;
gap: 10px;
}
.store-orders-create,
.store-orders-search {
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, .1);
background: rgba(255, 255, 255, .02);
}
.store-orders-create-grid {
margin-top: 8px;
display: grid;
grid-template-columns: minmax(0, 1.8fr) 100px 110px 130px 120px;
gap: 8px;
}
.store-orders-search {
display: flex;
align-items: center;
gap: 8px;
}
.store-orders-search .input {
min-width: 320px;
}
.store-orders-empty {
color: var(--muted);
font-size: 13px;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, .08);
}
.store-orders-list {
display: grid;
gap: 10px;
}
.store-order-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 14px;
align-items: center;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, .1);
background: rgba(255, 255, 255, .02);
}
.store-order-no {
color: var(--text);
text-decoration: none;
font-weight: 700;
font-size: 20px;
letter-spacing: .01em;
display: inline-block;
line-height: 1.2;
word-break: keep-all;
overflow-wrap: normal;
}
.store-order-no:hover {
color: #fff;
}
.store-order-meta {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.store-order-amount {
font-size: 22px;
font-weight: 700;
white-space: nowrap;
}
.store-order-status {
justify-self: start;
}
.store-order-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.store-order-status-form {
display: inline-flex;
gap: 8px;
align-items: center;
}
.store-order-status-form .input {
width: 140px;
min-width: 140px;
}
.store-order-refund {
border-color: rgba(140, 235, 195, .45);
color: #b8f6dc;
}
.store-order-delete {
border-color: rgba(255, 120, 120, .45);
color: #ffb9b9;
}
@media (max-width: 1100px) {
.store-orders-create-grid {
grid-template-columns: 1fr 100px 110px 130px 120px;
}
}
@media (max-width: 900px) {
.store-order-row {
grid-template-columns: 1fr;
}
.store-order-amount {
font-size: 18px;
}
.store-orders-create-grid {
grid-template-columns: 1fr 100px 100px;
}
.store-orders-create-grid .btn {
grid-column: 1 / -1;
justify-self: start;
}
.store-orders-search {
flex-wrap: wrap;
}
.store-orders-search .input {
min-width: 0;
width: 100%;
}
}
.ac-confirm-modal {
position: fixed;
inset: 0;
z-index: 4000;
display: grid;
place-items: center;
}
.ac-confirm-modal[hidden] {
display: none !important;
}
.ac-confirm-backdrop {
position: absolute;
inset: 0;
background: rgba(5, 8, 14, .66);
backdrop-filter: blur(2px);
}
.ac-confirm-dialog {
position: relative;
width: min(480px, calc(100vw - 32px));
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, .12);
background: linear-gradient(160deg, rgba(24, 28, 39, .98), rgba(18, 22, 32, .98));
box-shadow: 0 24px 60px rgba(0, 0, 0, .45);
display: grid;
gap: 10px;
}
.ac-confirm-dialog p {
margin: 0;
color: #d8def0;
line-height: 1.45;
}
.ac-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<script>
(function () {
const modal = document.getElementById('acConfirmModal');
const txt = document.getElementById('acConfirmText');
const btnCancel = document.getElementById('acConfirmCancel');
const btnOk = document.getElementById('acConfirmOk');
if (!modal || !txt || !btnCancel || !btnOk) return;
let targetForm = null;
const forms = document.querySelectorAll('form[data-confirm]');
function closeModal() {
modal.setAttribute('hidden', 'hidden');
targetForm = null;
}
function openModal(form) {
targetForm = form;
txt.textContent = form.getAttribute('data-confirm') || 'Are you sure?';
modal.removeAttribute('hidden');
}
forms.forEach((form) => {
form.addEventListener('submit', function (event) {
if (form.dataset.confirmed === '1') {
form.dataset.confirmed = '0';
return;
}
event.preventDefault();
openModal(form);
});
});
btnCancel.addEventListener('click', closeModal);
btnOk.addEventListener('click', function () {
if (targetForm) {
const f = targetForm;
f.dataset.confirmed = '1';
closeModal();
HTMLFormElement.prototype.submit.call(f);
} else {
closeModal();
}
});
modal.addEventListener('click', function (event) {
if (event.target.classList.contains('ac-confirm-backdrop')) {
closeModal();
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !modal.hasAttribute('hidden')) {
closeModal();
}
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,311 @@
<?php
$pageTitle = $title ?? 'Store Settings';
$settings = $settings ?? [];
$gateways = is_array($gateways ?? null) ? $gateways : [];
$error = (string)($error ?? '');
$saved = (string)($saved ?? '');
$tab = (string)($tab ?? 'general');
$tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general';
$paypalTest = (string)($_GET['paypal_test'] ?? '');
$privateRootReady = (bool)($private_root_ready ?? false);
$discounts = is_array($discounts ?? null) ? $discounts : [];
$chartRows = is_array($chart_rows ?? null) ? $chart_rows : [];
$chartLastRebuildAt = (string)($chart_last_rebuild_at ?? '');
$chartCronUrl = (string)($chart_cron_url ?? '');
$chartCronCmd = (string)($chart_cron_cmd ?? '');
ob_start();
?>
<section class="admin-card">
<div class="badge">Store</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;">Store Settings</h1>
<p style="color: var(--muted); margin-top:6px;">Configure defaults, payments, and transactional emails.</p>
</div>
<a href="/admin/store" class="btn outline">Back</a>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
<a href="/admin/store/settings?tab=general" class="btn <?= $tab === 'general' ? '' : 'outline' ?> small">General</a>
<a href="/admin/store/settings?tab=payments" class="btn <?= $tab === 'payments' ? '' : 'outline' ?> small">Payments</a>
<a href="/admin/store/settings?tab=emails" class="btn <?= $tab === 'emails' ? '' : 'outline' ?> small">Emails</a>
<a href="/admin/store/settings?tab=discounts" class="btn <?= $tab === 'discounts' ? '' : 'outline' ?> small">Discounts</a>
<a href="/admin/store/settings?tab=sales_chart" class="btn <?= $tab === 'sales_chart' ? '' : 'outline' ?> small">Sales Chart</a>
</div>
<?php if ($error !== ''): ?>
<div style="margin-top:12px; color:#f3b0b0; font-size:13px;"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($saved !== ''): ?>
<div style="margin-top:12px; color:#9be7c6; font-size:13px;">Settings saved.</div>
<?php endif; ?>
<?php if ($paypalTest === 'live' || $paypalTest === 'sandbox'): ?>
<div style="margin-top:12px; color:#9be7c6; font-size:13px;">PayPal <?= htmlspecialchars($paypalTest, ENT_QUOTES, 'UTF-8') ?> credentials are valid.</div>
<?php endif; ?>
<?php if ($tab === 'general'): ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="tab" value="general">
<div class="admin-card" style="padding:16px;">
<div class="label">Currency</div>
<input class="input" name="store_currency" value="<?= htmlspecialchars((string)($settings['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" placeholder="GBP">
<div class="label" style="margin-top:12px;">Private Download Root (outside public_html)</div>
<input class="input" name="store_private_root" value="<?= htmlspecialchars((string)($settings['store_private_root'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="/home/audiocore.site/private_downloads">
<div style="margin-top:8px; font-size:12px; color: <?= $privateRootReady ? '#9be7c6' : '#f3b0b0' ?>;">
<?= $privateRootReady ? 'Path is writable' : 'Path missing or not writable' ?>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px;">
<div>
<div class="label">Download Limit</div>
<input class="input" name="store_download_limit" value="<?= htmlspecialchars((string)($settings['store_download_limit'] ?? '5'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<div class="label">Expiry Days</div>
<input class="input" name="store_download_expiry_days" value="<?= htmlspecialchars((string)($settings['store_download_expiry_days'] ?? '30'), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div class="label" style="margin-top:12px;">Order Number Prefix</div>
<input class="input" name="store_order_prefix" value="<?= htmlspecialchars((string)($settings['store_order_prefix'] ?? 'AC-ORD'), ENT_QUOTES, 'UTF-8') ?>" placeholder="AC-ORD">
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn" type="submit">Save General Settings</button>
</div>
</form>
<?php elseif ($tab === 'payments'): ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="tab" value="payments">
<div class="admin-card" style="padding:16px;">
<div class="label" style="margin-bottom:10px;">Payment Mode</div>
<input type="hidden" name="store_test_mode" value="0">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_test_mode" value="1" <?= ((string)($settings['store_test_mode'] ?? '1') === '1') ? 'checked' : '' ?>>
Test Mode (Sandbox)
</label>
</div>
<div class="admin-card" style="padding:16px;">
<div class="label" style="margin-bottom:10px;">PayPal</div>
<input type="hidden" name="store_paypal_enabled" value="0">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
<input type="checkbox" name="store_paypal_enabled" value="1" <?= ((string)($settings['store_paypal_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
Enable PayPal
</label>
<div class="label" style="margin-top:10px;">PayPal Client ID</div>
<input class="input" name="store_paypal_client_id" value="<?= htmlspecialchars((string)($settings['store_paypal_client_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="label" style="margin-top:10px;">PayPal Secret</div>
<input class="input" name="store_paypal_secret" value="<?= htmlspecialchars((string)($settings['store_paypal_secret'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="live" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Live</button>
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="sandbox" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Sandbox</button>
<button class="btn" type="submit">Save Payment Settings</button>
</div>
</div>
</form>
<?php elseif ($tab === 'emails'): ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="tab" value="emails">
<div class="admin-card" style="padding:16px;">
<div class="label" style="margin-bottom:10px;">Order Email Template</div>
<div class="label">Email Logo URL</div>
<input class="input" name="store_email_logo_url" value="<?= htmlspecialchars((string)($settings['store_email_logo_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://example.com/logo.png">
<div class="label" style="margin-top:10px;">Subject</div>
<input class="input" name="store_order_email_subject" value="<?= htmlspecialchars((string)($settings['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}'), ENT_QUOTES, 'UTF-8') ?>">
<div class="label" style="margin-top:10px;">HTML Body</div>
<textarea class="input" name="store_order_email_html" rows="10" style="resize:vertical; font-family:'IBM Plex Mono',monospace; font-size:12px;"><?= htmlspecialchars((string)($settings['store_order_email_html'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
<div style="margin-top:8px; color:var(--muted); font-size:12px;">
Placeholders: <code>{{site_name}}</code>, <code>{{order_no}}</code>, <code>{{customer_email}}</code>, <code>{{currency}}</code>, <code>{{total}}</code>, <code>{{status}}</code>, <code>{{logo_url}}</code>, <code>{{logo_html}}</code>, <code>{{items_html}}</code>, <code>{{download_links_html}}</code>
</div>
<div style="margin-top:14px; display:grid; gap:10px; max-width:460px;">
<div class="label">Send Test Email To</div>
<input class="input" type="email" name="test_email_to" placeholder="you@example.com">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="btn outline small" type="submit" formaction="/admin/store/settings/test-email" formmethod="post">Send Test Email</button>
<button class="btn" type="submit">Save Email Settings</button>
</div>
</div>
</div>
</form>
<?php elseif ($tab === 'discounts'): ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div class="label" style="margin-bottom:10px;">Create Discount Code</div>
<form method="post" action="/admin/store/discounts/create" style="display:grid; gap:12px;">
<div style="display:grid; grid-template-columns:1.2fr 1fr 0.8fr 0.9fr 1.2fr; gap:10px;">
<div>
<div class="label" style="font-size:10px;">Code</div>
<input class="input" name="code" placeholder="SAVE10" maxlength="32" required>
</div>
<div>
<div class="label" style="font-size:10px;">Discount Type</div>
<select class="input" name="discount_type">
<option value="percent">Percent</option>
<option value="fixed">Fixed Amount</option>
</select>
</div>
<div>
<div class="label" style="font-size:10px;">Value</div>
<input class="input" name="discount_value" value="10" required>
</div>
<div>
<div class="label" style="font-size:10px;">Max Uses</div>
<input class="input" name="max_uses" value="0" required>
</div>
<div>
<div class="label" style="font-size:10px;">Expires At</div>
<input class="input" type="datetime-local" name="expires_at">
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.16em;">
<input type="checkbox" name="is_active" value="1" checked> Active
</label>
<button class="btn small" type="submit">Save Code</button>
</div>
</form>
<div style="margin-top:8px; color:var(--muted); font-size:12px;">Max uses: <strong>0</strong> means unlimited. Leave expiry blank for no expiry.</div>
</div>
<div class="admin-card" style="margin-top:12px; padding:14px;">
<div class="label" style="margin-bottom:10px;">Existing Codes</div>
<?php if (!$discounts): ?>
<div style="color:var(--muted); font-size:13px;">No discount codes yet.</div>
<?php else: ?>
<div style="overflow:auto;">
<table style="width:100%; border-collapse:separate; border-spacing:0 8px;">
<thead>
<tr style="color:var(--muted); font-size:10px; letter-spacing:.14em; text-transform:uppercase; text-align:left;">
<th style="padding:0 10px;">Code</th>
<th style="padding:0 10px;">Type</th>
<th style="padding:0 10px;">Value</th>
<th style="padding:0 10px;">Usage</th>
<th style="padding:0 10px;">Expires</th>
<th style="padding:0 10px;">Status</th>
<th style="padding:0 10px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($discounts as $d): ?>
<tr style="background:rgba(255,255,255,.02);">
<td style="padding:10px; border:1px solid rgba(255,255,255,.08); border-right:none; border-radius:10px 0 0 10px; font-weight:700;"><?= htmlspecialchars((string)($d['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); color:var(--muted); font-size:12px;"><?= htmlspecialchars((string)($d['discount_type'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); font-size:12px;"><?= number_format((float)($d['discount_value'] ?? 0), 2) ?></td>
<td style="padding:10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); font-size:12px; color:var(--muted);"><?= (int)($d['used_count'] ?? 0) ?>/<?= (int)($d['max_uses'] ?? 0) === 0 ? 'INF' : (int)($d['max_uses'] ?? 0) ?></td>
<td style="padding:10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); font-size:12px; color:var(--muted);"><?= htmlspecialchars((string)($d['expires_at'] ?? 'No expiry'), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08);"><span class="pill"><?= (int)($d['is_active'] ?? 0) === 1 ? 'active' : 'off' ?></span></td>
<td style="padding:10px; border:1px solid rgba(255,255,255,.08); border-left:none; border-radius:0 10px 10px 0; text-align:right;">
<form method="post" action="/admin/store/discounts/delete" onsubmit="return confirm('Delete this discount code?');" style="display:inline-flex;">
<input type="hidden" name="id" value="<?= (int)($d['id'] ?? 0) ?>">
<button class="btn outline small" type="submit" style="border-color:rgba(255,120,120,.45); color:#ffb9b9;">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="tab" value="sales_chart">
<div class="admin-card" style="padding:16px; display:grid; gap:12px;">
<div class="label" style="margin-bottom:6px;">Sales Chart Defaults</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px;">
<div>
<div class="label" style="font-size:10px;">Default Type</div>
<select class="input" name="store_sales_chart_default_scope">
<option value="tracks" <?= ((string)($settings['store_sales_chart_default_scope'] ?? 'tracks') === 'tracks') ? 'selected' : '' ?>>Tracks</option>
<option value="releases" <?= ((string)($settings['store_sales_chart_default_scope'] ?? 'tracks') === 'releases') ? 'selected' : '' ?>>Releases</option>
</select>
</div>
<div>
<div class="label" style="font-size:10px;">Default Window</div>
<select class="input" name="store_sales_chart_default_window">
<option value="latest" <?= ((string)($settings['store_sales_chart_default_window'] ?? 'latest') === 'latest') ? 'selected' : '' ?>>Latest (rolling)</option>
<option value="weekly" <?= ((string)($settings['store_sales_chart_default_window'] ?? 'latest') === 'weekly') ? 'selected' : '' ?>>Weekly</option>
<option value="all_time" <?= ((string)($settings['store_sales_chart_default_window'] ?? 'latest') === 'all_time') ? 'selected' : '' ?>>All time</option>
</select>
</div>
<div>
<div class="label" style="font-size:10px;">Default Limit</div>
<input class="input" name="store_sales_chart_limit" value="<?= htmlspecialchars((string)($settings['store_sales_chart_limit'] ?? '10'), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<div>
<div class="label" style="font-size:10px;">Latest Window Hours</div>
<input class="input" name="store_sales_chart_latest_hours" value="<?= htmlspecialchars((string)($settings['store_sales_chart_latest_hours'] ?? '24'), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div>
<div class="label" style="font-size:10px;">Cron Refresh Minutes</div>
<input class="input" name="store_sales_chart_refresh_minutes" value="<?= htmlspecialchars((string)($settings['store_sales_chart_refresh_minutes'] ?? '180'), ENT_QUOTES, 'UTF-8') ?>">
</div>
</div>
<div class="label" style="font-size:10px;">Cron Key</div>
<div style="display:grid; grid-template-columns:1fr auto; gap:8px;">
<input class="input" value="<?= htmlspecialchars((string)($settings['store_sales_chart_cron_key'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" readonly>
<button class="btn outline small" type="submit" name="store_sales_chart_regen_key" value="1">Regenerate</button>
</div>
<div class="label" style="font-size:10px;">Cron URL (dynamic)</div>
<input class="input" value="<?= htmlspecialchars($chartCronUrl, ENT_QUOTES, 'UTF-8') ?>" readonly>
<div class="label" style="font-size:10px;">Crontab Line</div>
<textarea class="input" rows="2" style="resize:vertical; font-family:'IBM Plex Mono',monospace; font-size:12px;" readonly><?= htmlspecialchars($chartCronCmd, ENT_QUOTES, 'UTF-8') ?></textarea>
<div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end;">
<button class="btn outline small" type="submit" formaction="/admin/store/settings/rebuild-sales-chart" formmethod="post">Rebuild Now</button>
<button class="btn" type="submit">Save Sales Chart Settings</button>
</div>
</div>
</form>
<div class="admin-card" style="margin-top:12px; padding:14px;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:10px;">
<div class="label">Current Chart Snapshot</div>
<div style="font-size:12px; color:var(--muted);">
Last rebuild: <?= $chartLastRebuildAt !== '' ? htmlspecialchars($chartLastRebuildAt, ENT_QUOTES, 'UTF-8') : 'Never' ?>
</div>
</div>
<?php if (!$chartRows): ?>
<div style="color:var(--muted); font-size:13px;">No chart rows yet. Run rebuild once.</div>
<?php else: ?>
<div style="overflow:auto;">
<table style="width:100%; border-collapse:separate; border-spacing:0 6px;">
<thead>
<tr style="color:var(--muted); font-size:10px; letter-spacing:.14em; text-transform:uppercase; text-align:left;">
<th style="padding:0 10px;">#</th>
<th style="padding:0 10px;">Item</th>
<th style="padding:0 10px;">Units</th>
<th style="padding:0 10px;">Revenue</th>
<th style="padding:0 10px;">Window</th>
</tr>
</thead>
<tbody>
<?php foreach ($chartRows as $row): ?>
<tr style="background:rgba(255,255,255,.02);">
<td style="padding:9px 10px; border:1px solid rgba(255,255,255,.08); border-right:none; border-radius:10px 0 0 10px; font-family:'IBM Plex Mono',monospace; font-size:12px;"><?= (int)($row['rank_no'] ?? 0) ?></td>
<td style="padding:9px 10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08);"><?= htmlspecialchars((string)($row['item_label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td>
<td style="padding:9px 10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); color:var(--muted); font-size:12px;"><?= (int)($row['units'] ?? 0) ?></td>
<td style="padding:9px 10px; border-top:1px solid rgba(255,255,255,.08); border-bottom:1px solid rgba(255,255,255,.08); color:var(--muted); font-size:12px;"><?= htmlspecialchars((string)($settings['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['revenue'] ?? 0), 2) ?></td>
<td style="padding:9px 10px; border:1px solid rgba(255,255,255,.08); border-left:none; border-radius:0 10px 10px 0; color:var(--muted); font-size:12px;">
<?= htmlspecialchars((string)($row['snapshot_from'] ?? 'all'), ENT_QUOTES, 'UTF-8') ?> -> <?= htmlspecialchars((string)($row['snapshot_to'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Account';
$isLoggedIn = (bool)($is_logged_in ?? false);
$email = (string)($email ?? '');
$orders = is_array($orders ?? null) ? $orders : [];
$downloads = is_array($downloads ?? null) ? $downloads : [];
$message = (string)($message ?? '');
$error = (string)($error ?? '');
$downloadLimit = (int)($download_limit ?? 5);
$downloadExpiryDays = (int)($download_expiry_days ?? 30);
$orderCount = count($orders);
$downloadCount = count($downloads);
$downloadsByOrder = [];
$nowTs = time();
foreach ($downloads as $d) {
if (!is_array($d)) {
continue;
}
$orderNo = (string)($d['order_no'] ?? '');
if ($orderNo === '') {
$orderNo = '__unknown__';
}
if (!isset($downloadsByOrder[$orderNo])) {
$downloadsByOrder[$orderNo] = [];
}
$downloadsByOrder[$orderNo][] = $d;
}
ob_start();
?>
<section class="card account-wrap">
<div class="badge">Store</div>
<div class="account-title-row">
<h1 style="margin:0; font-size:32px;">Account</h1>
<span class="account-subtle">Download hub</span>
</div>
<?php if ($message !== ''): ?>
<div class="account-alert success"><?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="account-alert error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if (!$isLoggedIn): ?>
<div class="account-grid">
<div class="account-panel">
<div class="badge" style="font-size:9px;">Login</div>
<p class="account-copy">Enter your order email and we will send a secure one-time access link.</p>
<form method="post" action="/account/request-login" class="account-form">
<label class="account-label">Email</label>
<input name="email" type="email" class="account-input" placeholder="you@example.com" required>
<button type="submit" class="account-btn">Send Login Link</button>
</form>
</div>
<div class="account-panel">
<div class="badge" style="font-size:9px;">Download Policy</div>
<ul class="account-policy-list">
<li>Each file can be downloaded up to <?= $downloadLimit ?> times.</li>
<li>Download links expire after <?= $downloadExpiryDays ?> days.</li>
<li>After expiry or limit, a new order is required.</li>
</ul>
</div>
</div>
<?php else: ?>
<div class="account-panel account-panel-head">
<div>
<div class="badge" style="font-size:9px;">Signed In</div>
<div class="account-email" title="<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div class="account-actions">
<div class="account-stat">
<span class="account-stat-label">Orders</span>
<strong><?= $orderCount ?></strong>
</div>
<div class="account-stat">
<span class="account-stat-label">Files</span>
<strong><?= $downloadCount ?></strong>
</div>
<a class="account-logout" href="/account/logout">Logout</a>
</div>
</div>
<div class="account-panel">
<div class="badge" style="font-size:9px;">Orders</div>
<?php if (!$orders): ?>
<p class="account-copy">No orders found for this account.</p>
<?php else: ?>
<div class="account-list">
<?php foreach ($orders as $idx => $order): ?>
<?php
$orderNo = (string)($order['order_no'] ?? '');
$orderDownloads = $downloadsByOrder[$orderNo] ?? [];
$activeCount = 0;
foreach ($orderDownloads as $dl) {
$limit = max(0, (int)($dl['download_limit'] ?? 0));
$used = max(0, (int)($dl['downloads_used'] ?? 0));
$remaining = $limit > 0 ? max(0, $limit - $used) : 0;
$expires = trim((string)($dl['expires_at'] ?? ''));
$expired = false;
if ($expires !== '') {
$expTs = strtotime($expires);
if ($expTs !== false && $expTs < $nowTs) {
$expired = true;
}
}
if ($remaining > 0 && !$expired) {
$activeCount++;
}
}
$hasDownloads = !empty($orderDownloads);
$isExpired = $hasDownloads && $activeCount === 0;
$modalId = 'orderDlModal' . $idx;
?>
<div class="account-order-row">
<div>
<div class="account-line-title"><?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
<div class="account-line-meta">
<span class="account-status-pill"><?= htmlspecialchars((string)($order['status'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span><?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></span>
<span><?= htmlspecialchars((string)($order['created_at'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($hasDownloads): ?>
<span><?= $activeCount ?> active download<?= $activeCount === 1 ? '' : 's' ?></span>
<?php endif; ?>
</div>
</div>
<div class="account-order-right">
<?php if (!$hasDownloads): ?>
<button type="button" class="account-download-btn is-disabled" disabled>No Downloads</button>
<?php elseif ($isExpired): ?>
<button type="button" class="account-download-btn is-expired" disabled>Expired</button>
<?php else: ?>
<button type="button" class="account-download-btn" data-open-modal="<?= htmlspecialchars($modalId, ENT_QUOTES, 'UTF-8') ?>">Downloads</button>
<?php endif; ?>
</div>
</div>
<?php if ($hasDownloads): ?>
<div id="<?= htmlspecialchars($modalId, ENT_QUOTES, 'UTF-8') ?>" class="account-modal" aria-hidden="true">
<div class="account-modal-backdrop" data-close-modal="<?= htmlspecialchars($modalId, ENT_QUOTES, 'UTF-8') ?>"></div>
<div class="account-modal-card" role="dialog" aria-modal="true" aria-label="Order downloads">
<div class="account-modal-head">
<div>
<div class="badge" style="font-size:9px;">Order Downloads</div>
<div class="account-modal-title"><?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
</div>
<button type="button" class="account-modal-close" data-close-modal="<?= htmlspecialchars($modalId, ENT_QUOTES, 'UTF-8') ?>">&times;</button>
</div>
<div class="account-modal-list">
<?php foreach ($orderDownloads as $dl): ?>
<?php
$limit = max(0, (int)($dl['download_limit'] ?? 0));
$used = max(0, (int)($dl['downloads_used'] ?? 0));
$remaining = $limit > 0 ? max(0, $limit - $used) : 0;
$expires = trim((string)($dl['expires_at'] ?? ''));
$expired = false;
if ($expires !== '') {
$expTs = strtotime($expires);
if ($expTs !== false && $expTs < $nowTs) {
$expired = true;
}
}
$canDownload = ($remaining > 0 && !$expired);
$dlUrl = (string)($dl['url'] ?? '#');
?>
<div class="account-modal-item">
<div>
<div class="account-line-title"><?= htmlspecialchars((string)($dl['file_name'] ?? 'Download'), ENT_QUOTES, 'UTF-8') ?></div>
<div class="account-line-meta">
Remaining: <?= $remaining ?>
<?php if ($expires !== ''): ?>
&middot; Expires: <?= htmlspecialchars($expires, ENT_QUOTES, 'UTF-8') ?>
<?php endif; ?>
</div>
</div>
<?php if ($canDownload): ?>
<a href="<?= htmlspecialchars($dlUrl, ENT_QUOTES, 'UTF-8') ?>" class="account-download-btn">Download</a>
<?php else: ?>
<button type="button" class="account-download-btn is-expired" disabled>Expired</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</section>
<style>
.account-wrap { display:grid; gap:14px; }
.account-title-row {
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
flex-wrap:wrap;
}
.account-subtle {
color:var(--muted);
font-size:12px;
letter-spacing:.14em;
text-transform:uppercase;
}
.account-grid { display:grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap:14px; }
.account-panel { padding:18px; border-radius:14px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); }
.account-panel-head { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; flex-wrap:wrap; }
.account-alert { padding:14px; border-radius:12px; font-weight:600; }
.account-alert.success { border:1px solid rgba(34,242,165,.4); background:rgba(34,242,165,.12); color:#d3ffef; }
.account-alert.error { border:1px solid rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6; }
.account-email {
margin-top:8px;
font-weight:700;
font-size:36px;
line-height:1.05;
max-width:100%;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.account-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.account-stat { min-width:96px; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); text-align:center; }
.account-stat-label { display:block; font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
.account-logout { display:inline-flex; align-items:center; justify-content:center; height:42px; padding:0 20px; border-radius:999px; border:1px solid rgba(255,255,255,.2); background:rgba(255,255,255,.06); color:#fff; text-decoration:none; font-size:12px; text-transform:uppercase; letter-spacing:.14em; font-weight:700; }
.account-logout:hover { background:rgba(255,255,255,.14); }
.account-list { display:grid; gap:10px; margin-top:10px; }
.account-order-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:14px; align-items:center; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
.account-order-right { display:grid; gap:8px; justify-items:end; }
.account-line-title { font-weight:600; font-size:22px; line-height:1.2; }
.account-line-meta { color:var(--muted); font-size:13px; margin-top:6px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
.account-status-pill {
display:inline-flex;
align-items:center;
padding:3px 8px;
border-radius:999px;
border:1px solid rgba(255,255,255,.18);
background:rgba(255,255,255,.05);
text-transform:uppercase;
letter-spacing:.08em;
font-size:10px;
color:#cfd5e7;
}
.account-download-btn { display:inline-flex; align-items:center; justify-content:center; height:40px; padding:0 20px; border-radius:999px; border:1px solid rgba(34,242,165,.5); background:rgba(34,242,165,.22); color:#d7ffef; text-decoration:none; text-transform:uppercase; letter-spacing:.14em; font-size:11px; font-weight:700; white-space:nowrap; }
.account-download-btn:hover { background:rgba(34,242,165,.32); }
.account-download-btn.is-expired,
.account-download-btn.is-disabled { border-color:rgba(255,255,255,.18); background:rgba(255,255,255,.08); color:rgba(255,255,255,.55); cursor:not-allowed; }
.account-copy { margin:10px 0 0; color:var(--muted); }
.account-form { display:grid; gap:10px; max-width:460px; }
.account-label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em; }
.account-input { height:44px; border-radius:10px; border:1px solid rgba(255,255,255,.2); background:rgba(255,255,255,.05); color:#fff; padding:0 12px; }
.account-btn{ height:42px; border-radius:999px; border:1px solid rgba(34,242,165,.45); background:rgba(34,242,165,.18); color:#cbfff1; font-weight:700; letter-spacing:.12em; text-transform:uppercase; cursor:pointer; max-width:260px; }
.account-btn:hover { background:rgba(34,242,165,.28); }
.account-policy-list { margin:10px 0 0; padding-left:18px; color:var(--muted); line-height:1.7; max-width:560px; }
.account-modal { position:fixed; inset:0; display:none; z-index:2000; }
.account-modal.is-open { display:block; }
.account-modal-backdrop { position:absolute; inset:0; background:rgba(0,0,0,.7); }
.account-modal-card { position:relative; max-width:760px; margin:6vh auto; width:calc(100% - 24px); max-height:88vh; overflow:auto; background:#12141b; border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:16px; box-shadow:0 24px 64px rgba(0,0,0,.55); }
.account-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; margin-bottom:12px; }
.account-modal-title { margin-top:8px; font-weight:700; font-size:20px; }
.account-modal-close { width:38px; height:38px; border-radius:10px; border:1px solid rgba(255,255,255,.2); background:rgba(255,255,255,.08); color:#fff; font-size:20px; line-height:1; cursor:pointer; }
.account-modal-list { display:grid; gap:10px; }
.account-modal-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; padding:12px; border-radius:10px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
@media (max-width: 980px) {
.account-grid { grid-template-columns: 1fr; }
.account-email { font-size:30px; }
}
@media (max-width: 640px) {
.account-email { font-size:24px; }
.account-actions { width:100%; }
.account-logout { width:100%; }
.account-order-row,
.account-modal-item { grid-template-columns: 1fr; }
.account-line-total { justify-self:start; }
.account-download-btn { width:100%; }
.account-order-right { justify-items:stretch; width:100%; }
.account-modal-card { margin:2vh auto; max-height:94vh; }
}
</style>
<script>
(function () {
const openBtns = document.querySelectorAll('[data-open-modal]');
const closeBtns = document.querySelectorAll('[data-close-modal]');
openBtns.forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-open-modal');
const modal = document.getElementById(id);
if (!modal) return;
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
});
});
closeBtns.forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-close-modal');
const modal = document.getElementById(id);
if (!modal) return;
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
});
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
document.querySelectorAll('.account-modal.is-open').forEach((modal) => {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
});
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Cart';
$items = is_array($items ?? null) ? $items : [];
$totals = is_array($totals ?? null) ? $totals : ['count' => 0, 'subtotal' => 0.0, 'discount_amount' => 0.0, 'amount' => 0.0, 'currency' => 'GBP', 'discount_code' => ''];
$discountCode = (string)($totals['discount_code'] ?? '');
ob_start();
?>
<section class="card" style="display:grid; gap:16px;">
<a href="/releases" class="badge" style="text-decoration:none; display:inline-block;">Continue shopping</a>
<h1 style="margin:0; font-size:32px;">Your Cart</h1>
<?php if (!$items): ?>
<div style="padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); color:var(--muted);">
Your basket is empty.
</div>
<?php else: ?>
<div style="display:grid; gap:10px;">
<?php foreach ($items as $item): ?>
<?php
$key = (string)($item['key'] ?? '');
$title = (string)($item['title'] ?? 'Item');
$coverUrl = (string)($item['cover_url'] ?? '');
$qty = max(1, (int)($item['qty'] ?? 1));
$price = (float)($item['price'] ?? 0);
$currency = (string)($item['currency'] ?? ($totals['currency'] ?? 'GBP'));
?>
<div style="display:grid; grid-template-columns:58px minmax(0,1fr) auto auto; align-items:center; gap:12px; padding:12px; border-radius:12px; border:1px solid rgba(255,255,255,.08); background:rgba(0,0,0,.2);">
<div style="width:58px; height:58px; border-radius:10px; overflow:hidden; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.06);">
<?php if ($coverUrl !== ''): ?>
<img src="<?= htmlspecialchars($coverUrl, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; height:100%; object-fit:cover; display:block;">
<?php else: ?>
<div style="width:100%; height:100%; display:grid; place-items:center; font-size:10px; color:var(--muted);">AC</div>
<?php endif; ?>
</div>
<div style="min-width:0;">
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price, 2) ?> x <?= $qty ?></div>
</div>
<div style="font-weight:700;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
<form method="post" action="/cart/remove" style="margin:0;">
<input type="hidden" name="key" value="<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="return_url" value="/cart">
<button type="submit" class="cart-btn cart-btn-ghost">Remove</button>
</form>
</div>
<?php endforeach; ?>
</div>
<div style="display:grid; gap:12px; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.25);">
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div style="color:var(--muted);"><?= (int)($totals['count'] ?? 0) ?> item(s)</div>
<div style="display:grid; justify-items:end; gap:4px;">
<div style="font-size:12px; color:var(--muted);">Subtotal: <?= htmlspecialchars((string)($totals['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($totals['subtotal'] ?? 0), 2) ?></div>
<?php if ((float)($totals['discount_amount'] ?? 0) > 0): ?>
<div style="font-size:12px; color:#9be7c6;">Discount (<?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?>): -<?= htmlspecialchars((string)($totals['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($totals['discount_amount'] ?? 0), 2) ?></div>
<?php endif; ?>
<div style="font-size:20px; font-weight:700;"><?= htmlspecialchars((string)($totals['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($totals['amount'] ?? 0), 2) ?></div>
</div>
</div>
<div id="discountWrap" class="cart-discount-wrap<?= $discountCode !== '' ? ' is-open' : '' ?>">
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px;">
<?php if ($discountCode !== ''): ?>
<span style="font-size:12px; color:var(--muted);">Applied discount</span>
<span class="cart-discount-chip"><?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?></span>
<form method="post" action="/cart/discount/remove" style="margin:0;">
<input type="hidden" name="return_url" value="/cart">
<button type="submit" class="cart-btn cart-btn-danger">Remove</button>
</form>
<?php endif; ?>
</div>
<div id="discountBox" class="cart-discount-box<?= $discountCode !== '' ? ' is-open' : '' ?>">
<form method="post" action="/cart/discount/apply" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<input type="hidden" name="return_url" value="/cart">
<input name="discount_code" value="<?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?>" class="input cart-discount-input" placeholder="Enter discount code">
<button type="submit" class="cart-btn cart-btn-primary">Apply code</button>
</form>
</div>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap;">
<button type="button" class="cart-btn cart-btn-ghost" id="toggleDiscountBox">
<?= $discountCode !== '' ? 'Discount Active' : 'Have a discount code?' ?>
</button>
<a href="/checkout" class="cart-btn cart-btn-primary">Checkout</a>
</div>
<?php endif; ?>
</section>
<style>
.cart-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.08);
color: #e9eefc;
text-decoration: none;
font-size: 12px;
letter-spacing: .12em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
cursor: pointer;
}
.cart-btn:hover { background: rgba(255,255,255,.14); }
.cart-btn-primary {
background: rgba(34,242,165,.18);
border-color: rgba(34,242,165,.48);
color: #cffff0;
}
.cart-btn-primary:hover { background: rgba(34,242,165,.28); }
.cart-btn-ghost {
background: rgba(255,255,255,.06);
}
.cart-btn-danger {
border-color: rgba(255, 120, 120, 0.4);
color: #ffd4d4;
background: rgba(255, 120, 120, 0.12);
}
.cart-btn-danger:hover {
background: rgba(255, 120, 120, 0.2);
}
.cart-discount-wrap {
display: none;
border: 1px solid rgba(255,255,255,.12);
border-radius: 14px;
padding: 12px;
background:
linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.01)),
rgba(10, 12, 18, 0.72);
box-shadow: inset 0 1px 0 rgba(255,255,255,.04);
}
.cart-discount-wrap.is-open {
display: block;
}
.cart-discount-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(34,242,165,.38);
background: rgba(34,242,165,.14);
color: #cffff0;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
}
.cart-discount-box {
display: none;
margin-top: 10px;
padding-top: 2px;
}
.cart-discount-box.is-open {
display: block;
}
.cart-discount-input {
min-width: 220px;
height: 38px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.18);
background: rgba(7,9,14,.72);
color: #f0f4ff;
padding: 0 12px;
}
.cart-discount-input::placeholder {
color: rgba(220,228,245,.42);
}
.cart-discount-input:focus {
outline: none;
border-color: rgba(34,242,165,.5);
box-shadow: 0 0 0 2px rgba(34,242,165,.14);
}
@media (max-width: 720px) {
.cart-discount-input {
min-width: 100%;
width: 100%;
}
}
</style>
<script>
(function () {
const toggleBtn = document.getElementById('toggleDiscountBox');
const box = document.getElementById('discountBox');
const wrap = document.getElementById('discountWrap');
if (!toggleBtn || !box || !wrap) return;
toggleBtn.addEventListener('click', function () {
box.classList.toggle('is-open');
wrap.classList.toggle('is-open');
});
})();
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Checkout';
$items = is_array($items ?? null) ? $items : [];
$total = (float)($total ?? 0);
$subtotal = (float)($subtotal ?? $total);
$discountAmount = (float)($discount_amount ?? 0);
$discountCode = (string)($discount_code ?? '');
$currency = (string)($currency ?? 'GBP');
$success = (string)($success ?? '');
$orderNo = (string)($order_no ?? '');
$error = (string)($error ?? '');
$downloadLinks = is_array($download_links ?? null) ? $download_links : [];
$downloadNotice = (string)($download_notice ?? '');
$downloadLimit = (int)($download_limit ?? 5);
$downloadExpiryDays = (int)($download_expiry_days ?? 30);
ob_start();
?>
<section class="card checkout-wrap">
<div class="badge">Store</div>
<h1 style="margin:0; font-size:32px;">Checkout</h1>
<?php if ($success !== ''): ?>
<div style="padding:14px; border-radius:12px; border:1px solid rgba(34,242,165,.4); background:rgba(34,242,165,.12);">
<div style="font-weight:700;">Order complete</div>
<?php if ($orderNo !== ''): ?>
<div style="margin-top:4px; color:var(--muted);">Order: <?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
</div>
<div class="checkout-panel">
<div class="badge" style="font-size:9px;">Your Downloads</div>
<?php if ($downloadLinks): ?>
<div style="display:grid; gap:10px; margin-top:10px;">
<?php foreach ($downloadLinks as $link): ?>
<?php
$label = trim((string)($link['label'] ?? 'Download'));
$url = trim((string)($link['url'] ?? ''));
if ($url === '') {
continue;
}
?>
<a href="<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>" class="checkout-download-link">
<span><?= htmlspecialchars($label !== '' ? $label : 'Download', ENT_QUOTES, 'UTF-8') ?></span>
<span class="checkout-download-link-arrow">Download</span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="margin:10px 0 0; color:var(--muted); font-size:13px;">
<?= htmlspecialchars($downloadNotice !== '' ? $downloadNotice : 'No downloads available for this order yet.', ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div style="padding:14px; border-radius:12px; border:1px solid rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6;">
<?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<?php if (!$items): ?>
<div style="padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); color:var(--muted);">
Your cart is empty.
</div>
<div><a href="/releases" class="btn">Browse releases</a></div>
<?php else: ?>
<div class="checkout-grid">
<div class="checkout-panel">
<div class="badge" style="font-size:9px;">Order Summary</div>
<div style="display:grid; gap:10px; margin-top:10px;">
<?php foreach ($items as $item): ?>
<?php
$title = (string)($item['title'] ?? 'Item');
$qty = max(1, (int)($item['qty'] ?? 1));
$price = (float)($item['price'] ?? 0);
$lineCurrency = (string)($item['currency'] ?? $currency);
?>
<div class="checkout-line">
<div class="checkout-line-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="checkout-line-meta"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price, 2) ?> x <?= $qty ?></div>
<div class="checkout-line-total"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="checkout-total">
<span>Subtotal</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($subtotal, 2) ?></strong>
</div>
<?php if ($discountAmount > 0): ?>
<div class="checkout-total" style="margin-top:8px;">
<span>Discount (<?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?>)</span>
<strong style="color:#9be7c6;">-<?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($discountAmount, 2) ?></strong>
</div>
<?php endif; ?>
<div class="checkout-total" style="margin-top:8px;">
<span>Order total</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($total, 2) ?></strong>
</div>
</div>
<div class="checkout-panel">
<div class="badge" style="font-size:9px;">Buyer Details</div>
<form method="post" action="/checkout/place" style="display:grid; gap:12px; margin-top:10px;">
<label style="font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em;">Email</label>
<input name="email" type="email" value="" placeholder="you@example.com" class="checkout-input" required>
<div class="checkout-terms">
<div class="badge" style="font-size:9px;">Terms</div>
<p style="margin:8px 0 0; color:var(--muted); font-size:13px; line-height:1.5;">
Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order.
Files are limited to <?= $downloadLimit ?> downloads and expire after <?= $downloadExpiryDays ?> days.
</p>
<label style="margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px;">
<input type="checkbox" name="accept_terms" value="1" required style="margin-top:2px;">
<span>I agree to the terms and understand all sales are final.</span>
</label>
</div>
<button type="submit" class="checkout-place-btn">Place Order</button>
</form>
</div>
</div>
<?php endif; ?>
</section>
<style>
.checkout-wrap { display:grid; gap:14px; }
.checkout-grid { display:grid; grid-template-columns: minmax(0,1fr) 420px; gap:14px; }
.checkout-panel {
padding:14px;
border-radius:12px;
border:1px solid rgba(255,255,255,.1);
background:rgba(0,0,0,.2);
}
.checkout-line {
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:8px;
padding:10px;
border-radius:10px;
border:1px solid rgba(255,255,255,.08);
background:rgba(255,255,255,.03);
}
.checkout-line-title { font-weight:600; }
.checkout-line-meta { color:var(--muted); font-size:12px; margin-top:4px; grid-column:1/2; }
.checkout-line-total { font-weight:700; grid-column:2/3; grid-row:1/3; align-self:center; }
.checkout-total {
margin-top:10px;
display:flex;
align-items:center;
justify-content:space-between;
padding:12px;
border-radius:10px;
border:1px solid rgba(255,255,255,.1);
background:rgba(255,255,255,.04);
}
.checkout-total strong { font-size:22px; }
.checkout-input {
height:40px;
border-radius:10px;
border:1px solid rgba(255,255,255,.2);
background:rgba(255,255,255,.05);
color:#fff;
padding:0 12px;
}
.checkout-terms {
padding:12px;
border-radius:10px;
border:1px solid rgba(255,255,255,.1);
background:rgba(255,255,255,.03);
}
.checkout-place-btn{
height:40px;
border-radius:999px;
border:1px solid rgba(34,242,165,.45);
background:rgba(34,242,165,.18);
color:#cbfff1;
font-weight:700;
letter-spacing:.1em;
text-transform:uppercase;
cursor:pointer;
}
.checkout-place-btn:hover { background:rgba(34,242,165,.28); }
.checkout-download-link {
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:12px;
border-radius:10px;
border:1px solid rgba(34,242,165,.35);
background:rgba(34,242,165,.1);
color:#d7ffef;
text-decoration:none;
font-weight:600;
}
.checkout-download-link:hover { background:rgba(34,242,165,.18); }
.checkout-download-link-arrow {
font-size:11px;
text-transform:uppercase;
letter-spacing:.14em;
color:#8df7d1;
}
@media (max-width: 900px) {
.checkout-grid { grid-template-columns: 1fr; }
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View 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);
}
}

View 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
}

View 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']);
};

View 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';

View 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';

View 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';

View 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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';