Compare commits

..

18 Commits

Author SHA1 Message Date
AudioCore Bot
9deabe1ec9 Release v1.5.1 2026-04-01 14:12:17 +00:00
AudioCore Bot
dc53051358 Improve track sample generator UI and metadata-based sample filenames 2026-03-09 00:03:25 +00:00
AudioCore Bot
4f50b611b0 Refine homepage shortcode layout and rendering cleanup 2026-03-05 20:15:48 +00:00
AudioCore Bot
8d12c68c0c Home shortcode layout: two-column composition and section headers 2026-03-05 18:25:21 +00:00
AudioCore Bot
bfdeecade1 Frontend shortcode polish: spacing, card sizing, newsletter styles 2026-03-05 17:55:10 +00:00
AudioCore Bot
d33c16a20f Shortcode grids: constrain card width so single items do not render huge 2026-03-05 17:36:16 +00:00
AudioCore Bot
2eaa61654b Fix custom CSS injection so base styles always load 2026-03-05 17:25:09 +00:00
AudioCore Bot
af6bb638ac Shortcodes: add home blocks and global custom CSS setting 2026-03-05 17:09:01 +00:00
AudioCore Bot
93e829bd19 Store: enforce configurable timezone for store timestamps 2026-03-05 15:28:50 +00:00
20a8582928 Update README.md 2026-03-05 14:38:47 +00:00
AudioCore Bot
fd03d276f1 Hide future-dated releases in artist profile latest releases 2026-03-05 14:30:52 +00:00
AudioCore Bot
c5302c3b44 Schedule releases by release_date and block unreleased store purchases 2026-03-05 14:18:20 +00:00
AudioCore Bot
89b4b1eefd Installer: auto-create storage directory and seed settings/db files 2026-03-04 22:40:59 +00:00
AudioCore Bot
2e92b9f421 Refine installer UI styles and normalize form controls 2026-03-04 22:01:54 +00:00
72b76397ef Update README.md 2026-03-04 21:43:18 +00:00
d5677b2457 Update README.md 2026-03-04 21:42:58 +00:00
9793d340cd Add README.md 2026-03-04 21:42:44 +00:00
d15470de67 Update update.json 2026-03-04 21:36:41 +00:00
56 changed files with 12088 additions and 5464 deletions

3
.gitignore vendored
View File

@@ -5,6 +5,8 @@ storage/*.sqlite
storage/mail_debug/ storage/mail_debug/
storage/sessions/ storage/sessions/
storage/cache/ storage/cache/
!storage/cache/
!storage/cache/.gitkeep
# Uploads / media / binaries # Uploads / media / binaries
uploads/ uploads/
@@ -33,3 +35,4 @@ storage/db.php
storage/settings.php storage/settings.php
storage/update_cache.json storage/update_cache.json
storage/logs/ storage/logs/

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# AudioCore V1.5.1
AudioCore V1.5.1 is a modular CMS for labels, artists, releases, storefront sales, and support workflows.
It is built around a plugin-first architecture so core stays clean while features can be enabled/disabled per site.
## Key features
- Modular plugin system (enable/disable from admin)
- Artists plugin (profiles, socials, artist pages)
- Releases plugin (catalog pages, release detail, track metadata, samples)
- Store plugin (cart, checkout, PayPal, orders, customers, discounts, downloads)
- Support plugin (ticketing, IMAP reply sync, admin replies)
- Newsletter module (subscriber management + templates)
- Media library (upload and reuse media across modules)
- Page builder + shortcode support
- Maintenance/coming-soon controls
- Update checker from Gitea manifest
## Tech overview
- PHP 8.3+
- MySQL/MariaDB
- OpenLiteSpeed/Apache compatible routing via `.htaccess`
- Server cron integration for background jobs (IMAP sync, chart refresh)
## Install (high level)
1. Upload project files.
2. Point web root to `dev/` (or deploy repo to desired root).
3. Open `/admin` to run installer.
4. Enter database + admin details.
5. Configure SMTP and run test email.
6. Enable required plugins and create their tables.
## Recommended production setup
- Keep private download storage outside public web root.
- Use HTTPS only.
- Configure SMTP correctly for transactional mail.
- Configure cron jobs in Admin > Cron Jobs.
- Keep `storage/` writable and secured.
## Release notes source
Updates are controlled via `update.json` in this repository and checked from admin Updates page.

View File

@@ -9,10 +9,12 @@ require_once __DIR__ . '/services/Auth.php';
require_once __DIR__ . '/services/Csrf.php'; require_once __DIR__ . '/services/Csrf.php';
require_once __DIR__ . '/services/Settings.php'; require_once __DIR__ . '/services/Settings.php';
require_once __DIR__ . '/services/Audit.php'; require_once __DIR__ . '/services/Audit.php';
require_once __DIR__ . '/services/ApiLayer.php';
require_once __DIR__ . '/services/Permissions.php'; require_once __DIR__ . '/services/Permissions.php';
require_once __DIR__ . '/services/Shortcodes.php'; require_once __DIR__ . '/services/Shortcodes.php';
require_once __DIR__ . '/services/Nav.php'; require_once __DIR__ . '/services/Nav.php';
require_once __DIR__ . '/services/Mailer.php'; require_once __DIR__ . '/services/Mailer.php';
require_once __DIR__ . '/services/RateLimiter.php';
require_once __DIR__ . '/services/Plugins.php'; require_once __DIR__ . '/services/Plugins.php';
require_once __DIR__ . '/services/Updater.php'; require_once __DIR__ . '/services/Updater.php';
@@ -20,3 +22,26 @@ Core\Services\Auth::init();
Core\Services\Settings::init(__DIR__ . '/../storage/settings.php'); Core\Services\Settings::init(__DIR__ . '/../storage/settings.php');
Core\Services\Plugins::init(__DIR__ . '/../plugins'); Core\Services\Plugins::init(__DIR__ . '/../plugins');
Core\Services\Audit::ensureTable(); Core\Services\Audit::ensureTable();
if (!function_exists('csrf_token')) {
function csrf_token(): string
{
return \Core\Services\Csrf::token();
}
}
if (!function_exists('csrf_input')) {
function csrf_input(): string
{
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="csrf_token" value="' . $token . '">';
}
}
if (!function_exists('csrf_meta')) {
function csrf_meta(): string
{
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
return '<meta name="csrf-token" content="' . $token . '">';
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Http; namespace Core\Http;
use Core\Services\Auth; use Core\Services\Auth;
use Core\Services\Csrf;
use Core\Services\Database; use Core\Services\Database;
use Core\Services\Csrf;
use Core\Services\Permissions; use Core\Services\Permissions;
use Core\Services\Shortcodes; use Core\Services\Shortcodes;
use Core\Views\View; use Core\Views\View;
@@ -44,11 +44,6 @@ class Router
} }
$method = strtoupper($method); $method = strtoupper($method);
if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) {
return new Response('Invalid request token. Please refresh and try again.', 419);
}
if ($method === 'GET') { if ($method === 'GET') {
$redirect = $this->matchRedirect($path); $redirect = $this->matchRedirect($path);
if ($redirect !== null) { if ($redirect !== null) {
@@ -63,6 +58,10 @@ class Router
} }
} }
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && !$this->isCsrfExempt($path) && !Csrf::verifyRequest()) {
return $this->csrfFailureResponse($path);
}
if (isset($this->routes[$method][$path])) { if (isset($this->routes[$method][$path])) {
$handler = $this->routes[$method][$path]; $handler = $this->routes[$method][$path];
return $handler(); return $handler();
@@ -131,12 +130,25 @@ class Router
]), 404); ]), 404);
} }
private function isCsrfExempt(string $path): bool
private function requiresCsrf(string $path): bool
{ {
// All browser-initiated POST routes require CSRF protection. return str_starts_with($path, '/api/');
return true; }
private function csrfFailureResponse(string $path): Response
{
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if (stripos($accept, 'application/json') !== false || stripos($contentType, 'application/json') !== false) {
return new Response(json_encode(['ok' => false, 'error' => 'Invalid CSRF token.']), 419, ['Content-Type' => 'application/json']);
}
$target = (string)($_SERVER['HTTP_REFERER'] ?? $path);
if ($target === '') {
$target = '/';
}
$separator = str_contains($target, '?') ? '&' : '?';
return new Response('', 302, ['Location' => $target . $separator . 'error=csrf']);
} }
private function matchRedirect(string $path): ?Response private function matchRedirect(string $path): ?Response

560
core/services/ApiLayer.php Normal file
View File

@@ -0,0 +1,560 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class ApiLayer
{
private static bool $schemaEnsured = false;
private static bool $schemaEnsuring = false;
public static function ensureSchema(?PDO $db = null): void
{
if (self::$schemaEnsured || self::$schemaEnsuring) {
return;
}
$db = $db instanceof PDO ? $db : Database::get();
if (!($db instanceof PDO)) {
return;
}
self::$schemaEnsuring = true;
try {
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_api_clients (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
api_key_hash CHAR(64) NOT NULL UNIQUE,
api_key_prefix VARCHAR(24) NOT NULL,
webhook_url VARCHAR(1000) NULL,
webhook_secret VARCHAR(128) NULL,
scopes_json TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_used_at DATETIME NULL,
last_used_ip VARCHAR(64) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_api_clients_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {
}
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_store_order_item_allocations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_id INT UNSIGNED NOT NULL,
order_item_id INT UNSIGNED NOT NULL,
artist_id INT UNSIGNED NULL,
release_id INT UNSIGNED NULL,
track_id INT UNSIGNED NULL,
source_item_type ENUM('track','release','bundle') NOT NULL,
source_item_id INT UNSIGNED NOT NULL,
title_snapshot VARCHAR(255) NOT NULL,
qty INT UNSIGNED NOT NULL DEFAULT 1,
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_store_allocations_order (order_id),
KEY idx_store_allocations_item (order_item_id),
KEY idx_store_allocations_artist (artist_id),
KEY idx_store_allocations_release (release_id),
KEY idx_store_allocations_track (track_id),
KEY idx_store_allocations_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_store_order_items ADD COLUMN artist_id INT UNSIGNED NULL AFTER item_id");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_releases ADD KEY idx_releases_artist_id (artist_id)");
} catch (Throwable $e) {
}
try {
$db->exec("ALTER TABLE ac_store_order_items ADD KEY idx_store_order_items_artist_id (artist_id)");
} catch (Throwable $e) {
}
self::backfillReleaseArtists($db);
self::backfillOrderItemArtists($db);
self::backfillAllocations($db);
self::$schemaEnsured = true;
} finally {
self::$schemaEnsuring = false;
}
}
public static function issueClient(PDO $db, string $name, string $webhookUrl = '', string $scopesJson = '[]'): string
{
self::ensureSchema($db);
$plainKey = 'ac_' . bin2hex(random_bytes(24));
$stmt = $db->prepare("
INSERT INTO ac_api_clients (name, api_key_hash, api_key_prefix, webhook_url, scopes_json, is_active)
VALUES (:name, :hash, :prefix, :webhook_url, :scopes_json, 1)
");
$stmt->execute([
':name' => $name,
':hash' => hash('sha256', $plainKey),
':prefix' => substr($plainKey, 0, 16),
':webhook_url' => $webhookUrl !== '' ? $webhookUrl : null,
':scopes_json' => $scopesJson,
]);
return $plainKey;
}
public static function verifyRequest(?PDO $db = null): ?array
{
$db = $db instanceof PDO ? $db : Database::get();
if (!($db instanceof PDO)) {
return null;
}
self::ensureSchema($db);
$key = self::extractApiKey();
if ($key === '') {
return null;
}
try {
$stmt = $db->prepare("
SELECT id, name, api_key_prefix, webhook_url, webhook_secret, scopes_json, is_active
FROM ac_api_clients
WHERE api_key_hash = :hash
AND is_active = 1
LIMIT 1
");
$stmt->execute([':hash' => hash('sha256', $key)]);
$client = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$client) {
return null;
}
$touch = $db->prepare("
UPDATE ac_api_clients
SET last_used_at = NOW(), last_used_ip = :ip
WHERE id = :id
");
$touch->execute([
':ip' => self::clientIp(),
':id' => (int)$client['id'],
]);
return $client;
} catch (Throwable $e) {
return null;
}
}
public static function syncOrderItemAllocations(PDO $db, int $orderId, int $orderItemId): void
{
self::ensureSchema($db);
if ($orderId <= 0 || $orderItemId <= 0) {
return;
}
try {
$stmt = $db->prepare("
SELECT id, order_id, item_type, item_id, artist_id, title_snapshot, qty, line_total, currency_snapshot
FROM ac_store_order_items
WHERE id = :id AND order_id = :order_id
LIMIT 1
");
$stmt->execute([
':id' => $orderItemId,
':order_id' => $orderId,
]);
$item = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$item) {
return;
}
$db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_item_id = :order_item_id")
->execute([':order_item_id' => $orderItemId]);
$type = (string)($item['item_type'] ?? '');
$sourceId = (int)($item['item_id'] ?? 0);
$qty = max(1, (int)($item['qty'] ?? 1));
$lineTotal = (float)($item['line_total'] ?? 0);
$currency = strtoupper(trim((string)($item['currency_snapshot'] ?? 'GBP')));
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
$currency = 'GBP';
}
$rows = [];
if ($type === 'track') {
$track = self::loadTrackArtistContext($db, $sourceId);
if ($track) {
$rows[] = [
'artist_id' => (int)($track['artist_id'] ?? 0),
'release_id' => (int)($track['release_id'] ?? 0),
'track_id' => $sourceId,
'title_snapshot' => (string)($track['title'] ?? $item['title_snapshot'] ?? 'Track'),
'gross_amount' => $lineTotal,
];
}
} elseif ($type === 'release') {
$release = self::loadReleaseArtistContext($db, $sourceId);
if ($release) {
$rows[] = [
'artist_id' => (int)($release['artist_id'] ?? 0),
'release_id' => $sourceId,
'track_id' => null,
'title_snapshot' => (string)($release['title'] ?? $item['title_snapshot'] ?? 'Release'),
'gross_amount' => $lineTotal,
];
}
} elseif ($type === 'bundle') {
$rows = self::buildBundleAllocationRows($db, $sourceId, $lineTotal);
}
if (!$rows) {
return;
}
$insert = $db->prepare("
INSERT INTO ac_store_order_item_allocations
(order_id, order_item_id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at)
VALUES (:order_id, :order_item_id, :artist_id, :release_id, :track_id, :source_item_type, :source_item_id, :title_snapshot, :qty, :gross_amount, :currency_snapshot, NOW())
");
foreach ($rows as $row) {
$artistId = (int)($row['artist_id'] ?? 0);
$releaseId = (int)($row['release_id'] ?? 0);
$trackId = (int)($row['track_id'] ?? 0);
$insert->execute([
':order_id' => $orderId,
':order_item_id' => $orderItemId,
':artist_id' => $artistId > 0 ? $artistId : null,
':release_id' => $releaseId > 0 ? $releaseId : null,
':track_id' => $trackId > 0 ? $trackId : null,
':source_item_type' => $type,
':source_item_id' => $sourceId,
':title_snapshot' => (string)($row['title_snapshot'] ?? $item['title_snapshot'] ?? 'Item'),
':qty' => $qty,
':gross_amount' => (float)($row['gross_amount'] ?? 0),
':currency_snapshot' => $currency,
]);
}
} catch (Throwable $e) {
}
}
public static function dispatchSaleWebhooksForOrder(int $orderId): void
{
$db = Database::get();
if (!($db instanceof PDO) || $orderId <= 0) {
return;
}
self::ensureSchema($db);
try {
$stmt = $db->query("
SELECT id, name, webhook_url, webhook_secret
FROM ac_api_clients
WHERE is_active = 1
AND webhook_url IS NOT NULL
AND webhook_url <> ''
ORDER BY id ASC
");
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
if (!$clients) {
return;
}
$payload = self::buildOrderWebhookPayload($db, $orderId);
if (!$payload) {
return;
}
foreach ($clients as $client) {
$url = trim((string)($client['webhook_url'] ?? ''));
if ($url === '') {
continue;
}
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if (!is_string($json) || $json === '') {
continue;
}
$headers = [
'Content-Type: application/json',
'Content-Length: ' . strlen($json),
'X-AudioCore-Event: sale.paid',
'X-AudioCore-Client: ' . (string)($client['name'] ?? 'AudioCore API'),
];
$secret = trim((string)($client['webhook_secret'] ?? ''));
if ($secret !== '') {
$headers[] = 'X-AudioCore-Signature: sha256=' . hash_hmac('sha256', $json, $secret);
}
self::postJson($url, $json, $headers);
}
} catch (Throwable $e) {
}
}
private static function buildOrderWebhookPayload(PDO $db, int $orderId): array
{
try {
$orderStmt = $db->prepare("
SELECT id, order_no, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, created_at, updated_at
FROM ac_store_orders
WHERE id = :id
LIMIT 1
");
$orderStmt->execute([':id' => $orderId]);
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
return [];
}
$allocStmt = $db->prepare("
SELECT id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at
FROM ac_store_order_item_allocations
WHERE order_id = :order_id
ORDER BY id ASC
");
$allocStmt->execute([':order_id' => $orderId]);
$allocations = $allocStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return [
'event' => 'sale.paid',
'order' => $order,
'allocations' => $allocations,
];
} catch (Throwable $e) {
return [];
}
}
private static function backfillReleaseArtists(PDO $db): void
{
try {
$db->exec("
UPDATE ac_releases r
JOIN ac_artists a ON a.name = r.artist_name
SET r.artist_id = a.id
WHERE r.artist_id IS NULL
AND r.artist_name IS NOT NULL
AND r.artist_name <> ''
");
} catch (Throwable $e) {
}
}
private static function backfillOrderItemArtists(PDO $db): void
{
try {
$db->exec("
UPDATE ac_store_order_items oi
JOIN ac_release_tracks t ON oi.item_type = 'track' AND oi.item_id = t.id
JOIN ac_releases r ON r.id = t.release_id
SET oi.artist_id = r.artist_id
WHERE oi.artist_id IS NULL
");
} catch (Throwable $e) {
}
try {
$db->exec("
UPDATE ac_store_order_items oi
JOIN ac_releases r ON oi.item_type = 'release' AND oi.item_id = r.id
SET oi.artist_id = r.artist_id
WHERE oi.artist_id IS NULL
");
} catch (Throwable $e) {
}
}
private static function backfillAllocations(PDO $db): void
{
try {
$stmt = $db->query("
SELECT oi.id, oi.order_id
FROM ac_store_order_items oi
LEFT JOIN ac_store_order_item_allocations a ON a.order_item_id = oi.id
WHERE a.id IS NULL
ORDER BY oi.id ASC
LIMIT 5000
");
$rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
foreach ($rows as $row) {
self::syncOrderItemAllocations($db, (int)($row['order_id'] ?? 0), (int)($row['id'] ?? 0));
}
} catch (Throwable $e) {
}
}
private static function loadReleaseArtistContext(PDO $db, int $releaseId): ?array
{
if ($releaseId <= 0) {
return null;
}
try {
$stmt = $db->prepare("
SELECT r.id, r.title, r.artist_id
FROM ac_releases r
WHERE r.id = :id
LIMIT 1
");
$stmt->execute([':id' => $releaseId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
private static function loadTrackArtistContext(PDO $db, int $trackId): ?array
{
if ($trackId <= 0) {
return null;
}
try {
$stmt = $db->prepare("
SELECT t.id, t.title, t.release_id, r.artist_id
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
WHERE t.id = :id
LIMIT 1
");
$stmt->execute([':id' => $trackId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (Throwable $e) {
return null;
}
}
private static function buildBundleAllocationRows(PDO $db, int $bundleId, float $lineTotal): array
{
if ($bundleId <= 0) {
return [];
}
try {
$stmt = $db->prepare("
SELECT
r.id AS release_id,
r.title,
r.artist_id,
COALESCE(SUM(CASE WHEN sp.is_enabled = 1 THEN sp.track_price ELSE 0 END), 0) AS weight
FROM ac_store_bundle_items bi
JOIN ac_releases r ON r.id = bi.release_id
LEFT JOIN ac_release_tracks t ON t.release_id = r.id
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
WHERE bi.bundle_id = :bundle_id
GROUP BY r.id, r.title, r.artist_id, bi.sort_order, bi.id
ORDER BY bi.sort_order ASC, bi.id ASC
");
$stmt->execute([':bundle_id' => $bundleId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if (!$rows) {
return [];
}
$weights = [];
$totalWeight = 0.0;
foreach ($rows as $index => $row) {
$weight = max(0.0, (float)($row['weight'] ?? 0));
if ($weight <= 0) {
$weight = 1.0;
}
$weights[$index] = $weight;
$totalWeight += $weight;
}
if ($totalWeight <= 0) {
$totalWeight = (float)count($rows);
}
$result = [];
$allocated = 0.0;
$lastIndex = count($rows) - 1;
foreach ($rows as $index => $row) {
if ($index === $lastIndex) {
$amount = round($lineTotal - $allocated, 2);
} else {
$amount = round($lineTotal * ($weights[$index] / $totalWeight), 2);
$allocated += $amount;
}
$result[] = [
'artist_id' => (int)($row['artist_id'] ?? 0),
'release_id' => (int)($row['release_id'] ?? 0),
'track_id' => null,
'title_snapshot' => (string)($row['title'] ?? 'Bundle release'),
'gross_amount' => max(0.0, $amount),
];
}
return $result;
} catch (Throwable $e) {
return [];
}
}
private static function extractApiKey(): string
{
$auth = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if (stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
$header = trim((string)($_SERVER['HTTP_X_API_KEY'] ?? ''));
if ($header !== '') {
return $header;
}
return '';
}
private static function clientIp(): string
{
$forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($forwarded !== '') {
$parts = explode(',', $forwarded);
return trim((string)($parts[0] ?? ''));
}
return trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
}
private static function postJson(string $url, string $json, array $headers): void
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch === false) {
return;
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $json,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
return;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $json,
'timeout' => 8,
'ignore_errors' => true,
],
]);
@file_get_contents($url, false, $context);
}
}

View File

@@ -12,13 +12,7 @@ class Auth
public static function init(): void public static function init(): void
{ {
if (session_status() !== PHP_SESSION_ACTIVE) { if (session_status() !== PHP_SESSION_ACTIVE) {
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; session_start();
session_start([
'cookie_httponly' => true,
'cookie_secure' => $secure,
'cookie_samesite' => 'Lax',
'use_strict_mode' => 1,
]);
} }
} }
@@ -48,9 +42,14 @@ class Auth
public static function logout(): void public static function logout(): void
{ {
self::init(); self::init();
unset($_SESSION[self::SESSION_KEY]); $_SESSION = [];
unset($_SESSION[self::SESSION_ROLE_KEY]); if (ini_get('session.use_cookies')) {
unset($_SESSION[self::SESSION_NAME_KEY]); $params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']);
}
session_destroy();
session_start();
session_regenerate_id(true);
} }
public static function role(): string public static function role(): string

View File

@@ -28,8 +28,7 @@ class Csrf
$sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? ''); $sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? '');
if ($sessionToken === '') { if ($sessionToken === '') {
// Legacy compatibility: allow request when no token has been seeded yet. $sessionToken = self::token();
return true;
} }
$provided = ''; $provided = '';
@@ -40,8 +39,7 @@ class Csrf
} }
if ($provided === '') { if ($provided === '') {
// Legacy compatibility: don't hard-fail older forms without token. return false;
return true;
} }
return hash_equals($sessionToken, $provided); return hash_equals($sessionToken, $provided);

View File

@@ -56,7 +56,7 @@ class Plugins
'slug' => (string)($plugin['slug'] ?? ''), 'slug' => (string)($plugin['slug'] ?? ''),
]; ];
} }
return array_values(array_filter($items, static function (array $item): bool { $items = array_values(array_filter($items, static function (array $item): bool {
if ($item['label'] === '' || $item['url'] === '') { if ($item['label'] === '' || $item['url'] === '') {
return false; return false;
} }
@@ -66,6 +66,24 @@ class Plugins
} }
return true; return true;
})); }));
$order = [
'artists' => 10,
'releases' => 20,
'store' => 30,
'advanced-reporting' => 40,
'support' => 50,
];
usort($items, static function (array $a, array $b) use ($order): int {
$aSlug = (string)($a['slug'] ?? '');
$bSlug = (string)($b['slug'] ?? '');
$aOrder = $order[$aSlug] ?? 1000;
$bOrder = $order[$bSlug] ?? 1000;
if ($aOrder === $bOrder) {
return strcasecmp((string)($a['label'] ?? ''), (string)($b['label'] ?? ''));
}
return $aOrder <=> $bOrder;
});
return $items;
} }
public static function register(Router $router): void public static function register(Router $router): void

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class RateLimiter
{
private static bool $tableEnsured = false;
public static function tooMany(string $action, string $subjectKey, int $limit, int $windowSeconds): bool
{
if ($limit < 1 || $windowSeconds < 1) {
return false;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return false;
}
self::ensureTable($db);
try {
$cutoff = date('Y-m-d H:i:s', time() - $windowSeconds);
$countStmt = $db->prepare("
SELECT COUNT(*) AS c
FROM ac_rate_limits
WHERE action_name = :action_name
AND subject_key = :subject_key
AND created_at >= :cutoff
");
$countStmt->execute([
':action_name' => $action,
':subject_key' => $subjectKey,
':cutoff' => $cutoff,
]);
$count = (int)(($countStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
if ($count >= $limit) {
return true;
}
$insertStmt = $db->prepare("
INSERT INTO ac_rate_limits (action_name, subject_key, created_at)
VALUES (:action_name, :subject_key, NOW())
");
$insertStmt->execute([
':action_name' => $action,
':subject_key' => $subjectKey,
]);
$db->exec("DELETE FROM ac_rate_limits WHERE created_at < (NOW() - INTERVAL 2 DAY)");
} catch (Throwable $e) {
return false;
}
return false;
}
private static function ensureTable(PDO $db): void
{
if (self::$tableEnsured) {
return;
}
$db->exec("
CREATE TABLE IF NOT EXISTS ac_rate_limits (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action_name VARCHAR(80) NOT NULL,
subject_key VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rate_limits_lookup (action_name, subject_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
self::$tableEnsured = true;
}
}

View File

@@ -2,6 +2,5 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
'version' => '1.5.0', 'version' => '1.5.1',
]; ];

View File

@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace Core\Views; namespace Core\Views;
use Core\Services\Csrf;
class View class View
{ {
private string $basePath; private string $basePath;
@@ -26,6 +28,28 @@ class View
ob_start(); ob_start();
require $path; require $path;
return ob_get_clean() ?: ''; $html = ob_get_clean() ?: '';
return $this->injectCsrfTokens($html);
}
private function injectCsrfTokens(string $html): string
{
if ($html === '' || stripos($html, '<form') === false) {
return $html;
}
$tokenField = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(Csrf::token(), ENT_QUOTES, 'UTF-8') . '">';
return (string)preg_replace_callback(
'~<form\b[^>]*>~i',
static function (array $matches) use ($tokenField): string {
$tag = $matches[0];
if (!preg_match('~\bmethod\s*=\s*([\"\']?)post\1~i', $tag)) {
return $tag;
}
return $tag . $tokenField;
},
$html
);
} }
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
ini_set('display_errors', '1'); ini_set('display_errors', '0');
ini_set('display_startup_errors', '1'); ini_set('display_startup_errors', '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
error_reporting(E_ALL); error_reporting(E_ALL);

View File

@@ -42,6 +42,9 @@ class AdminController
public function installer(): Response public function installer(): Response
{ {
if ($this->appInstalled()) {
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
}
$installer = $_SESSION['installer'] ?? []; $installer = $_SESSION['installer'] ?? [];
$step = !empty($installer['core_ready']) ? 2 : 1; $step = !empty($installer['core_ready']) ? 2 : 1;
$values = is_array($installer['values'] ?? null) ? $installer['values'] : []; $values = is_array($installer['values'] ?? null) ? $installer['values'] : [];
@@ -61,6 +64,9 @@ class AdminController
public function install(): Response public function install(): Response
{ {
if ($this->appInstalled()) {
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
}
$action = trim((string)($_POST['installer_action'] ?? 'setup_core')); $action = trim((string)($_POST['installer_action'] ?? 'setup_core'));
if ($action === 'setup_core') { if ($action === 'setup_core') {
return $this->installSetupCore(); return $this->installSetupCore();
@@ -307,9 +313,9 @@ class AdminController
'title' => 'Settings', 'title' => 'Settings',
'status' => $status, 'status' => $status,
'status_message' => $statusMessage, 'status_message' => $statusMessage,
'footer_text' => Settings::get('footer_text', 'AudioCore V1.5'), 'footer_text' => Settings::get('footer_text', 'AudioCore V1.5.1'),
'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')), 'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')),
'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5'), 'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5.1'),
'site_header_tagline' => Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'), 'site_header_tagline' => Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'),
'site_header_badge_text' => Settings::get('site_header_badge_text', 'Independent catalog'), 'site_header_badge_text' => Settings::get('site_header_badge_text', 'Independent catalog'),
'site_header_brand_mode' => Settings::get('site_header_brand_mode', 'default'), 'site_header_brand_mode' => Settings::get('site_header_brand_mode', 'default'),
@@ -327,6 +333,7 @@ class AdminController
'site_maintenance_button_label' => Settings::get('site_maintenance_button_label', ''), 'site_maintenance_button_label' => Settings::get('site_maintenance_button_label', ''),
'site_maintenance_button_url' => Settings::get('site_maintenance_button_url', ''), 'site_maintenance_button_url' => Settings::get('site_maintenance_button_url', ''),
'site_maintenance_html' => Settings::get('site_maintenance_html', ''), 'site_maintenance_html' => Settings::get('site_maintenance_html', ''),
'site_maintenance_access_password_enabled' => Settings::get('site_maintenance_access_password_hash', '') !== '' ? '1' : '0',
'smtp_host' => Settings::get('smtp_host', ''), 'smtp_host' => Settings::get('smtp_host', ''),
'smtp_port' => Settings::get('smtp_port', '587'), 'smtp_port' => Settings::get('smtp_port', '587'),
'smtp_user' => Settings::get('smtp_user', ''), 'smtp_user' => Settings::get('smtp_user', ''),
@@ -336,11 +343,12 @@ class AdminController
'smtp_from_name' => Settings::get('smtp_from_name', ''), 'smtp_from_name' => Settings::get('smtp_from_name', ''),
'mailchimp_api_key' => Settings::get('mailchimp_api_key', ''), 'mailchimp_api_key' => Settings::get('mailchimp_api_key', ''),
'mailchimp_list_id' => Settings::get('mailchimp_list_id', ''), 'mailchimp_list_id' => Settings::get('mailchimp_list_id', ''),
'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5')), 'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5.1')),
'seo_meta_description' => Settings::get('seo_meta_description', ''), 'seo_meta_description' => Settings::get('seo_meta_description', ''),
'seo_robots_index' => Settings::get('seo_robots_index', '1'), 'seo_robots_index' => Settings::get('seo_robots_index', '1'),
'seo_robots_follow' => Settings::get('seo_robots_follow', '1'), 'seo_robots_follow' => Settings::get('seo_robots_follow', '1'),
'seo_og_image' => Settings::get('seo_og_image', ''), 'seo_og_image' => Settings::get('seo_og_image', ''),
'site_custom_css' => Settings::get('site_custom_css', ''),
'redirects' => $redirects, 'redirects' => $redirects,
'permission_definitions' => Permissions::definitions(), 'permission_definitions' => Permissions::definitions(),
'permission_matrix' => Permissions::matrix(), 'permission_matrix' => Permissions::matrix(),
@@ -363,6 +371,20 @@ class AdminController
'source' => 'Releases plugin', 'source' => 'Releases plugin',
'enabled' => Plugins::isEnabled('releases'), 'enabled' => Plugins::isEnabled('releases'),
], ],
[
'tag' => '[latest-releases]',
'description' => 'Home-friendly alias of releases grid.',
'example' => '[latest-releases limit="8"]',
'source' => 'Releases plugin',
'enabled' => Plugins::isEnabled('releases'),
],
[
'tag' => '[new-artists]',
'description' => 'Outputs the latest active artists grid.',
'example' => '[new-artists limit="6"]',
'source' => 'Artists plugin',
'enabled' => Plugins::isEnabled('artists'),
],
[ [
'tag' => '[sale-chart]', 'tag' => '[sale-chart]',
'description' => 'Outputs a best-sellers chart.', 'description' => 'Outputs a best-sellers chart.',
@@ -370,6 +392,27 @@ class AdminController
'source' => 'Store plugin', 'source' => 'Store plugin',
'enabled' => Plugins::isEnabled('store'), 'enabled' => Plugins::isEnabled('store'),
], ],
[
'tag' => '[top-sellers]',
'description' => 'Alias for sale chart block.',
'example' => '[top-sellers type="tracks" window="weekly" limit="10"]',
'source' => 'Store plugin',
'enabled' => Plugins::isEnabled('store'),
],
[
'tag' => '[hero]',
'description' => 'Home hero block with CTA buttons.',
'example' => '[hero title="Latest Drops" subtitle="Fresh releases weekly" cta_text="Browse Releases" cta_url="/releases"]',
'source' => 'Pages module',
'enabled' => true,
],
[
'tag' => '[home-catalog]',
'description' => 'Complete homepage catalog block (hero + releases + chart + artists + newsletter).',
'example' => '[home-catalog release_limit="8" artist_limit="6" chart_limit="10"]',
'source' => 'Pages module',
'enabled' => true,
],
[ [
'tag' => '[login-link]', 'tag' => '[login-link]',
'description' => 'Renders an account login link.', 'description' => 'Renders an account login link.',
@@ -469,7 +512,12 @@ class AdminController
$allowedTags = [ $allowedTags = [
'releases', 'releases',
'latest-releases',
'new-artists',
'sale-chart', 'sale-chart',
'top-sellers',
'hero',
'home-catalog',
'login-link', 'login-link',
'account-link', 'account-link',
'cart-link', 'cart-link',
@@ -498,7 +546,7 @@ class AdminController
. '.preview-shell{max-width:1080px;margin:0 auto;border:1px solid rgba(255,255,255,.12);border-radius:16px;background:rgba(20,22,28,.72);padding:18px;}' . '.preview-shell{max-width:1080px;margin:0 auto;border:1px solid rgba(255,255,255,.12);border-radius:16px;background:rgba(20,22,28,.72);padding:18px;}'
. '.preview-head{font-family:"IBM Plex Mono",monospace;font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.55);margin-bottom:14px;}' . '.preview-head{font-family:"IBM Plex Mono",monospace;font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.55);margin-bottom:14px;}'
. '.ac-shortcode-empty{border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.02);border-radius:14px;padding:12px 14px;color:#9aa0b2;font-size:13px;}' . '.ac-shortcode-empty{border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.02);border-radius:14px;padding:12px 14px;color:#9aa0b2;font-size:13px;}'
. '.ac-shortcode-release-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}' . '.ac-shortcode-release-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
. '.ac-shortcode-release-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:14px;overflow:hidden;display:grid;min-height:100%;}' . '.ac-shortcode-release-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:14px;overflow:hidden;display:grid;min-height:100%;}'
. '.ac-shortcode-release-cover{aspect-ratio:1/1;background:rgba(255,255,255,0.03);display:grid;place-items:center;overflow:hidden;}' . '.ac-shortcode-release-cover{aspect-ratio:1/1;background:rgba(255,255,255,0.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-release-cover img{width:100%;height:100%;object-fit:cover;display:block;}' . '.ac-shortcode-release-cover img{width:100%;height:100%;object-fit:cover;display:block;}'
@@ -506,11 +554,25 @@ class AdminController
. '.ac-shortcode-release-meta{padding:10px;display:grid;gap:4px;}' . '.ac-shortcode-release-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-release-title{font-size:18px;line-height:1.2;font-weight:600;}' . '.ac-shortcode-release-title{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-release-artist,.ac-shortcode-release-date{color:#9aa0b2;font-size:12px;}' . '.ac-shortcode-release-artist,.ac-shortcode-release-date{color:#9aa0b2;font-size:12px;}'
. '.ac-shortcode-artists-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
. '.ac-shortcode-artist-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,.1);background:rgba(15,18,24,.6);border-radius:14px;overflow:hidden;display:grid;}'
. '.ac-shortcode-artist-avatar{aspect-ratio:1/1;background:rgba(255,255,255,.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-artist-avatar img{width:100%;height:100%;object-fit:cover;display:block;}'
. '.ac-shortcode-artist-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-artist-name{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-artist-country{color:#9aa0b2;font-size:12px;}'
. '.ac-shortcode-sale-list{list-style:none;margin:0;padding:0;display:grid;gap:8px;}' . '.ac-shortcode-sale-list{list-style:none;margin:0;padding:0;display:grid;gap:8px;}'
. '.ac-shortcode-sale-item{border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:10px;padding:10px 12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;}' . '.ac-shortcode-sale-item{border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:10px;padding:10px 12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;}'
. '.ac-shortcode-sale-rank{font-family:"IBM Plex Mono",monospace;font-size:11px;color:#9aa0b2;letter-spacing:.15em;}' . '.ac-shortcode-sale-rank{font-family:"IBM Plex Mono",monospace;font-size:11px;color:#9aa0b2;letter-spacing:.15em;}'
. '.ac-shortcode-sale-title{font-size:14px;line-height:1.3;}' . '.ac-shortcode-sale-title{font-size:14px;line-height:1.3;}'
. '.ac-shortcode-sale-meta{font-size:12px;color:#9aa0b2;white-space:nowrap;}' . '.ac-shortcode-sale-meta{font-size:12px;color:#9aa0b2;white-space:nowrap;}'
. '.ac-shortcode-hero{border:1px solid rgba(255,255,255,.14);border-radius:18px;padding:18px;background:linear-gradient(135deg,rgba(255,255,255,.05),rgba(255,255,255,.01));display:grid;gap:10px;}'
. '.ac-shortcode-hero-eyebrow{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#9aa0b2;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-title{font-size:32px;line-height:1.05;font-weight:700;}'
. '.ac-shortcode-hero-subtitle{font-size:15px;color:#d1d7e7;max-width:72ch;}'
. '.ac-shortcode-hero-actions{display:flex;gap:8px;flex-wrap:wrap;}'
. '.ac-shortcode-hero-btn{display:inline-flex;align-items:center;justify-content:center;height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.05);color:#f5f7ff;text-decoration:none;font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-btn.primary{border-color:rgba(57,244,179,.6);background:rgba(57,244,179,.16);color:#9ff8d8;}'
. '.ac-shortcode-link{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.15);background:rgba(15,18,24,.6);color:#f5f7ff;text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase;}' . '.ac-shortcode-link{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.15);background:rgba(15,18,24,.6);color:#f5f7ff;text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase;}'
. '.ac-shortcode-link:hover{border-color:rgba(57,244,179,.6);color:#9ff8d8;}' . '.ac-shortcode-link:hover{border-color:rgba(57,244,179,.6);color:#9ff8d8;}'
. '.ac-shortcode-newsletter-form{display:grid;gap:10px;border:1px solid rgba(255,255,255,.15);border-radius:14px;background:rgba(15,18,24,.6);padding:14px;}' . '.ac-shortcode-newsletter-form{display:grid;gap:10px;border:1px solid rgba(255,255,255,.15);border-radius:14px;background:rgba(15,18,24,.6);padding:14px;}'
@@ -518,6 +580,10 @@ class AdminController
. '.ac-shortcode-newsletter-row{display:grid;grid-template-columns:1fr auto;gap:8px;}' . '.ac-shortcode-newsletter-row{display:grid;grid-template-columns:1fr auto;gap:8px;}'
. '.ac-shortcode-newsletter-input{height:40px;border:1px solid rgba(255,255,255,.16);border-radius:10px;background:rgba(8,10,16,.6);color:#f5f7ff;padding:0 12px;font-size:14px;}' . '.ac-shortcode-newsletter-input{height:40px;border:1px solid rgba(255,255,255,.16);border-radius:10px;background:rgba(8,10,16,.6);color:#f5f7ff;padding:0 12px;font-size:14px;}'
. '.ac-shortcode-newsletter-btn{height:40px;padding:0 14px;border:1px solid rgba(57,244,179,.6);border-radius:999px;background:rgba(57,244,179,.16);color:#9ff8d8;font-size:12px;letter-spacing:.14em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;cursor:pointer;}' . '.ac-shortcode-newsletter-btn{height:40px;padding:0 14px;border:1px solid rgba(57,244,179,.6);border-radius:999px;background:rgba(57,244,179,.16);color:#9ff8d8;font-size:12px;letter-spacing:.14em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;cursor:pointer;}'
. '.ac-home-catalog{display:grid;gap:14px;}'
. '.ac-home-columns{display:grid;grid-template-columns:minmax(0,2.2fr) minmax(280px,1fr);gap:14px;align-items:start;}'
. '.ac-home-main,.ac-home-side{display:grid;gap:14px;align-content:start;}'
. '@media (max-width:1200px){.ac-home-columns{grid-template-columns:1fr;}}'
. '</style></head><body>' . '</style></head><body>'
. '<div class="preview-shell"><div class="preview-head">' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>' . '<div class="preview-shell"><div class="preview-head">' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</div>'
. $rendered . $rendered
@@ -573,6 +639,8 @@ class AdminController
$maintenanceButtonLabel = trim((string)($_POST['site_maintenance_button_label'] ?? '')); $maintenanceButtonLabel = trim((string)($_POST['site_maintenance_button_label'] ?? ''));
$maintenanceButtonUrl = trim((string)($_POST['site_maintenance_button_url'] ?? '')); $maintenanceButtonUrl = trim((string)($_POST['site_maintenance_button_url'] ?? ''));
$maintenanceHtml = trim((string)($_POST['site_maintenance_html'] ?? '')); $maintenanceHtml = trim((string)($_POST['site_maintenance_html'] ?? ''));
$maintenanceAccessPassword = trim((string)($_POST['site_maintenance_access_password'] ?? ''));
$maintenanceAccessPasswordClear = isset($_POST['site_maintenance_access_password_clear']);
$smtpHost = trim((string)($_POST['smtp_host'] ?? '')); $smtpHost = trim((string)($_POST['smtp_host'] ?? ''));
$smtpPort = trim((string)($_POST['smtp_port'] ?? '')); $smtpPort = trim((string)($_POST['smtp_port'] ?? ''));
$smtpUser = trim((string)($_POST['smtp_user'] ?? '')); $smtpUser = trim((string)($_POST['smtp_user'] ?? ''));
@@ -587,6 +655,7 @@ class AdminController
$seoRobotsIndex = isset($_POST['seo_robots_index']) ? '1' : '0'; $seoRobotsIndex = isset($_POST['seo_robots_index']) ? '1' : '0';
$seoRobotsFollow = isset($_POST['seo_robots_follow']) ? '1' : '0'; $seoRobotsFollow = isset($_POST['seo_robots_follow']) ? '1' : '0';
$seoOgImage = trim((string)($_POST['seo_og_image'] ?? '')); $seoOgImage = trim((string)($_POST['seo_og_image'] ?? ''));
$siteCustomCss = trim((string)($_POST['site_custom_css'] ?? ''));
Settings::set('footer_text', $footer); Settings::set('footer_text', $footer);
$footerLinks = $this->parseFooterLinks($footerLinksJson); $footerLinks = $this->parseFooterLinks($footerLinksJson);
@@ -615,6 +684,11 @@ class AdminController
Settings::set('site_maintenance_button_label', $maintenanceButtonLabel); Settings::set('site_maintenance_button_label', $maintenanceButtonLabel);
Settings::set('site_maintenance_button_url', $maintenanceButtonUrl); Settings::set('site_maintenance_button_url', $maintenanceButtonUrl);
Settings::set('site_maintenance_html', $maintenanceHtml); Settings::set('site_maintenance_html', $maintenanceHtml);
if ($maintenanceAccessPasswordClear) {
Settings::set('site_maintenance_access_password_hash', '');
} elseif ($maintenanceAccessPassword !== '') {
Settings::set('site_maintenance_access_password_hash', password_hash($maintenanceAccessPassword, PASSWORD_DEFAULT));
}
Settings::set('smtp_host', $smtpHost); Settings::set('smtp_host', $smtpHost);
Settings::set('smtp_port', $smtpPort); Settings::set('smtp_port', $smtpPort);
Settings::set('smtp_user', $smtpUser); Settings::set('smtp_user', $smtpUser);
@@ -629,10 +703,11 @@ class AdminController
Settings::set('seo_robots_index', $seoRobotsIndex); Settings::set('seo_robots_index', $seoRobotsIndex);
Settings::set('seo_robots_follow', $seoRobotsFollow); Settings::set('seo_robots_follow', $seoRobotsFollow);
Settings::set('seo_og_image', $seoOgImage); Settings::set('seo_og_image', $seoOgImage);
Settings::set('site_custom_css', $siteCustomCss);
Audit::log('settings.save', [ Audit::log('settings.save', [
'updated_keys' => [ 'updated_keys' => [
'footer_text', 'footer_links_json', 'site_header_*', 'fontawesome_*', 'footer_text', 'footer_links_json', 'site_header_*', 'fontawesome_*',
'site_maintenance_*', 'smtp_*', 'mailchimp_*', 'seo_*', 'site_maintenance_*', 'smtp_*', 'mailchimp_*', 'seo_*', 'site_custom_css',
], ],
]); ]);
return new Response('', 302, ['Location' => '/admin/settings']); return new Response('', 302, ['Location' => '/admin/settings']);
@@ -700,7 +775,18 @@ class AdminController
. " 'pass' => '" . addslashes($dbPass) . "',\n" . " 'pass' => '" . addslashes($dbPass) . "',\n"
. " 'port' => " . (int)$dbPort . ",\n" . " 'port' => " . (int)$dbPort . ",\n"
. "];\n"; . "];\n";
$configPath = __DIR__ . '/../../storage/db.php'; $storageDir = __DIR__ . '/../../storage';
if (!is_dir($storageDir)) {
if (!@mkdir($storageDir, 0775, true) && !is_dir($storageDir)) {
return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to create storage directory.')]);
}
}
$settingsPath = $storageDir . '/settings.php';
if (!is_file($settingsPath)) {
$settingsSeed = "<?php\nreturn [\n 'site_title' => 'AudioCore V1.5.1',\n];\n";
@file_put_contents($settingsPath, $settingsSeed);
}
$configPath = $storageDir . '/db.php';
if (file_put_contents($configPath, $config) === false) { if (file_put_contents($configPath, $config) === false) {
return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to write DB config file.')]); return new Response('', 302, ['Location' => '/admin/installer?error=' . rawurlencode('Unable to write DB config file.')]);
} }
@@ -853,7 +939,7 @@ class AdminController
]; ];
$subject = 'AudioCore installer SMTP test'; $subject = 'AudioCore installer SMTP test';
$html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5 installer SMTP settings are valid.</p>' $html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5.1 installer SMTP settings are valid.</p>'
. '<p><strong>Generated:</strong> ' . gmdate('Y-m-d H:i:s') . ' UTC</p>'; . '<p><strong>Generated:</strong> ' . gmdate('Y-m-d H:i:s') . ' UTC</p>';
$mail = Mailer::send($testEmail, $subject, $html, $smtpSettings); $mail = Mailer::send($testEmail, $subject, $html, $smtpSettings);
@@ -956,9 +1042,9 @@ class AdminController
private function installerDefaultValues(): array private function installerDefaultValues(): array
{ {
return [ return [
'site_title' => 'AudioCore V1.5', 'site_title' => 'AudioCore V1.5.1',
'site_tagline' => 'Core CMS for DJs & Producers', 'site_tagline' => 'Core CMS for DJs & Producers',
'seo_title_suffix' => 'AudioCore V1.5', 'seo_title_suffix' => 'AudioCore V1.5.1',
'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.', 'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.',
'smtp_host' => '', 'smtp_host' => '',
'smtp_port' => '587', 'smtp_port' => '587',
@@ -966,7 +1052,7 @@ class AdminController
'smtp_pass' => '', 'smtp_pass' => '',
'smtp_encryption' => 'tls', 'smtp_encryption' => 'tls',
'smtp_from_email' => '', 'smtp_from_email' => '',
'smtp_from_name' => 'AudioCore V1.5', 'smtp_from_name' => 'AudioCore V1.5.1',
]; ];
} }
@@ -1592,6 +1678,24 @@ class AdminController
return Database::get() instanceof PDO; return Database::get() instanceof PDO;
} }
private function appInstalled(): bool
{
$db = Database::get();
if (!$db instanceof PDO) {
return false;
}
$this->ensureCoreTables();
try {
$adminUsers = (int)$db->query("SELECT COUNT(*) FROM ac_admin_users")->fetchColumn();
$legacyAdmins = (int)$db->query("SELECT COUNT(*) FROM ac_admins")->fetchColumn();
return ($adminUsers + $legacyAdmins) > 0;
} catch (Throwable $e) {
return false;
}
}
private function normalizeUrl(string $url): string private function normalizeUrl(string $url): string
{ {
if (preg_match('~^(https?://|/|#|mailto:)~i', $url)) { if (preg_match('~^(https?://|/|#|mailto:)~i', $url)) {

View File

@@ -11,35 +11,150 @@ $val = static function (string $key, string $default = '') use ($values): string
ob_start(); ob_start();
?> ?>
<section class="card" style="max-width:980px; margin:0 auto;"> <style>
<div class="badge">Setup</div> .ac-installer { max-width: 980px; margin: 0 auto; }
<h1 style="margin:16px 0 6px; font-size:30px;">AudioCore V1.5 Installer</h1> .ac-installer-title { margin: 16px 0 6px; font-size: 42px; line-height: 1.05; }
<p style="margin:0; color:rgba(235,241,255,.75);">Deploy a fresh instance with validated SMTP and baseline health checks.</p> .ac-installer-intro { margin: 0; color: rgba(235,241,255,.75); }
.ac-installer-steps { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
.ac-step-pill {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.03);
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
}
.ac-step-pill.is-active { background: rgba(57,244,179,.18); }
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;"> .ac-installer-form { margin-top: 18px; display: grid; gap: 14px; }
<div style="padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.16); background:<?= $step === 1 ? 'rgba(57,244,179,.18)' : 'rgba(255,255,255,.03)' ?>;"> .ac-installer-grid {
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase;">1. Core Setup</span> display: grid;
</div> grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
<div style="padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.16); background:<?= $step === 2 ? 'rgba(57,244,179,.18)' : 'rgba(255,255,255,.03)' ?>;"> gap: 12px;
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase;">2. Site + SMTP</span> }
</div> .ac-installer-section {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,.10);
}
.ac-installer label {
display: inline-block;
margin: 0 0 8px;
font-size: 12px;
color: rgba(238,244,255,.78);
text-transform: uppercase;
letter-spacing: .16em;
font-family: 'IBM Plex Mono', monospace;
}
.ac-installer .input,
.ac-installer textarea.input,
.ac-installer select.input {
width: 100%;
height: 46px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(12,16,24,.58);
color: #edf2ff;
padding: 0 14px;
font-size: 15px;
outline: none;
}
.ac-installer textarea.input {
height: auto;
min-height: 92px;
padding: 12px 14px;
resize: vertical;
}
.ac-installer .input:focus {
border-color: rgba(57,244,179,.55);
box-shadow: 0 0 0 3px rgba(57,244,179,.14);
}
.ac-installer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.ac-installer .button {
height: 44px;
border-radius: 999px;
padding: 0 18px;
border: 1px solid rgba(255,255,255,.2);
background: rgba(255,255,255,.04);
color: #eef4ff;
text-transform: uppercase;
letter-spacing: .16em;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ac-installer .button:hover { background: rgba(255,255,255,.1); }
.ac-installer .button-primary {
background: linear-gradient(135deg, #25afff, #2bd8ff);
border-color: rgba(40,185,255,.75);
color: #041019;
}
.ac-installer .button-primary:hover { filter: brightness(1.05); }
.ac-installer-alert {
margin-top: 16px;
border-radius: 12px;
padding: 12px 14px;
}
.ac-installer-alert.error {
border: 1px solid rgba(255,124,124,.45);
background: rgba(180,40,40,.18);
color: #ffd6d6;
}
.ac-installer-alert.success {
border: 1px solid rgba(57,244,179,.45);
background: rgba(10,90,60,.22);
color: #b8ffe5;
}
.ac-installer-checks {
padding: 12px;
border: 1px solid rgba(255,255,255,.1);
border-radius: 12px;
background: rgba(255,255,255,.02);
}
.ac-installer-checks-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .16em;
color: rgba(255,255,255,.65);
margin-bottom: 10px;
font-family: 'IBM Plex Mono', monospace;
}
</style>
<section class="card ac-installer">
<div class="badge">Setup</div>
<h1 class="ac-installer-title">AudioCore V1.5.1 Installer</h1>
<p class="ac-installer-intro">Deploy a fresh instance with validated SMTP and baseline health checks.</p>
<div class="ac-installer-steps">
<div class="ac-step-pill <?= $step === 1 ? 'is-active' : '' ?>">1. Core Setup</div>
<div class="ac-step-pill <?= $step === 2 ? 'is-active' : '' ?>">2. Site + SMTP</div>
</div> </div>
<?php if (!empty($error)): ?> <?php if (!empty($error)): ?>
<div style="margin-top:16px; border:1px solid rgba(255,124,124,.45); background:rgba(180,40,40,.18); border-radius:12px; padding:12px 14px; color:#ffd6d6;"> <div class="ac-installer-alert error"><?= htmlspecialchars((string)$error, ENT_QUOTES, 'UTF-8') ?></div>
<?= htmlspecialchars((string)$error, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($success)): ?> <?php if (!empty($success)): ?>
<div style="margin-top:16px; border:1px solid rgba(57,244,179,.45); background:rgba(10,90,60,.22); border-radius:12px; padding:12px 14px; color:#b8ffe5;"> <div class="ac-installer-alert success"><?= htmlspecialchars((string)$success, ENT_QUOTES, 'UTF-8') ?></div>
<?= htmlspecialchars((string)$success, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($step === 1): ?> <?php if ($step === 1): ?>
<form method="post" action="/admin/install" style="margin-top:18px; display:grid; gap:14px;"> <form method="post" action="/admin/install" class="ac-installer-form">
<input type="hidden" name="installer_action" value="setup_core"> <input type="hidden" name="installer_action" value="setup_core">
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;"> <div class="ac-installer-grid">
<div> <div>
<label>DB Host *</label> <label>DB Host *</label>
<input class="input" name="db_host" value="<?= htmlspecialchars($val('db_host', 'localhost'), ENT_QUOTES, 'UTF-8') ?>"> <input class="input" name="db_host" value="<?= htmlspecialchars($val('db_host', 'localhost'), ENT_QUOTES, 'UTF-8') ?>">
@@ -62,7 +177,7 @@ ob_start();
</div> </div>
</div> </div>
<div style="margin-top:6px; padding-top:14px; border-top:1px solid rgba(255,255,255,.1); display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;"> <div class="ac-installer-section ac-installer-grid">
<div> <div>
<label>Admin Name *</label> <label>Admin Name *</label>
<input class="input" name="admin_name" value="<?= htmlspecialchars($val('admin_name', 'Admin'), ENT_QUOTES, 'UTF-8') ?>"> <input class="input" name="admin_name" value="<?= htmlspecialchars($val('admin_name', 'Admin'), ENT_QUOTES, 'UTF-8') ?>">
@@ -77,16 +192,16 @@ ob_start();
</div> </div>
</div> </div>
<div style="display:flex; justify-content:flex-end; margin-top:4px;"> <div class="ac-installer-actions">
<button type="submit" class="button button-primary">Create Core Setup</button> <button type="submit" class="button button-primary">Create Core Setup</button>
</div> </div>
</form> </form>
<?php else: ?> <?php else: ?>
<form method="post" action="/admin/install" style="margin-top:18px; display:grid; gap:14px;"> <form method="post" action="/admin/install" class="ac-installer-form">
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;"> <div class="ac-installer-grid">
<div> <div>
<label>Site Title *</label> <label>Site Title *</label>
<input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>"> <input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div> </div>
<div> <div>
<label>Site Tagline</label> <label>Site Tagline</label>
@@ -94,15 +209,15 @@ ob_start();
</div> </div>
<div> <div>
<label>SEO Title Suffix</label> <label>SEO Title Suffix</label>
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($val('seo_title_suffix', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>"> <input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($val('seo_title_suffix', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div> </div>
<div style="grid-column:1/-1;"> <div style="grid-column:1/-1;">
<label>SEO Meta Description</label> <label>SEO Meta Description</label>
<textarea class="input" name="seo_meta_description" rows="3" style="resize:vertical;"><?= htmlspecialchars($val('seo_meta_description', ''), ENT_QUOTES, 'UTF-8') ?></textarea> <textarea class="input" name="seo_meta_description" rows="3"><?= htmlspecialchars($val('seo_meta_description', ''), ENT_QUOTES, 'UTF-8') ?></textarea>
</div> </div>
</div> </div>
<div style="padding-top:12px; border-top:1px solid rgba(255,255,255,.1); display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;"> <div class="ac-installer-section ac-installer-grid">
<div> <div>
<label>SMTP Host *</label> <label>SMTP Host *</label>
<input class="input" name="smtp_host" value="<?= htmlspecialchars($val('smtp_host', ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="smtp.example.com"> <input class="input" name="smtp_host" value="<?= htmlspecialchars($val('smtp_host', ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="smtp.example.com">
@@ -129,7 +244,7 @@ ob_start();
</div> </div>
<div> <div>
<label>SMTP From Name</label> <label>SMTP From Name</label>
<input class="input" name="smtp_from_name" value="<?= htmlspecialchars($val('smtp_from_name', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>"> <input class="input" name="smtp_from_name" value="<?= htmlspecialchars($val('smtp_from_name', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
</div> </div>
<div> <div>
<label>Test Recipient Email *</label> <label>Test Recipient Email *</label>
@@ -138,13 +253,11 @@ ob_start();
</div> </div>
<?php if (!empty($smtpResult)): ?> <?php if (!empty($smtpResult)): ?>
<div style="border:1px solid <?= !empty($smtpResult['ok']) ? 'rgba(57,244,179,.45)' : 'rgba(255,124,124,.45)' ?>; background:<?= !empty($smtpResult['ok']) ? 'rgba(10,90,60,.22)' : 'rgba(180,40,40,.18)' ?>; border-radius:12px; padding:12px 14px;"> <div class="ac-installer-alert <?= !empty($smtpResult['ok']) ? 'success' : 'error' ?>" style="margin-top:0;">
<div style="font-weight:700; margin-bottom:4px; color:<?= !empty($smtpResult['ok']) ? '#b8ffe5' : '#ffd6d6' ?>"> <div style="font-weight:700; margin-bottom:4px;">
<?= !empty($smtpResult['ok']) ? 'SMTP test passed' : 'SMTP test failed' ?> <?= !empty($smtpResult['ok']) ? 'SMTP test passed' : 'SMTP test failed' ?>
</div> </div>
<div style="color:<?= !empty($smtpResult['ok']) ? '#b8ffe5' : '#ffd6d6' ?>;"> <div><?= htmlspecialchars((string)($smtpResult['message'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
<?= htmlspecialchars((string)($smtpResult['message'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
</div>
<?php if (!empty($smtpResult['debug'])): ?> <?php if (!empty($smtpResult['debug'])): ?>
<details style="margin-top:8px;"> <details style="margin-top:8px;">
<summary style="cursor:pointer;">Debug output</summary> <summary style="cursor:pointer;">Debug output</summary>
@@ -155,13 +268,13 @@ ob_start();
<?php endif; ?> <?php endif; ?>
<?php if (!empty($checks)): ?> <?php if (!empty($checks)): ?>
<div style="padding:12px; border:1px solid rgba(255,255,255,.1); border-radius:12px; background:rgba(255,255,255,.02);"> <div class="ac-installer-checks">
<div style="font-size:12px; text-transform:uppercase; letter-spacing:.16em; color:rgba(255,255,255,.65); margin-bottom:10px;">Installer Health Checks</div> <div class="ac-installer-checks-title">Installer Health Checks</div>
<div style="display:grid; gap:8px;"> <div style="display:grid; gap:8px;">
<?php foreach ($checks as $check): ?> <?php foreach ($checks as $check): ?>
<div style="display:flex; gap:10px; align-items:flex-start;"> <div style="display:flex; gap:10px; align-items:flex-start;">
<span style="display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:999px; font-size:12px; margin-top:2px; background:<?= !empty($check['ok']) ? 'rgba(57,244,179,.2)' : 'rgba(255,124,124,.2)' ?>; color:<?= !empty($check['ok']) ? '#9ff8d8' : '#ffb7b7' ?>;"> <span style="display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:999px; font-size:12px; margin-top:2px; background:<?= !empty($check['ok']) ? 'rgba(57,244,179,.2)' : 'rgba(255,124,124,.2)' ?>; color:<?= !empty($check['ok']) ? '#9ff8d8' : '#ffb7b7' ?>;">
<?= !empty($check['ok']) ? '' : '!' ?> <?= !empty($check['ok']) ? '&#10003;' : '!' ?>
</span> </span>
<div> <div>
<div style="font-weight:600;"><?= htmlspecialchars((string)($check['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div> <div style="font-weight:600;"><?= htmlspecialchars((string)($check['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></div>
@@ -173,7 +286,7 @@ ob_start();
</div> </div>
<?php endif; ?> <?php endif; ?>
<div style="display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; margin-top:4px;"> <div class="ac-installer-actions">
<button type="submit" name="installer_action" value="test_smtp" class="button">Send Test Email + Run Checks</button> <button type="submit" name="installer_action" value="test_smtp" class="button">Send Test Email + Run Checks</button>
<button type="submit" name="installer_action" value="finish_install" class="button button-primary">Finish Installation</button> <button type="submit" name="installer_action" value="finish_install" class="button button-primary">Finish Installation</button>
</div> </div>

View File

@@ -28,6 +28,7 @@ if ($isAuthed) {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<?= csrf_meta() ?>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle ?? 'Admin', ENT_QUOTES, 'UTF-8') ?></title> <title><?= htmlspecialchars($pageTitle ?? 'Admin', ENT_QUOTES, 'UTF-8') ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -364,7 +365,7 @@ if ($isAuthed) {
<div class="sidebar-user"> <div class="sidebar-user">
<div class="icon"> <div class="icon">
<?php if ($faEnabled): ?> <?php if ($faEnabled): ?>
<i class="fa-duotone fa-head-side-headphones"></i> <i class="fa-solid fa-head-side-headphones"></i>
<?php else: ?> <?php else: ?>
AC AC
<?php endif; ?> <?php endif; ?>
@@ -454,6 +455,10 @@ if ($isAuthed) {
<?php if ($faEnabled): ?><i class="fa-solid fa-user-shield"></i><?php endif; ?> <?php if ($faEnabled): ?><i class="fa-solid fa-user-shield"></i><?php endif; ?>
Accounts Accounts
</a> </a>
<a href="/admin/api" class="<?= ($pageTitle ?? '') === 'API' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-plug-circle-bolt"></i><?php endif; ?>
API
</a>
<a href="/admin/plugins" class="<?= ($pageTitle ?? '') === 'Plugins' ? 'active' : '' ?>"> <a href="/admin/plugins" class="<?= ($pageTitle ?? '') === 'Plugins' ? 'active' : '' ?>">
<?php if ($faEnabled): ?><i class="fa-solid fa-plug"></i><?php endif; ?> <?php if ($faEnabled): ?><i class="fa-solid fa-plug"></i><?php endif; ?>
Plugins Plugins
@@ -472,7 +477,7 @@ if ($isAuthed) {
<div class="brand-badge">AC</div> <div class="brand-badge">AC</div>
<div> <div>
<div>AudioCore Admin</div> <div>AudioCore Admin</div>
<div class="badge">V1.5</div> <div class="badge">V1.5.1</div>
</div> </div>
</div> </div>
<div class="admin-actions"> <div class="admin-actions">

View File

@@ -21,6 +21,7 @@ ob_start();
<button type="button" class="settings-tab" data-tab="smtp" role="tab" aria-selected="false">SMTP</button> <button type="button" class="settings-tab" data-tab="smtp" role="tab" aria-selected="false">SMTP</button>
<button type="button" class="settings-tab" data-tab="mailchimp" role="tab" aria-selected="false">Mailchimp</button> <button type="button" class="settings-tab" data-tab="mailchimp" role="tab" aria-selected="false">Mailchimp</button>
<button type="button" class="settings-tab" data-tab="seo" role="tab" aria-selected="false">SEO</button> <button type="button" class="settings-tab" data-tab="seo" role="tab" aria-selected="false">SEO</button>
<button type="button" class="settings-tab" data-tab="custom_css" role="tab" aria-selected="false">Custom CSS</button>
<button type="button" class="settings-tab" data-tab="redirects" role="tab" aria-selected="false">Redirects</button> <button type="button" class="settings-tab" data-tab="redirects" role="tab" aria-selected="false">Redirects</button>
<button type="button" class="settings-tab" data-tab="permissions" role="tab" aria-selected="false">Permissions</button> <button type="button" class="settings-tab" data-tab="permissions" role="tab" aria-selected="false">Permissions</button>
<button type="button" class="settings-tab" data-tab="audit" role="tab" aria-selected="false">Audit Log</button> <button type="button" class="settings-tab" data-tab="audit" role="tab" aria-selected="false">Audit Log</button>
@@ -31,7 +32,7 @@ ob_start();
<div class="badge" style="opacity:0.7;">Branding</div> <div class="badge" style="opacity:0.7;">Branding</div>
<div style="margin-top:12px; display:grid; gap:12px;"> <div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Header Title</label> <label class="label">Header Title</label>
<input class="input" name="site_header_title" value="<?= htmlspecialchars($site_header_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5"> <input class="input" name="site_header_title" value="<?= htmlspecialchars($site_header_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<label class="label">Header Tagline</label> <label class="label">Header Tagline</label>
<input class="input" name="site_header_tagline" value="<?= htmlspecialchars($site_header_tagline ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Core CMS for DJs & Producers"> <input class="input" name="site_header_tagline" value="<?= htmlspecialchars($site_header_tagline ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Core CMS for DJs & Producers">
@@ -119,7 +120,7 @@ ob_start();
<div class="badge" style="opacity:0.7;">Footer</div> <div class="badge" style="opacity:0.7;">Footer</div>
<div style="margin-top:12px; display:grid; gap:12px;"> <div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Footer Text</label> <label class="label">Footer Text</label>
<input class="input" name="footer_text" value="<?= htmlspecialchars($footer_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5"> <input class="input" name="footer_text" value="<?= htmlspecialchars($footer_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<div style="font-size:12px; color:var(--muted);">Shown in the site footer.</div> <div style="font-size:12px; color:var(--muted);">Shown in the site footer.</div>
<div class="label">Footer Links</div> <div class="label">Footer Links</div>
@@ -157,6 +158,17 @@ ob_start();
<input class="input" name="site_maintenance_button_url" value="<?= htmlspecialchars($site_maintenance_button_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="/admin/login"> <input class="input" name="site_maintenance_button_url" value="<?= htmlspecialchars($site_maintenance_button_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="/admin/login">
</div> </div>
</div> </div>
<div style="display:grid; gap:10px; padding:14px; border:1px solid rgba(255,255,255,.08); border-radius:16px; background:rgba(255,255,255,.02);">
<div class="badge" style="opacity:.7;">Visitor Access Password</div>
<div style="font-size:12px; color:var(--muted);">
Set a password to let non-admin users unlock the site during maintenance mode. Leave blank to keep the current password.
</div>
<input class="input" type="password" name="site_maintenance_access_password" placeholder="<?= (($site_maintenance_access_password_enabled ?? '0') === '1') ? 'Password already set - enter a new one to replace it' : 'Set an access password' ?>">
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
<input type="checkbox" name="site_maintenance_access_password_clear" value="1">
Clear the current access password
</label>
</div>
<label class="label">Custom HTML (optional, overrides title/message layout)</label> <label class="label">Custom HTML (optional, overrides title/message layout)</label>
<textarea class="input" name="site_maintenance_html" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace;"><?= htmlspecialchars($site_maintenance_html ?? '', ENT_QUOTES, 'UTF-8') ?></textarea> <textarea class="input" name="site_maintenance_html" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace;"><?= htmlspecialchars($site_maintenance_html ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
</div> </div>
@@ -217,7 +229,7 @@ ob_start();
<div class="badge" style="opacity:0.7;">Global SEO</div> <div class="badge" style="opacity:0.7;">Global SEO</div>
<div style="margin-top:12px; display:grid; gap:12px;"> <div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Title Suffix</label> <label class="label">Title Suffix</label>
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($seo_title_suffix ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5"> <input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($seo_title_suffix ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
<label class="label">Default Meta Description</label> <label class="label">Default Meta Description</label>
<textarea class="input" name="seo_meta_description" rows="3" style="resize:vertical;"><?= htmlspecialchars($seo_meta_description ?? '', ENT_QUOTES, 'UTF-8') ?></textarea> <textarea class="input" name="seo_meta_description" rows="3" style="resize:vertical;"><?= htmlspecialchars($seo_meta_description ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
<label class="label">Open Graph Image URL</label> <label class="label">Open Graph Image URL</label>
@@ -236,6 +248,17 @@ ob_start();
</div> </div>
</div> </div>
<div class="settings-panel" data-panel="custom_css" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px;">
<div class="badge" style="opacity:0.7;">Custom CSS</div>
<div style="margin-top:12px; display:grid; gap:12px;">
<label class="label">Site-wide Custom CSS</label>
<textarea class="input" name="site_custom_css" rows="14" style="resize:vertical; font-family:'IBM Plex Mono', monospace;" placeholder=".my-custom-class { color: #22f2a5; }"><?= htmlspecialchars($site_custom_css ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
<div style="font-size:12px; color:var(--muted);">Applied on all frontend pages after theme styles.</div>
</div>
</div>
</div>
<div class="settings-panel" data-panel="redirects" role="tabpanel" hidden> <div class="settings-panel" data-panel="redirects" role="tabpanel" hidden>
<div class="admin-card" style="padding:16px; display:grid; gap:12px;"> <div class="admin-card" style="padding:16px; display:grid; gap:12px;">
<div class="badge" style="opacity:0.7;">Redirects Manager</div> <div class="badge" style="opacity:0.7;">Redirects Manager</div>

View File

@@ -0,0 +1,760 @@
<?php
declare(strict_types=1);
namespace Modules\Api;
use Core\Http\Response;
use Core\Services\ApiLayer;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Views\View;
use PDO;
use Throwable;
class ApiController
{
private View $view;
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function adminIndex(): Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
$db = Database::get();
$clients = [];
$createdKey = (string)($_SESSION['ac_api_created_key'] ?? '');
unset($_SESSION['ac_api_created_key']);
if ($db instanceof PDO) {
ApiLayer::ensureSchema($db);
try {
$stmt = $db->query("
SELECT id, name, api_key_prefix, webhook_url, is_active, last_used_at, last_used_ip, created_at
FROM ac_api_clients
ORDER BY created_at DESC, id DESC
");
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
} catch (Throwable $e) {
$clients = [];
}
}
return new Response($this->view->render('admin/index.php', [
'title' => 'API',
'clients' => $clients,
'created_key' => $createdKey,
'status' => trim((string)($_GET['status'] ?? '')),
'message' => trim((string)($_GET['message'] ?? '')),
'base_url' => $this->baseUrl(),
]));
}
public function adminCreateClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$name = trim((string)($_POST['name'] ?? ''));
$webhookUrl = trim((string)($_POST['webhook_url'] ?? ''));
if ($name === '') {
return $this->adminRedirect('error', 'Client name is required.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$plainKey = ApiLayer::issueClient($db, $name, $webhookUrl);
$_SESSION['ac_api_created_key'] = $plainKey;
return $this->adminRedirect('ok', 'API client created. Copy the key now.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to create API client.');
}
}
public function adminToggleClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("
UPDATE ac_api_clients
SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END
WHERE id = :id
");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client updated.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to update API client.');
}
}
public function adminDeleteClient(): Response
{
if ($guard = $this->guardAdmin()) {
return $guard;
}
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
return $this->adminRedirect('error', 'Invalid client.');
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->adminRedirect('error', 'Database unavailable.');
}
try {
$stmt = $db->prepare("DELETE FROM ac_api_clients WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $id]);
return $this->adminRedirect('ok', 'API client deleted.');
} catch (Throwable $e) {
return $this->adminRedirect('error', 'Unable to delete API client.');
}
}
public function authVerify(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
return $this->json([
'ok' => true,
'client' => [
'id' => (int)($client['id'] ?? 0),
'name' => (string)($client['name'] ?? ''),
'prefix' => (string)($client['api_key_prefix'] ?? ''),
],
]);
}
public function artistSales(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at,
o.email AS customer_email,
o.payment_provider,
o.payment_ref
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE a.artist_id = :artist_id
AND o.status = 'paid'
ORDER BY a.id DESC
LIMIT 500
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist sales.'], 500);
}
}
public function salesSince(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
if ($afterId <= 0 && $since === '') {
return $this->json(['ok' => false, 'error' => 'after_id or since is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$where = [
'a.artist_id = :artist_id',
"o.status = 'paid'",
];
$params = [':artist_id' => $artistId];
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
a.order_item_id,
a.source_item_type,
a.source_item_id,
a.release_id,
a.track_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id ASC
LIMIT 500
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load incremental sales.'], 500);
}
}
public function artistTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$artistId = (int)($_GET['artist_id'] ?? 0);
if ($artistId <= 0) {
return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422);
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
try {
$stmt = $db->prepare("
SELECT
t.id,
t.release_id,
r.title AS release_title,
r.slug AS release_slug,
r.catalog_no,
r.release_date,
t.track_no,
t.title,
t.mix_name,
t.duration,
t.bpm,
t.key_signature,
t.sample_url,
sp.is_enabled AS store_enabled,
sp.track_price,
sp.currency
FROM ac_release_tracks t
JOIN ac_releases r ON r.id = t.release_id
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
WHERE r.artist_id = :artist_id
ORDER BY r.release_date DESC, r.id DESC, t.track_no ASC, t.id ASC
");
$stmt->execute([':artist_id' => $artistId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'artist_id' => $artistId,
'count' => count($rows),
'tracks' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load artist tracks.'], 500);
}
}
public function submitRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$title = trim((string)($data['title'] ?? ''));
if ($title === '') {
return $this->json(['ok' => false, 'error' => 'title is required.'], 422);
}
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$slug = $this->slugify((string)($data['slug'] ?? $title));
try {
$stmt = $db->prepare("
INSERT INTO ac_releases
(title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published)
VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published)
");
$stmt->execute([
':title' => $title,
':slug' => $slug,
':artist_id' => $artistId > 0 ? $artistId : null,
':artist_name' => $artistName !== '' ? $artistName : null,
':description' => $this->nullableText($data['description'] ?? null),
':credits' => $this->nullableText($data['credits'] ?? null),
':catalog_no' => $this->nullableText($data['catalog_no'] ?? null),
':release_date' => $this->nullableText($data['release_date'] ?? null),
':cover_url' => $this->nullableText($data['cover_url'] ?? null),
':sample_url' => $this->nullableText($data['sample_url'] ?? null),
':is_published' => !empty($data['is_published']) ? 1 : 0,
]);
$releaseId = (int)$db->lastInsertId();
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'slug' => $slug,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to create release.'], 500);
}
}
public function submitTracks(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
$tracks = is_array($data['tracks'] ?? null) ? $data['tracks'] : [];
if ($releaseId <= 0 || !$tracks) {
return $this->json(['ok' => false, 'error' => 'release_id and tracks are required.'], 422);
}
$created = [];
try {
foreach ($tracks as $track) {
if (!is_array($track)) {
continue;
}
$trackId = (int)($track['id'] ?? 0);
$title = trim((string)($track['title'] ?? ''));
if ($title === '') {
continue;
}
if ($trackId > 0) {
$stmt = $db->prepare("
UPDATE ac_release_tracks
SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration,
bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url
WHERE id = :id AND release_id = :release_id
");
$stmt->execute([
':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
':id' => $trackId,
':release_id' => $releaseId,
]);
$created[] = ['id' => $trackId, 'updated' => true];
} else {
$stmt = $db->prepare("
INSERT INTO ac_release_tracks
(release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url)
VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url)
");
$stmt->execute([
':release_id' => $releaseId,
':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null,
':title' => $title,
':mix_name' => $this->nullableText($track['mix_name'] ?? null),
':duration' => $this->nullableText($track['duration'] ?? null),
':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null,
':key_signature' => $this->nullableText($track['key_signature'] ?? null),
':sample_url' => $this->nullableText($track['sample_url'] ?? null),
]);
$created[] = ['id' => (int)$db->lastInsertId(), 'created' => true];
}
}
return $this->json([
'ok' => true,
'release_id' => $releaseId,
'tracks' => $created,
], 201);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to submit tracks.'], 500);
}
}
public function updateRelease(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$data = $this->requestData();
$releaseId = (int)($data['release_id'] ?? 0);
if ($releaseId <= 0) {
return $this->json(['ok' => false, 'error' => 'release_id is required.'], 422);
}
$fields = [];
$params = [':id' => $releaseId];
$map = [
'title' => 'title',
'description' => 'description',
'credits' => 'credits',
'catalog_no' => 'catalog_no',
'release_date' => 'release_date',
'cover_url' => 'cover_url',
'sample_url' => 'sample_url',
'is_published' => 'is_published',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
$params[":{$input}"] = $input === 'is_published'
? (!empty($data[$input]) ? 1 : 0)
: $this->nullableText($data[$input]);
}
if (array_key_exists('slug', $data)) {
$fields[] = "slug = :slug";
$params[':slug'] = $this->slugify((string)$data['slug']);
}
if (array_key_exists('artist_id', $data) || array_key_exists('artist_name', $data)) {
$artistId = (int)($data['artist_id'] ?? 0);
$artistName = trim((string)($data['artist_name'] ?? ''));
if ($artistId > 0) {
$artistName = $this->artistNameById($db, $artistId) ?: $artistName;
}
$fields[] = "artist_id = :artist_id";
$fields[] = "artist_name = :artist_name";
$params[':artist_id'] = $artistId > 0 ? $artistId : null;
$params[':artist_name'] = $artistName !== '' ? $artistName : null;
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_releases SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'release_id' => $releaseId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update release.'], 500);
}
}
public function updateTrack(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$data = $this->requestData();
$trackId = (int)($data['track_id'] ?? 0);
if ($trackId <= 0) {
return $this->json(['ok' => false, 'error' => 'track_id is required.'], 422);
}
$fields = [];
$params = [':id' => $trackId];
$map = [
'track_no' => 'track_no',
'title' => 'title',
'mix_name' => 'mix_name',
'duration' => 'duration',
'bpm' => 'bpm',
'key_signature' => 'key_signature',
'sample_url' => 'sample_url',
];
foreach ($map as $input => $column) {
if (!array_key_exists($input, $data)) {
continue;
}
$fields[] = "{$column} = :{$input}";
if ($input === 'bpm' || $input === 'track_no') {
$params[":{$input}"] = $data[$input] !== '' ? (int)$data[$input] : null;
} else {
$params[":{$input}"] = $this->nullableText($data[$input]);
}
}
if (!$fields) {
return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422);
}
try {
$stmt = $db->prepare("UPDATE ac_release_tracks SET " . implode(', ', $fields) . " WHERE id = :id");
$stmt->execute($params);
return $this->json(['ok' => true, 'track_id' => $trackId]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to update track.'], 500);
}
}
public function orderItemData(): Response
{
$client = $this->requireClient();
if ($client instanceof Response) {
return $client;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
ApiLayer::ensureSchema($db);
$artistId = (int)($_GET['artist_id'] ?? 0);
$orderId = (int)($_GET['order_id'] ?? 0);
$afterId = (int)($_GET['after_id'] ?? 0);
$since = trim((string)($_GET['since'] ?? ''));
try {
$where = ['1=1'];
$params = [];
if ($artistId > 0) {
$where[] = 'a.artist_id = :artist_id';
$params[':artist_id'] = $artistId;
}
if ($orderId > 0) {
$where[] = 'a.order_id = :order_id';
$params[':order_id'] = $orderId;
}
if ($afterId > 0) {
$where[] = 'a.id > :after_id';
$params[':after_id'] = $afterId;
}
if ($since !== '') {
$where[] = 'a.created_at >= :since';
$params[':since'] = $since;
}
$stmt = $db->prepare("
SELECT
a.id,
a.order_id,
o.order_no,
o.status AS order_status,
o.email AS customer_email,
a.order_item_id,
a.artist_id,
a.release_id,
a.track_id,
a.source_item_type,
a.source_item_id,
a.title_snapshot,
a.qty,
a.gross_amount,
a.currency_snapshot,
a.created_at
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
WHERE " . implode(' AND ', $where) . "
ORDER BY a.id DESC
LIMIT 1000
");
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
return $this->json([
'ok' => true,
'count' => count($rows),
'rows' => $rows,
]);
} catch (Throwable $e) {
return $this->json(['ok' => false, 'error' => 'Unable to load order item data.'], 500);
}
}
private function requestData(): array
{
$contentType = strtolower(trim((string)($_SERVER['CONTENT_TYPE'] ?? '')));
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$decoded = json_decode(is_string($raw) ? $raw : '', true);
return is_array($decoded) ? $decoded : [];
}
return is_array($_POST) ? $_POST : [];
}
private function requireClient(): array|Response
{
$db = Database::get();
if (!($db instanceof PDO)) {
return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500);
}
$client = ApiLayer::verifyRequest($db);
if (!$client) {
return $this->json(['ok' => false, 'error' => 'Invalid API key.'], 401);
}
return $client;
}
private function json(array $data, int $status = 200): Response
{
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
return new Response(
is_string($json) ? $json : '{"ok":false,"error":"json_encode_failed"}',
$status,
['Content-Type' => 'application/json; charset=utf-8']
);
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value;
$value = trim($value, '-');
return $value !== '' ? $value : 'release';
}
private function nullableText(mixed $value): ?string
{
$text = trim((string)$value);
return $text !== '' ? $text : null;
}
private function artistNameById(PDO $db, int $artistId): string
{
if ($artistId <= 0) {
return '';
}
try {
$stmt = $db->prepare("SELECT name FROM ac_artists WHERE id = :id LIMIT 1");
$stmt->execute([':id' => $artistId]);
return trim((string)($stmt->fetchColumn() ?: ''));
} catch (Throwable $e) {
return '';
}
}
private function adminRedirect(string $status, string $message): Response
{
return new Response('', 302, [
'Location' => '/admin/api?status=' . rawurlencode($status) . '&message=' . rawurlencode($message),
]);
}
private function baseUrl(): string
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
$scheme = $https ? 'https' : 'http';
$host = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
return $host !== '' ? ($scheme . '://' . $host) : '';
}
private function guardAdmin(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
}

26
modules/api/module.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Modules\Api\ApiController;
require_once __DIR__ . '/ApiController.php';
return function (Router $router): void {
$controller = new ApiController();
$router->get('/admin/api', [$controller, 'adminIndex']);
$router->post('/admin/api/clients/create', [$controller, 'adminCreateClient']);
$router->post('/admin/api/clients/toggle', [$controller, 'adminToggleClient']);
$router->post('/admin/api/clients/delete', [$controller, 'adminDeleteClient']);
$router->get('/api/v1/auth/verify', [$controller, 'authVerify']);
$router->get('/api/v1/sales', [$controller, 'artistSales']);
$router->get('/api/v1/sales/since', [$controller, 'salesSince']);
$router->get('/api/v1/tracks', [$controller, 'artistTracks']);
$router->get('/api/v1/order-items', [$controller, 'orderItemData']);
$router->post('/api/v1/releases', [$controller, 'submitRelease']);
$router->post('/api/v1/tracks', [$controller, 'submitTracks']);
$router->post('/api/v1/releases/update', [$controller, 'updateRelease']);
$router->post('/api/v1/tracks/update', [$controller, 'updateTrack']);
};

View File

@@ -0,0 +1,189 @@
<?php
$pageTitle = 'API';
$clients = is_array($clients ?? null) ? $clients : [];
$createdKey = (string)($created_key ?? '');
$status = trim((string)($status ?? ''));
$message = trim((string)($message ?? ''));
$baseUrl = rtrim((string)($base_url ?? ''), '/');
$endpointRows = [
[
'title' => 'Verify key',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/auth/verify',
'note' => 'Quick auth check for AMS bootstrapping.',
],
[
'title' => 'Artist sales',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales?artist_id=123',
'note' => 'Detailed paid sales rows for one artist.',
],
[
'title' => 'Sales since sync',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/sales/since?artist_id=123&after_id=500',
'note' => 'Incremental sync using after_id or timestamp.',
],
[
'title' => 'Artist tracks',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/tracks?artist_id=123',
'note' => 'Track list tied to one artist account.',
],
[
'title' => 'Order item detail',
'method' => 'GET',
'path' => $baseUrl . '/api/v1/order-items?artist_id=123',
'note' => 'Granular order transparency for AMS reporting.',
],
[
'title' => 'Submit release / tracks',
'method' => 'POST',
'path' => $baseUrl . '/api/v1/releases + /api/v1/tracks',
'note' => 'AMS pushes approved releases and tracks into AudioCore.',
],
];
ob_start();
?>
<section class="admin-card">
<div class="badge">Integration</div>
<div style="margin-top:14px; max-width:860px;">
<h1 style="margin:0; font-size:30px;">API</h1>
<p style="margin:8px 0 0; color:var(--muted);">AMS integration endpoints, API keys, and sales sync access live here. Keep this page operational rather than decorative: create clients, issue keys, and hand exact endpoints to the AMS.</p>
</div>
<?php if ($message !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:14px; border-color:<?= $status === 'ok' ? 'rgba(34,242,165,.25)' : 'rgba(255,120,120,.22)' ?>; color:<?= $status === 'ok' ? '#9ff8d8' : '#ffb0b0' ?>;">
<?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
<?php if ($createdKey !== ''): ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">New API Key</div>
<div style="margin-top:8px; font-size:13px; color:#ffdfad;">Copy this now. It is only shown once.</div>
</div>
<button type="button" class="btn outline small" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?>')">Copy key</button>
</div>
<code style="display:block; margin-top:12px; padding:14px 16px; border-radius:14px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.03); font-family:'IBM Plex Mono', monospace; font-size:13px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endif; ?>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-top:18px;">
<article class="admin-card" style="padding:16px;">
<div class="label">Clients</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;"><?= count($clients) ?></div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Configured external systems</div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Auth</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">Bearer</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Also accepts <code>X-API-Key</code></div>
</article>
<article class="admin-card" style="padding:16px;">
<div class="label">Webhook</div>
<div style="margin-top:10px; font-size:28px; font-weight:700;">sale.paid</div>
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Outbound sale notifications for AMS sync</div>
</article>
</div>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Create API Client</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px; max-width:680px;">Use one client per AMS install or per integration target. That keeps revocation clean and usage attribution obvious.</div>
</div>
</div>
<form method="post" action="/admin/api/clients/create" style="display:grid; gap:14px; margin-top:16px;">
<div style="display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end;">
<label style="display:grid; gap:6px;">
<span class="label">Client Name</span>
<input class="input" type="text" name="name" placeholder="AudioCore AMS">
</label>
<label style="display:grid; gap:6px;">
<span class="label">Webhook URL (optional)</span>
<input class="input" type="url" name="webhook_url" placeholder="https://ams.example.com/webhooks/audiocore">
</label>
<button class="btn" type="submit">Create Key</button>
</div>
</form>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Endpoint Reference</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Keep this as an operator reference. The layout is stacked because this panel needs readability more than compression.</div>
</div>
<div style="color:var(--muted); font-size:12px;">Use <strong>Authorization: Bearer &lt;api-key&gt;</strong> or <strong>X-API-Key</strong>.</div>
</div>
<div style="display:grid; gap:10px; margin-top:16px;">
<?php foreach ($endpointRows as $row): ?>
<div class="admin-card" style="padding:14px 16px; display:grid; grid-template-columns:140px 96px minmax(0,1fr); gap:14px; align-items:start;">
<div>
<div style="font-weight:700;"><?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?></div>
<div style="margin-top:5px; color:var(--muted); font-size:12px; line-height:1.45;"><?= htmlspecialchars($row['note'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<div>
<span class="pill" style="padding:6px 10px; font-size:10px; letter-spacing:0.14em; border-color:rgba(115,255,198,0.25); color:#9ff8d8;"><?= htmlspecialchars($row['method'], ENT_QUOTES, 'UTF-8') ?></span>
</div>
<code style="display:block; padding:10px 12px; border-radius:10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.1); font-size:12px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($row['path'], ENT_QUOTES, 'UTF-8') ?></code>
</div>
<?php endforeach; ?>
</div>
</article>
<article class="admin-card" style="padding:18px; margin-top:16px;">
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
<div>
<div class="label">Active Clients</div>
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Disable a client to cut access immediately. Delete only when you do not need audit visibility anymore.</div>
</div>
</div>
<?php if (!$clients): ?>
<div style="margin-top:14px; color:var(--muted); font-size:13px;">No API clients created yet.</div>
<?php else: ?>
<div style="display:grid; gap:10px; margin-top:14px;">
<?php foreach ($clients as $client): ?>
<div class="admin-card" style="padding:16px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center;">
<div>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
<strong><?= htmlspecialchars((string)($client['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></strong>
<span class="pill" style="padding:5px 10px; font-size:10px; letter-spacing:0.14em; border-color:<?= (int)($client['is_active'] ?? 0) === 1 ? 'rgba(115,255,198,0.35)' : 'rgba(255,255,255,0.16)' ?>; color:<?= (int)($client['is_active'] ?? 0) === 1 ? '#9ff8d8' : '#a7adba' ?>;">
<?= (int)($client['is_active'] ?? 0) === 1 ? 'Active' : 'Disabled' ?>
</span>
</div>
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,auto)); gap:18px; margin-top:10px; color:var(--muted); font-size:12px;">
<div><span class="label" style="display:block; margin-bottom:4px;">Key prefix</span><?= htmlspecialchars((string)($client['api_key_prefix'] ?? ''), ENT_QUOTES, 'UTF-8') ?>...</div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last used</span><?= htmlspecialchars((string)($client['last_used_at'] ?? 'Never'), ENT_QUOTES, 'UTF-8') ?></div>
<div><span class="label" style="display:block; margin-bottom:4px;">Last IP</span><?= htmlspecialchars((string)($client['last_used_ip'] ?? '—'), ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php if (!empty($client['webhook_url'])): ?>
<div style="margin-top:8px; color:var(--muted); font-size:12px;">
Webhook: <?= htmlspecialchars((string)$client['webhook_url'], ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?>
</div>
<div style="display:flex; gap:8px; align-items:center; justify-content:flex-end;">
<form method="post" action="/admin/api/clients/toggle">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small"><?= (int)($client['is_active'] ?? 0) === 1 ? 'Disable' : 'Enable' ?></button>
</form>
<form method="post" action="/admin/api/clients/delete" onsubmit="return confirm('Delete this API client?');">
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
<button type="submit" class="btn outline small" style="border-color:rgba(255,120,120,.35); color:#ffb0b0;">Delete</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
</section>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../admin/views/layout.php';

View File

View File

@@ -1,61 +1,171 @@
<?php <?php
$pageTitle = $title ?? 'News'; $posts = is_array($posts ?? null) ? $posts : [];
$posts = $posts ?? []; $page = is_array($page ?? null) ? $page : null;
$page = $page ?? null; $pageContentHtml = trim((string)($page['content_html'] ?? ''));
$formatDate = static function (?string $value): string {
if ($value === null || trim($value) === '') {
return '';
}
try {
return (new DateTime($value))->format('d M Y');
} catch (Throwable $e) {
return $value;
}
};
$renderTags = static function (string $tags): array {
return array_values(array_filter(array_map('trim', explode(',', $tags))));
};
ob_start(); ob_start();
?> ?>
<section class="card"> <section class="card news-list-shell-minimal">
<div class="badge">News</div> <?php if ($pageContentHtml !== ''): ?>
<?php if ($page && !empty($page['content_html'])): ?> <div class="blog-page-content-box">
<div style="margin-top:12px; color:var(--muted); line-height:1.7;"> <?= $pageContentHtml ?>
<?= (string)$page['content_html'] ?>
</div> </div>
<?php else: ?>
<h1 style="margin-top:12px; font-size:30px;">Latest Updates</h1>
<p style="color:var(--muted); margin-top:8px;">News, updates, and announcements.</p>
<?php endif; ?> <?php endif; ?>
<div style="margin-top:18px; display:grid; gap:12px;"> <?php if ($posts): ?>
<?php if (!$posts): ?> <div class="news-list-grid-minimal">
<div style="color:var(--muted);">No posts yet.</div>
<?php else: ?>
<?php foreach ($posts as $post): ?> <?php foreach ($posts as $post): ?>
<article style="padding:14px 16px; border-radius:16px; border:1px solid rgba(255,255,255,0.12); background: rgba(0,0,0,0.25);"> <a class="news-card-minimal" href="/news/<?= htmlspecialchars((string)$post['slug'], ENT_QUOTES, 'UTF-8') ?>">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.28em; color:var(--muted);">Post</div>
<h2 style="margin:8px 0 6px; font-size:22px;"><?= htmlspecialchars((string)($post['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></h2>
<?php if (!empty($post['featured_image_url'])): ?> <?php if (!empty($post['featured_image_url'])): ?>
<img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:12px; margin:10px 0;"> <div class="news-card-media-minimal">
<?php endif; ?> <img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
<?php if (!empty($post['published_at'])): ?>
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;"><?= htmlspecialchars((string)$post['published_at'], ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;">
<?php if (!empty($post['author_name'])): ?>
<?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?>
<?php endif; ?>
<?php if (!empty($post['category'])): ?>
<?php if (!empty($post['author_name'])): ?> &middot; <?php endif; ?>
<?= htmlspecialchars((string)$post['category'], ENT_QUOTES, 'UTF-8') ?>
<?php endif; ?>
</div> </div>
<p style="color:var(--muted); line-height:1.6;"> <?php endif; ?>
<?= htmlspecialchars((string)($post['excerpt'] ?? ''), ENT_QUOTES, 'UTF-8') ?> <div class="news-card-copy-minimal">
</p> <div class="news-card-meta-minimal">
<?php if (!empty($post['tags'])): ?> <?php $published = $formatDate((string)($post['published_at'] ?? '')); ?>
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;"> <?php if ($published !== ''): ?><span><?= htmlspecialchars($published, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
<?php foreach (array_filter(array_map('trim', explode(',', (string)$post['tags'] ?? ''))) as $tag): ?> <?php if (!empty($post['author_name'])): ?><span><?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;"> </div>
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?> <h2><?= htmlspecialchars((string)$post['title'], ENT_QUOTES, 'UTF-8') ?></h2>
</span> <?php if (!empty($post['excerpt'])): ?>
<p><?= htmlspecialchars((string)$post['excerpt'], ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php $tags = $renderTags((string)($post['tags'] ?? '')); ?>
<?php if ($tags): ?>
<div class="news-card-tags-minimal">
<?php foreach (array_slice($tags, 0, 3) as $tag): ?>
<span><?= htmlspecialchars($tag, ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<a href="/news/<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" style="display:inline-flex; margin-top:10px; font-size:12px; text-transform:uppercase; letter-spacing:0.2em; color:#9ad4ff;">Read more</a>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div> </div>
</a>
<?php endforeach; ?>
</div>
<?php elseif ($pageContentHtml === ''): ?>
<div class="news-empty-minimal">No published posts yet.</div>
<?php endif; ?>
</section> </section>
<style>
.news-list-shell-minimal { display: grid; gap: 18px; }
.blog-page-content-box {
border-radius: 22px;
border: 1px solid rgba(255,255,255,.1);
background: rgba(12,15,21,.84);
padding: 28px;
overflow: hidden;
}
.blog-page-content-box img {
max-width: 100%;
height: auto;
display: block;
}
.news-list-grid-minimal {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.news-card-minimal {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 0;
min-height: 180px;
border-radius: 22px;
overflow: hidden;
border: 1px solid rgba(255,255,255,.1);
background: rgba(12,15,21,.84);
text-decoration: none;
color: inherit;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.news-card-minimal:hover {
transform: translateY(-2px);
border-color: rgba(34,242,165,.34);
box-shadow: 0 18px 32px rgba(0,0,0,.2);
}
.news-card-media-minimal { background: rgba(255,255,255,.03); }
.news-card-media-minimal img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.news-card-copy-minimal {
display: grid;
align-content: start;
gap: 12px;
padding: 20px;
}
.news-card-meta-minimal {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: rgba(255,255,255,.58);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: .16em;
text-transform: uppercase;
}
.news-card-copy-minimal h2 {
margin: 0;
font-size: 34px;
line-height: .96;
}
.news-card-copy-minimal p {
margin: 0;
color: #c4cede;
line-height: 1.65;
font-size: 15px;
}
.news-card-tags-minimal {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.news-card-tags-minimal span {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
color: #dce5f6;
font-size: 11px;
}
.news-empty-minimal {
border-radius: 18px;
border: 1px dashed rgba(255,255,255,.16);
padding: 24px;
color: #afb8ca;
background: rgba(255,255,255,.02);
}
@media (max-width: 980px) {
.news-list-grid-minimal { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.blog-page-content-box { padding: 20px; }
.news-card-minimal {
grid-template-columns: 1fr;
min-height: 0;
}
.news-card-media-minimal { min-height: 220px; }
.news-card-copy-minimal h2 { font-size: 28px; }
}
</style>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php'; require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -1,47 +1,257 @@
<?php <?php
$pageTitle = $title ?? 'Post'; $pageTitle = $title ?? 'Post';
$contentHtml = $content_html ?? ''; $contentHtml = (string)($content_html ?? '');
$publishedAt = $published_at ?? ''; $publishedAt = (string)($published_at ?? '');
$featuredImage = $featured_image_url ?? ''; $authorName = (string)($author_name ?? '');
$authorName = $author_name ?? ''; $category = (string)($category ?? '');
$category = $category ?? ''; $tags = (string)($tags ?? '');
$tags = $tags ?? ''; $tagList = array_filter(array_map('trim', explode(',', $tags)));
$publishedDisplay = '';
if ($publishedAt !== '') {
try {
$publishedDisplay = (new DateTime($publishedAt))->format('d M Y');
} catch (Throwable $e) {
$publishedDisplay = $publishedAt;
}
}
ob_start(); ob_start();
?> ?>
<section class="card"> <section class="card article-shell-fluid">
<div class="article-fluid-grid">
<aside class="article-fluid-meta">
<div class="badge">News</div> <div class="badge">News</div>
<h1 style="margin-top:12px; font-size:30px;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1> <h1 class="article-fluid-title"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
<?php if ($publishedAt !== '' || $authorName !== '' || $category !== ''): ?> <div class="article-fluid-meta-list">
<div style="font-size:12px; color:var(--muted); margin-top:6px;"> <?php if ($publishedDisplay !== ''): ?>
<?php if ($publishedAt !== ''): ?> <div class="article-fluid-meta-item">
<?= htmlspecialchars($publishedAt, ENT_QUOTES, 'UTF-8') ?> <span>Date</span>
<strong><?= htmlspecialchars($publishedDisplay, ENT_QUOTES, 'UTF-8') ?></strong>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($authorName !== ''): ?> <?php if ($authorName !== ''): ?>
<?php if ($publishedAt !== ''): ?> &middot; <?php endif; ?> <div class="article-fluid-meta-item">
<?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?> <span>Author</span>
<strong><?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?></strong>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($category !== ''): ?> <?php if ($category !== ''): ?>
<?php if ($publishedAt !== '' || $authorName !== ''): ?> &middot; <?php endif; ?> <div class="article-fluid-meta-item">
<?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?> <span>Category</span>
<?php endif; ?> <strong><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></strong>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($featuredImage !== ''): ?>
<img src="<?= htmlspecialchars($featuredImage, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:16px; margin-top:16px;">
<?php endif; ?>
<div style="margin-top:14px; color:var(--muted); line-height:1.8;">
<?= $contentHtml ?>
</div> </div>
<?php if ($tags !== ''): ?> <?php if ($tagList): ?>
<div style="margin-top:16px; display:flex; flex-wrap:wrap; gap:6px;"> <div class="article-fluid-tags">
<?php foreach (array_filter(array_map('trim', explode(',', (string)$tags))) as $tag): ?> <?php foreach ($tagList as $tag): ?>
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;"> <span><?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?></span>
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
</span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<a href="/news" class="article-fluid-back">Back to news</a>
</aside>
<div class="article-fluid-content-shell">
<div class="article-fluid-post-box">
<?= $contentHtml ?>
</div>
</div>
</div>
</section> </section>
<style>
.article-shell-fluid {
padding: 0;
}
.article-fluid-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 0;
align-items: start;
min-height: 100%;
}
.article-fluid-meta {
display: grid;
gap: 18px;
align-content: start;
min-height: 100%;
padding: 32px 24px;
}
.article-fluid-title {
margin: 0;
color: #f5f7ff;
font-size: clamp(34px, 3vw, 56px);
line-height: .94;
word-break: break-word;
}
.article-fluid-meta-list {
display: grid;
gap: 14px;
}
.article-fluid-meta-item span {
display: block;
margin-bottom: 6px;
color: rgba(255,255,255,.56);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
}
.article-fluid-meta-item strong {
color: #e8eefc;
font-size: 15px;
font-weight: 600;
}
.article-fluid-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-fluid-tags span {
display: inline-flex;
align-items: center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
color: #dce5f6;
font-size: 11px;
}
.article-fluid-back {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.04);
color: #eef4ff;
text-decoration: none;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: .16em;
text-transform: uppercase;
}
.article-fluid-back:hover {
border-color: rgba(34,242,165,.36);
color: #9ff8d8;
}
.article-fluid-content-shell {
min-width: 0;
min-height: 100%;
margin: 0;
padding: 40px 44px;
border-radius: 0 28px 28px 0;
background: #f4f1ec;
box-shadow: none;
}
.article-fluid-post-box {
min-width: 0;
padding: 0;
color: #26272d;
}
.article-fluid-post-box,
.article-fluid-post-box p,
.article-fluid-post-box li,
.article-fluid-post-box blockquote,
.article-fluid-post-box figcaption,
.article-fluid-post-box td,
.article-fluid-post-box th {
color: #3a3d45;
}
.article-fluid-post-box h1,
.article-fluid-post-box h2,
.article-fluid-post-box h3,
.article-fluid-post-box h4,
.article-fluid-post-box h5,
.article-fluid-post-box h6,
.article-fluid-post-box strong {
color: #202228;
}
.article-fluid-post-box,
.article-fluid-post-box > *,
.article-fluid-post-box > * > *,
.article-fluid-post-box > * > * > * {
max-width: none !important;
}
.article-fluid-post-box > * {
width: 100% !important;
margin: 0 !important;
}
.article-fluid-post-box > div,
.article-fluid-post-box > section,
.article-fluid-post-box > article,
.article-fluid-post-box > main,
.article-fluid-post-box > figure {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
}
.article-fluid-post-box * {
margin-left: 0 !important;
margin-right: 0 !important;
}
.article-fluid-post-box [style*="max-width"],
.article-fluid-post-box [style*="width"],
.article-fluid-post-box [style*="margin: auto"],
.article-fluid-post-box [style*="margin-left:auto"],
.article-fluid-post-box [style*="margin-right:auto"] {
max-width: none !important;
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.article-fluid-post-box > div,
.article-fluid-post-box > section,
.article-fluid-post-box > article,
.article-fluid-post-box > main,
.article-fluid-post-box > figure,
.article-fluid-post-box > div > div:first-child {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
}
.article-fluid-post-box img {
max-width: 100%;
height: auto;
display: block;
}
.article-fluid-post-box iframe,
.article-fluid-post-box video,
.article-fluid-post-box table,
.article-fluid-post-box pre {
max-width: 100%;
}
@media (max-width: 980px) {
.article-fluid-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.article-fluid-meta {
padding: 24px 24px 8px;
}
.article-fluid-content-shell {
margin: 0;
border-radius: 0 0 28px 28px;
}
}
@media (max-width: 720px) {
.article-shell-fluid {
padding: 0;
}
.article-fluid-content-shell {
padding: 28px 24px;
}
.article-fluid-post-box {
padding: 0;
}
.article-fluid-title {
font-size: 42px;
}
}
</style>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
require __DIR__ . '/../../../views/site/layout.php'; require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -7,6 +7,7 @@ use Core\Http\Response;
use Core\Services\Auth; use Core\Services\Auth;
use Core\Services\Database; use Core\Services\Database;
use Core\Services\Mailer; use Core\Services\Mailer;
use Core\Services\RateLimiter;
use Core\Services\Settings; use Core\Services\Settings;
use Core\Views\View; use Core\Views\View;
use PDO; use PDO;
@@ -28,6 +29,13 @@ class NewsletterController
if ($email === '') { if ($email === '') {
return new Response('Missing email', 400); return new Response('Missing email', 400);
} }
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return new Response('Invalid email', 400);
}
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
if (RateLimiter::tooMany('newsletter_subscribe', $limitKey, 8, 600)) {
return new Response('Too many requests. Please wait 10 minutes and try again.', 429);
}
$db = Database::get(); $db = Database::get();
if ($db instanceof PDO) { if ($db instanceof PDO) {
@@ -61,6 +69,16 @@ class NewsletterController
{ {
$email = trim((string)($_POST['email'] ?? '')); $email = trim((string)($_POST['email'] ?? ''));
$status = 'Email is required.'; $status = 'Email is required.';
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
if (RateLimiter::tooMany('newsletter_unsubscribe', $limitKey, 8, 600)) {
return new Response($this->view->render('site/unsubscribe.php', [
'title' => 'Unsubscribe',
'email' => $email,
'status' => 'Too many unsubscribe attempts. Please wait 10 minutes.',
]), 429);
}
}
$db = Database::get(); $db = Database::get();
if ($db instanceof PDO && $email !== '') { if ($db instanceof PDO && $email !== '') {
@@ -76,6 +94,24 @@ class NewsletterController
])); ]));
} }
private function clientIp(): string
{
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
$value = trim((string)($_SERVER[$key] ?? ''));
if ($value === '') {
continue;
}
if ($key === 'HTTP_X_FORWARDED_FOR') {
$parts = array_map('trim', explode(',', $value));
$value = (string)($parts[0] ?? '');
}
if ($value !== '') {
return substr($value, 0, 64);
}
}
return '0.0.0.0';
}
public function adminIndex(): Response public function adminIndex(): Response
{ {
if ($guard = $this->guard(['admin', 'manager'])) { if ($guard = $this->guard(['admin', 'manager'])) {

View File

@@ -39,12 +39,22 @@ class PagesController
return $this->notFound(); return $this->notFound();
} }
return new Response($this->view->render('site/show.php', [ $rendered = Shortcodes::render((string)$page['content_html'], [
'title' => (string)$page['title'],
'content_html' => Shortcodes::render((string)$page['content_html'], [
'page_slug' => $slug, 'page_slug' => $slug,
'page_title' => (string)$page['title'], 'page_title' => (string)$page['title'],
]), ]);
// WYSIWYG editors often wrap shortcode blocks in <p>, which breaks grid placement.
$rendered = preg_replace(
'~<p>\s*(<(?:section|div|form|a)[^>]*class="[^"]*ac-shortcode[^"]*"[^>]*>.*?</(?:section|div|form|a)>)\s*</p>~is',
'$1',
$rendered
) ?? $rendered;
$rendered = preg_replace('~(<(?:section|div|form|a)[^>]*class="[^"]*ac-shortcode[^"]*"[^>]*>)\s*<br\s*/?>~i', '$1', $rendered) ?? $rendered;
$rendered = preg_replace('~<br\s*/?>\s*(</(?:section|div|form|a)>)~i', '$1', $rendered) ?? $rendered;
return new Response($this->view->render('site/show.php', [
'title' => (string)$page['title'],
'content_html' => $rendered,
])); ]));
} }

View File

@@ -2,10 +2,57 @@
declare(strict_types=1); declare(strict_types=1);
use Core\Http\Router; use Core\Http\Router;
use Core\Services\Shortcodes;
use Modules\Pages\PagesController; use Modules\Pages\PagesController;
require_once __DIR__ . '/PagesController.php'; require_once __DIR__ . '/PagesController.php';
Shortcodes::register('hero', static function (array $attrs = []): string {
$eyebrow = trim((string)($attrs['eyebrow'] ?? 'Catalog Focus'));
$title = trim((string)($attrs['title'] ?? 'Latest Drops'));
$subtitle = trim((string)($attrs['subtitle'] ?? 'Discover fresh releases, artists, and top-selling tracks.'));
$ctaText = trim((string)($attrs['cta_text'] ?? 'Browse Releases'));
$ctaUrl = trim((string)($attrs['cta_url'] ?? '/releases'));
$secondaryText = trim((string)($attrs['secondary_text'] ?? 'Meet the Roster'));
$secondaryUrl = trim((string)($attrs['secondary_url'] ?? '/artists'));
$html = '<section class="ac-shortcode-hero">';
if ($eyebrow !== '') {
$html .= '<div class="ac-shortcode-hero-eyebrow">' . htmlspecialchars($eyebrow, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '<h2 class="ac-shortcode-hero-title">' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</h2>';
if ($subtitle !== '') {
$html .= '<p class="ac-shortcode-hero-subtitle">' . htmlspecialchars($subtitle, ENT_QUOTES, 'UTF-8') . '</p>';
}
$html .= '<div class="ac-shortcode-hero-actions">'
. '<a class="ac-shortcode-hero-btn primary" href="' . htmlspecialchars($ctaUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($ctaText, ENT_QUOTES, 'UTF-8') . '</a>';
if ($secondaryText !== '' && $secondaryUrl !== '') {
$html .= '<a class="ac-shortcode-hero-btn" href="' . htmlspecialchars($secondaryUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($secondaryText, ENT_QUOTES, 'UTF-8') . '</a>';
}
$html .= '</div></section>';
return $html;
});
Shortcodes::register('home-catalog', static function (array $attrs = []): string {
$releaseLimit = max(1, min(12, (int)($attrs['release_limit'] ?? 8)));
$artistLimit = max(1, min(10, (int)($attrs['artist_limit'] ?? 6)));
$chartLimit = max(1, min(20, (int)($attrs['chart_limit'] ?? 10)));
$hero = Shortcodes::render('[hero eyebrow="Catalog Focus" title="Latest Drops" subtitle="Fresh releases, artists, and best sellers." cta_text="Browse Releases" cta_url="/releases" secondary_text="Meet Artists" secondary_url="/artists"]');
$releases = Shortcodes::render('[latest-releases limit="' . $releaseLimit . '"]');
$artists = Shortcodes::render('[new-artists limit="' . $artistLimit . '"]');
$chart = Shortcodes::render('[sale-chart type="tracks" window="latest" limit="' . $chartLimit . '"]');
$newsletter = Shortcodes::render('[newsletter-signup title="Join the list" button="Subscribe"]');
return '<section class="ac-home-catalog">'
. $hero
. '<div class="ac-home-columns">'
. '<div class="ac-home-main">' . $releases . $chart . '</div>'
. '<aside class="ac-home-side">' . $artists . $newsletter . '</aside>'
. '</div>'
. '</section>';
});
return function (Router $router): void { return function (Router $router): void {
$controller = new PagesController(); $controller = new PagesController();
$router->get('/page', [$controller, 'show']); $router->get('/page', [$controller, 'show']);

View File

@@ -4,7 +4,7 @@ $contentHtml = $content_html ?? '';
ob_start(); ob_start();
?> ?>
<section class="card"> <section class="card">
<div style="margin-top:14px; color:var(--muted); line-height:1.8;"> <div class="page-content" style="margin-top:14px; color:var(--muted);">
<?= $contentHtml ?> <?= $contentHtml ?>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,536 @@
<?php
declare(strict_types=1);
namespace Plugins\AdvancedReporting;
use Core\Http\Response;
use Core\Services\ApiLayer;
use Core\Services\Auth;
use Core\Services\Database;
use Core\Services\Settings;
use Core\Views\View;
use PDO;
use Throwable;
class ReportsController
{
private View $view;
private array $releaseTrackCache = [];
private array $trackDownloadCountCache = [];
public function __construct()
{
$this->view = new View(__DIR__ . '/views');
}
public function adminIndex(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return new Response($this->view->render('admin/index.php', [
'title' => 'Sales Reports',
'tab' => 'overview',
'filters' => $this->filters(),
'overview' => [],
'artist_rows' => [],
'track_rows' => [],
'artist_options' => [],
'tables_ready' => false,
'currency' => Settings::get('store_currency', 'GBP'),
]));
}
ApiLayer::ensureSchema($db);
$filters = $this->filters();
$tab = $filters['tab'];
return new Response($this->view->render('admin/index.php', [
'title' => 'Sales Reports',
'tab' => $tab,
'filters' => $filters,
'overview' => $this->overviewPayload($db, $filters),
'artist_rows' => $tab === 'artists' ? $this->artistRows($db, $filters) : [],
'track_rows' => $tab === 'tracks' ? $this->trackRows($db, $filters) : [],
'artist_options' => $this->artistOptions($db),
'tables_ready' => $this->tablesReady($db),
'currency' => strtoupper(trim((string)Settings::get('store_currency', 'GBP'))),
]));
}
public function adminExport(): Response
{
if ($guard = $this->guard()) {
return $guard;
}
$db = Database::get();
if (!($db instanceof PDO)) {
return new Response('Database unavailable', 500);
}
ApiLayer::ensureSchema($db);
$filters = $this->filters();
$type = strtolower(trim((string)($_GET['type'] ?? 'artists')));
if (!in_array($type, ['artists', 'tracks'], true)) {
$type = 'artists';
}
$rows = $type === 'tracks' ? $this->trackRows($db, $filters) : $this->artistRows($db, $filters);
$stream = fopen('php://temp', 'w+');
if ($stream === false) {
return new Response('Unable to create export', 500);
}
if ($type === 'tracks') {
fputcsv($stream, ['Artist', 'Track', 'Release', 'Catalog', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Downloads']);
foreach ($rows as $row) {
fputcsv($stream, [
(string)($row['artist_name'] ?? ''),
(string)($row['track_display'] ?? ''),
(string)($row['release_title'] ?? ''),
(string)($row['catalog_no'] ?? ''),
(int)($row['units_sold'] ?? 0),
(int)($row['units_refunded'] ?? 0),
number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''),
number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''),
(int)($row['download_count'] ?? 0),
]);
}
} else {
fputcsv($stream, ['Artist', 'Paid Orders', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Releases', 'Tracks']);
foreach ($rows as $row) {
fputcsv($stream, [
(string)($row['artist_name'] ?? ''),
(int)($row['paid_orders'] ?? 0),
(int)($row['units_sold'] ?? 0),
(int)($row['units_refunded'] ?? 0),
number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''),
number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''),
number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''),
(int)($row['release_count'] ?? 0),
(int)($row['track_count'] ?? 0),
]);
}
}
rewind($stream);
$csv = stream_get_contents($stream);
fclose($stream);
return new Response((string)$csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="sales-report-' . $type . '-' . gmdate('Ymd-His') . '.csv"',
]);
}
private function guard(): ?Response
{
if (!Auth::check()) {
return new Response('', 302, ['Location' => '/admin/login']);
}
if (!Auth::hasRole(['admin', 'manager'])) {
return new Response('', 302, ['Location' => '/admin']);
}
return null;
}
private function filters(): array
{
$tab = strtolower(trim((string)($_GET['tab'] ?? 'overview')));
if (!in_array($tab, ['overview', 'artists', 'tracks'], true)) {
$tab = 'overview';
}
return [
'tab' => $tab,
'from' => trim((string)($_GET['from'] ?? '')),
'to' => trim((string)($_GET['to'] ?? '')),
'q' => trim((string)($_GET['q'] ?? '')),
'artist_id' => max(0, (int)($_GET['artist_id'] ?? 0)),
];
}
private function tablesReady(PDO $db): bool
{
try {
$probe = $db->query("SHOW TABLES LIKE 'ac_store_order_item_allocations'");
return (bool)($probe && $probe->fetch(PDO::FETCH_NUM));
} catch (Throwable $e) {
return false;
}
}
private function overviewPayload(PDO $db, array $filters): array
{
$stats = [
'gross_revenue' => 0.0,
'refunded_revenue' => 0.0,
'net_revenue' => 0.0,
'payment_fees' => 0.0,
'net_after_fees' => 0.0,
'paid_orders' => 0,
'refunded_orders' => 0,
'units_sold' => 0,
'units_refunded' => 0,
'top_artists' => [],
'top_tracks' => [],
];
try {
[$whereSql, $params] = $this->dateWhere($filters, 'o');
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END), 0) AS gross_revenue,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN o.total ELSE 0 END), 0) AS refunded_revenue,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END), 0) AS payment_fees,
COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN o.id END) AS paid_orders,
COUNT(DISTINCT CASE WHEN o.status = 'refunded' THEN o.id END) AS refunded_orders
FROM ac_store_orders o
{$whereSql}
");
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['gross_revenue'] = (float)($row['gross_revenue'] ?? 0);
$stats['refunded_revenue'] = (float)($row['refunded_revenue'] ?? 0);
$stats['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$stats['net_revenue'] = $stats['gross_revenue'] - $stats['refunded_revenue'];
$stats['net_after_fees'] = $stats['net_revenue'] - $stats['payment_fees'];
$stats['paid_orders'] = (int)($row['paid_orders'] ?? 0);
$stats['refunded_orders'] = (int)($row['refunded_orders'] ?? 0);
} catch (Throwable $e) {
}
try {
[$whereSql, $params] = $this->dateWhere($filters, 'o');
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN a.qty ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN a.qty ELSE 0 END), 0) AS units_refunded
FROM ac_store_order_item_allocations a
JOIN ac_store_orders o ON o.id = a.order_id
{$whereSql}
");
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['units_sold'] = (int)($row['units_sold'] ?? 0);
$stats['units_refunded'] = (int)($row['units_refunded'] ?? 0);
} catch (Throwable $e) {
}
$topFilters = $filters;
$topFilters['limit'] = 5;
$stats['top_artists'] = $this->artistRows($db, $topFilters);
$stats['top_tracks'] = $this->trackRows($db, $topFilters);
return $stats;
}
private function artistRows(PDO $db, array $filters): array
{
[$whereSql, $params] = $this->dateWhere($filters, 'o');
if (!empty($filters['artist_id'])) {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'alloc.artist_id = :artist_id';
$params[':artist_id'] = (int)$filters['artist_id'];
}
$q = trim((string)($filters['q'] ?? ''));
if ($q !== '') {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . " (ar.name LIKE :q OR r.artist_name LIKE :q OR alloc.title_snapshot LIKE :q)";
$params[':q'] = '%' . $q . '%';
}
$limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : '';
try {
$stmt = $db->prepare("
SELECT
COALESCE(alloc.artist_id, 0) AS artist_id,
COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name,
COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN alloc.order_id END) AS paid_orders,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.qty ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.qty ELSE 0 END), 0) AS units_refunded,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) AS gross_revenue,
COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0) AS refunded_revenue,
COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0) AS payment_fees,
COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.release_id IS NOT NULL THEN alloc.release_id END) AS release_count,
COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.track_id IS NOT NULL THEN alloc.track_id END) AS track_count
FROM ac_store_order_item_allocations alloc
JOIN ac_store_orders o ON o.id = alloc.order_id
LEFT JOIN ac_artists ar ON ar.id = alloc.artist_id
LEFT JOIN ac_releases r ON r.id = alloc.release_id
{$whereSql}
GROUP BY COALESCE(alloc.artist_id, 0)
ORDER BY
((COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0)) - COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0)) DESC,
COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) DESC,
artist_name ASC
{$limitSql}
");
$stmt->execute($params);
$rows = array_values(array_filter($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], static function (array $row): bool {
return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0;
}));
foreach ($rows as &$row) {
$row['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0);
$row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees'];
}
unset($row);
return $rows;
} catch (Throwable $e) {
return [];
}
}
private function trackRows(PDO $db, array $filters): array
{
[$whereSql, $params] = $this->dateWhere($filters, 'o');
if (!empty($filters['artist_id'])) {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'COALESCE(alloc.artist_id, r.artist_id) = :artist_id';
$params[':artist_id'] = (int)$filters['artist_id'];
}
$q = trim((string)($filters['q'] ?? ''));
if ($q !== '') {
$whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . "(t.title LIKE :q OR t.mix_name LIKE :q OR ar.name LIKE :q OR r.title LIKE :q OR r.catalog_no LIKE :q)";
$params[':q'] = '%' . $q . '%';
}
$limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : '';
try {
$stmt = $db->prepare("
SELECT
alloc.id,
alloc.order_id,
alloc.track_id,
alloc.release_id,
alloc.artist_id,
alloc.qty,
alloc.gross_amount,
o.status,
COALESCE(o.payment_fee, 0) AS payment_fee,
COALESCE(o.payment_gross, 0) AS payment_gross,
COALESCE(o.total, 0) AS order_total,
COALESCE(NULLIF(MAX(t.title), ''), MAX(alloc.title_snapshot), 'Track') AS track_title,
COALESCE(NULLIF(MAX(t.mix_name), ''), '') AS mix_name,
COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name,
COALESCE(NULLIF(MAX(r.title), ''), 'Release') AS release_title,
COALESCE(NULLIF(MAX(r.catalog_no), ''), '') AS catalog_no
FROM ac_store_order_item_allocations alloc
JOIN ac_store_orders o ON o.id = alloc.order_id
LEFT JOIN ac_release_tracks t ON t.id = alloc.track_id
LEFT JOIN ac_releases r ON r.id = COALESCE(alloc.release_id, t.release_id)
LEFT JOIN ac_artists ar ON ar.id = COALESCE(alloc.artist_id, r.artist_id)
{$whereSql}
GROUP BY alloc.id
ORDER BY alloc.id DESC
");
$stmt->execute($params);
$allocations = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$rows = [];
foreach ($allocations as $alloc) {
$releaseId = (int)($alloc['release_id'] ?? 0);
$trackId = (int)($alloc['track_id'] ?? 0);
if ($trackId > 0) {
$targets = [[
'track_id' => $trackId,
'artist_name' => (string)($alloc['artist_name'] ?? 'Unknown Artist'),
'release_title' => (string)($alloc['release_title'] ?? 'Release'),
'catalog_no' => (string)($alloc['catalog_no'] ?? ''),
'track_title' => (string)($alloc['track_title'] ?? 'Track'),
'mix_name' => (string)($alloc['mix_name'] ?? ''),
'weight' => 1.0,
]];
} elseif ($releaseId > 0) {
$targets = $this->releaseTracks($db, $releaseId, (string)($alloc['artist_name'] ?? 'Unknown Artist'), (string)($alloc['release_title'] ?? 'Release'), (string)($alloc['catalog_no'] ?? ''));
if (!$targets) {
continue;
}
} else {
continue;
}
$totalWeight = 0.0;
foreach ($targets as $target) {
$totalWeight += max(0.0, (float)($target['weight'] ?? 0));
}
if ($totalWeight <= 0) {
$totalWeight = (float)count($targets);
foreach ($targets as &$target) {
$target['weight'] = 1.0;
}
unset($target);
}
foreach ($targets as $target) {
$key = (int)($target['track_id'] ?? 0);
if ($key <= 0) {
continue;
}
if (!isset($rows[$key])) {
$trackTitle = trim((string)($target['track_title'] ?? 'Track'));
$mixName = trim((string)($target['mix_name'] ?? ''));
$rows[$key] = [
'track_id' => $key,
'artist_name' => (string)($target['artist_name'] ?? 'Unknown Artist'),
'release_title' => (string)($target['release_title'] ?? 'Release'),
'catalog_no' => (string)($target['catalog_no'] ?? ''),
'track_title' => $trackTitle,
'mix_name' => $mixName,
'track_display' => $mixName !== '' ? $trackTitle . ' (' . $mixName . ')' : $trackTitle,
'units_sold' => 0,
'units_refunded' => 0,
'gross_revenue' => 0.0,
'refunded_revenue' => 0.0,
'download_count' => $this->trackDownloadCount($db, $key),
'payment_fees' => 0.0,
'net_after_fees' => 0.0,
];
}
$share = max(0.0, (float)($target['weight'] ?? 0)) / $totalWeight;
$amount = (float)($alloc['gross_amount'] ?? 0) * $share;
$qty = (int)($alloc['qty'] ?? 0);
$feeBase = (float)($alloc['payment_gross'] ?? 0);
if ($feeBase <= 0) {
$feeBase = (float)($alloc['order_total'] ?? 0);
}
$feeShare = ((string)($alloc['status'] ?? '') === 'paid' && $feeBase > 0)
? ((float)($alloc['payment_fee'] ?? 0) * (((float)($alloc['gross_amount'] ?? 0)) / $feeBase) * $share)
: 0.0;
if ((string)($alloc['status'] ?? '') === 'paid') {
$rows[$key]['units_sold'] += $qty;
$rows[$key]['gross_revenue'] += $amount;
$rows[$key]['payment_fees'] += $feeShare;
} elseif ((string)($alloc['status'] ?? '') === 'refunded') {
$rows[$key]['units_refunded'] += $qty;
$rows[$key]['refunded_revenue'] += $amount;
}
}
}
$rows = array_values(array_filter($rows, static function (array $row): bool {
return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0;
}));
foreach ($rows as &$row) {
$row['payment_fees'] = (float)($row['payment_fees'] ?? 0);
$row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0);
$row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees'];
}
unset($row);
usort($rows, static function (array $a, array $b): int {
$cmp = ((float)($b['net_revenue'] ?? 0)) <=> ((float)($a['net_revenue'] ?? 0));
if ($cmp !== 0) {
return $cmp;
}
$cmp = ((float)($b['net_after_fees'] ?? 0)) <=> ((float)($a['net_after_fees'] ?? 0));
if ($cmp !== 0) {
return $cmp;
}
return strcasecmp((string)($a['track_display'] ?? ''), (string)($b['track_display'] ?? ''));
});
if (!empty($filters['limit'])) {
$rows = array_slice($rows, 0, max(1, (int)$filters['limit']));
}
return $rows;
} catch (Throwable $e) {
return [];
}
}
private function releaseTracks(PDO $db, int $releaseId, string $artistName, string $releaseTitle, string $catalogNo): array
{
if ($releaseId <= 0) {
return [];
}
if (isset($this->releaseTrackCache[$releaseId])) {
return $this->releaseTrackCache[$releaseId];
}
try {
$stmt = $db->prepare("
SELECT
t.id,
t.title,
COALESCE(t.mix_name, '') AS mix_name,
COALESCE(sp.track_price, 0) AS track_price
FROM ac_release_tracks t
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1
WHERE t.release_id = :release_id
ORDER BY t.track_no ASC, t.id ASC
");
$stmt->execute([':release_id' => $releaseId]);
$tracks = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
$targets = [];
foreach ($tracks as $track) {
$targets[] = [
'track_id' => (int)($track['id'] ?? 0),
'artist_name' => $artistName,
'release_title' => $releaseTitle,
'catalog_no' => $catalogNo,
'track_title' => (string)($track['title'] ?? 'Track'),
'mix_name' => (string)($track['mix_name'] ?? ''),
'weight' => max(0.0, (float)($track['track_price'] ?? 0)),
];
}
$this->releaseTrackCache[$releaseId] = $targets;
return $targets;
} catch (Throwable $e) {
return [];
}
}
private function trackDownloadCount(PDO $db, int $trackId): int
{
if ($trackId <= 0) {
return 0;
}
if (isset($this->trackDownloadCountCache[$trackId])) {
return $this->trackDownloadCountCache[$trackId];
}
try {
$stmt = $db->prepare("
SELECT COUNT(e.id) AS download_count
FROM ac_store_files f
LEFT JOIN ac_store_download_events e ON e.file_id = f.id
WHERE f.scope_type = 'track' AND f.scope_id = :track_id
");
$stmt->execute([':track_id' => $trackId]);
$count = (int)(($stmt->fetch(PDO::FETCH_ASSOC) ?: [])['download_count'] ?? 0);
$this->trackDownloadCountCache[$trackId] = $count;
return $count;
} catch (Throwable $e) {
return 0;
}
}
private function artistOptions(PDO $db): array
{
try {
$stmt = $db->query("SELECT id, name FROM ac_artists WHERE name IS NOT NULL AND name <> '' ORDER BY name ASC");
return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
} catch (Throwable $e) {
return [];
}
}
private function dateWhere(array $filters, string $alias): array
{
$where = [];
$params = [];
$from = trim((string)($filters['from'] ?? ''));
$to = trim((string)($filters['to'] ?? ''));
if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
$where[] = $alias . '.created_at >= :from_date';
$params[':from_date'] = $from . ' 00:00:00';
}
if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
$where[] = $alias . '.created_at <= :to_date';
$params[':to_date'] = $to . ' 23:59:59';
}
return [$where ? ' WHERE ' . implode(' AND ', $where) : '', $params];
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "Advanced Reporting",
"version": "0.1.0",
"description": "Expanded sales reporting for the store layer.",
"author": "AudioCore",
"admin_nav": {
"label": "Sales Reports",
"url": "/admin/store/reports",
"roles": ["admin", "manager"],
"icon": "fa-solid fa-chart-line"
},
"entry": "plugin.php",
"default_enabled": false
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Core\Http\Router;
use Plugins\AdvancedReporting\ReportsController;
require_once __DIR__ . '/ReportsController.php';
return function (Router $router): void {
$controller = new ReportsController();
$router->get('/admin/store/reports', [$controller, 'adminIndex']);
$router->get('/admin/store/reports/export', [$controller, 'adminExport']);
};

View File

@@ -0,0 +1,387 @@
<?php
$pageTitle = $title ?? 'Sales Reports';
$tab = (string)($tab ?? 'overview');
$filters = is_array($filters ?? null) ? $filters : [];
$overview = is_array($overview ?? null) ? $overview : [];
$artistRows = is_array($artist_rows ?? null) ? $artist_rows : [];
$trackRows = is_array($track_rows ?? null) ? $track_rows : [];
$artistOptions = is_array($artist_options ?? null) ? $artist_options : [];
$tablesReady = (bool)($tables_ready ?? false);
$currency = (string)($currency ?? 'GBP');
$tabLabels = [
'overview' => 'Overview',
'artists' => 'Artists',
'tracks' => 'Tracks',
];
$activeTabLabel = $tabLabels[$tab] ?? 'Overview';
$overviewGross = (float)($overview['gross_revenue'] ?? 0);
$overviewRefunded = (float)($overview['refunded_revenue'] ?? 0);
$overviewNet = (float)($overview['net_revenue'] ?? 0);
$overviewNetAfterFees = (float)($overview['net_after_fees'] ?? 0);
$selectedFrom = (string)($filters['from'] ?? '');
$selectedTo = (string)($filters['to'] ?? '');
$selectedArtistId = (int)($filters['artist_id'] ?? 0);
$selectedQuery = (string)($filters['q'] ?? '');
$today = new DateTimeImmutable('today');
$monthStart = $today->modify('first day of this month');
$monthEnd = $today->modify('last day of this month');
$lastMonthStart = $monthStart->modify('-1 month');
$lastMonthEnd = $monthStart->modify('-1 day');
$quarter = (int)floor((((int)$today->format('n')) - 1) / 3);
$quarterStartMonth = ($quarter * 3) + 1;
$quarterStart = new DateTimeImmutable($today->format('Y') . '-' . str_pad((string)$quarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01');
$lastQuarterEnd = $quarterStart->modify('-1 day');
$lastQuarterStartMonth = (int)$lastQuarterEnd->format('n') - (((int)$lastQuarterEnd->format('n') - 1) % 3);
$lastQuarterStart = new DateTimeImmutable($lastQuarterEnd->format('Y') . '-' . str_pad((string)$lastQuarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01');
$quickRanges = [
'7d' => ['label' => 'Last 7 days', 'from' => $today->modify('-6 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'30d' => ['label' => 'Last 30 days', 'from' => $today->modify('-29 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'month' => ['label' => 'This month', 'from' => $monthStart->format('Y-m-d'), 'to' => $monthEnd->format('Y-m-d')],
'last-month' => ['label' => 'Last month', 'from' => $lastMonthStart->format('Y-m-d'), 'to' => $lastMonthEnd->format('Y-m-d')],
'quarter' => ['label' => 'This quarter', 'from' => $quarterStart->format('Y-m-d'), 'to' => $today->format('Y-m-d')],
'last-quarter' => ['label' => 'Last quarter', 'from' => $lastQuarterStart->format('Y-m-d'), 'to' => $lastQuarterEnd->format('Y-m-d')],
'ytd' => ['label' => 'Year to date', 'from' => $today->format('Y') . '-01-01', 'to' => $today->format('Y-m-d')],
];
$activeRangeLabel = 'Custom range';
foreach ($quickRanges as $range) {
if ($selectedFrom === $range['from'] && $selectedTo === $range['to']) {
$activeRangeLabel = $range['label'];
break;
}
}
$artistExportUrl = '/admin/store/reports/export?type=artists'
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
$trackExportUrl = '/admin/store/reports/export?type=tracks'
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
$exportUrl = '/admin/store/reports/export?type=' . ($tab === 'tracks' ? 'tracks' : 'artists')
. '&from=' . rawurlencode($selectedFrom)
. '&to=' . rawurlencode($selectedTo)
. '&artist_id=' . $selectedArtistId
. '&q=' . rawurlencode($selectedQuery);
ob_start();
?>
<section class="admin-card reports-page reports-shell">
<div class="reports-hero">
<div class="reports-hero-copy">
<div class="badge">Store Analytics</div>
<h1>Sales Reports</h1>
<p>Revenue, allocations, and performance reporting across orders, artists, releases, and tracks.</p>
</div>
<div class="reports-hero-actions">
<div class="reports-status-card">
<span class="reports-status-label">Current View</span>
<strong><?= htmlspecialchars($activeTabLabel, ENT_QUOTES, 'UTF-8') ?></strong>
<small><?= $tablesReady ? 'Live allocation data ready' : 'Waiting for reporting tables' ?></small>
</div>
<a href="/admin/store" class="btn outline">Back to Store</a>
</div>
</div>
<?php if (!$tablesReady): ?>
<div class="reports-empty-state">
<strong>Reporting tables are not ready.</strong>
<span>Initialize the Store plugin first so allocation and reporting tables exist.</span>
</div>
<?php else: ?>
<div class="reports-toolbar">
<div class="reports-tabset">
<a href="/admin/store/reports?tab=overview" class="reports-tab <?= $tab === 'overview' ? 'is-active' : '' ?>">Overview</a>
<a href="/admin/store/reports?tab=artists" class="reports-tab <?= $tab === 'artists' ? 'is-active' : '' ?>">Artists</a>
<a href="/admin/store/reports?tab=tracks" class="reports-tab <?= $tab === 'tracks' ? 'is-active' : '' ?>">Tracks</a>
</div>
<div class="reports-export-actions">
<a href="<?= htmlspecialchars($artistExportUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">Export Artists</a>
<a href="<?= htmlspecialchars($trackExportUrl, ENT_QUOTES, 'UTF-8') ?>" class="btn outline small">Export Tracks</a>
</div>
</div>
<div class="reports-range-bar">
<div class="reports-range-copy">
<span class="reports-range-label">Reporting Period</span>
<strong><?= htmlspecialchars($activeRangeLabel, ENT_QUOTES, 'UTF-8') ?></strong>
<small><?= $selectedFrom !== '' || $selectedTo !== '' ? htmlspecialchars(trim(($selectedFrom !== '' ? $selectedFrom : 'Beginning') . ' → ' . ($selectedTo !== '' ? $selectedTo : 'Today')), ENT_QUOTES, 'UTF-8') : 'No explicit date filter applied' ?></small>
</div>
<div class="reports-range-chips">
<?php foreach ($quickRanges as $key => $range): ?>
<?php $rangeUrl = '/admin/store/reports?tab=' . rawurlencode($tab) . '&from=' . rawurlencode($range['from']) . '&to=' . rawurlencode($range['to']) . '&artist_id=' . $selectedArtistId . '&q=' . rawurlencode($selectedQuery); ?>
<a href="<?= htmlspecialchars($rangeUrl, ENT_QUOTES, 'UTF-8') ?>" class="reports-chip <?= $activeRangeLabel === $range['label'] ? 'is-active' : '' ?>"><?= htmlspecialchars($range['label'], ENT_QUOTES, 'UTF-8') ?></a>
<?php endforeach; ?>
</div>
</div>
<form method="get" action="/admin/store/reports" class="reports-filter-bar">
<input type="hidden" name="tab" value="<?= htmlspecialchars($tab, ENT_QUOTES, 'UTF-8') ?>">
<label class="reports-filter-field reports-filter-wide">
<span>Search</span>
<input class="input" type="text" name="q" value="<?= htmlspecialchars($selectedQuery, ENT_QUOTES, 'UTF-8') ?>" placeholder="Artist, track, release, catalog">
</label>
<label class="reports-filter-field">
<span>From</span>
<input class="input" type="date" name="from" value="<?= htmlspecialchars($selectedFrom, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label class="reports-filter-field">
<span>To</span>
<input class="input" type="date" name="to" value="<?= htmlspecialchars($selectedTo, ENT_QUOTES, 'UTF-8') ?>">
</label>
<label class="reports-filter-field">
<span>Artist</span>
<select class="input" name="artist_id">
<option value="0">All artists</option>
<?php foreach ($artistOptions as $artist): ?>
<option value="<?= (int)($artist['id'] ?? 0) ?>" <?= $selectedArtistId === (int)($artist['id'] ?? 0) ? 'selected' : '' ?>><?= htmlspecialchars((string)($artist['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="reports-filter-actions">
<button type="submit" class="btn small">Apply</button>
<a href="/admin/store/reports?tab=<?= rawurlencode($tab) ?>" class="btn outline small">Reset</a>
</div>
</form>
<?php if ($tab === 'overview'): ?>
<div class="reports-kpi-grid reports-kpi-grid-minimal">
<article class="reports-metric reports-metric-primary reports-metric-focus">
<span>Before Fees</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($overviewNet, 2) ?></strong>
<small>Total sales before processor fees.</small>
</article>
<article class="reports-metric reports-metric-primary reports-metric-focus">
<span>After Fees</span>
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($overviewNetAfterFees, 2) ?></strong>
<small>Total sales after captured PayPal fees.</small>
</article>
</div>
<div class="reports-showcase-grid">
<section class="reports-panel reports-ranking-panel">
<div class="reports-panel-head">
<div>
<h2>Top Artists</h2>
<p>Best performing artist allocations for the selected period.</p>
</div>
<span>Net After Fees</span>
</div>
<?php if (!$overview['top_artists']): ?>
<div class="reports-empty-state compact">
<strong>No artist sales in this period.</strong>
<span>Try widening the date range or clearing the artist filter.</span>
</div>
<?php else: ?>
<div class="reports-ranking-list">
<?php foreach ($overview['top_artists'] as $index => $row): ?>
<article class="reports-ranking-row">
<div class="reports-rank"><?= $index + 1 ?></div>
<div class="reports-ranking-copy">
<strong><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= (int)($row['units_sold'] ?? 0) ?> sold / <?= (int)($row['paid_orders'] ?? 0) ?> paid orders / <?= (int)($row['release_count'] ?? 0) ?> releases</span>
</div>
<div class="reports-ranking-value"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="reports-panel reports-ranking-panel">
<div class="reports-panel-head">
<div>
<h2>Top Tracks</h2>
<p>Derived track performance from direct, release, and bundle allocations.</p>
</div>
<span>Net After Fees</span>
</div>
<?php if (!$overview['top_tracks']): ?>
<div class="reports-empty-state compact">
<strong>No track sales in this period.</strong>
<span>Track-level allocations appear once paid orders exist in the selected range.</span>
</div>
<?php else: ?>
<div class="reports-ranking-list">
<?php foreach ($overview['top_tracks'] as $index => $row): ?>
<article class="reports-ranking-row reports-ranking-row-track">
<div class="reports-rank"><?= $index + 1 ?></div>
<div class="reports-ranking-copy">
<strong><?= htmlspecialchars((string)($row['track_display'] ?? 'Track'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?> / <?= htmlspecialchars((string)($row['catalog_no'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="reports-ranking-value"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
<?php elseif ($tab === 'artists'): ?>
<section class="reports-panel reports-ledger-panel">
<div class="reports-panel-head reports-panel-head-large">
<div>
<h2>Artist Ledger</h2>
<p>Grouped artist performance with paid orders, units, refunds, captured fees, and net after fees.</p>
</div>
<span><?= count($artistRows) ?> rows</span>
</div>
<?php if (!$artistRows): ?>
<div class="reports-empty-state compact">
<strong>No artist sales found.</strong>
<span>Try widening the date range or clearing the artist filter.</span>
</div>
<?php else: ?>
<div class="reports-ledger-list">
<?php foreach ($artistRows as $row): ?>
<article class="reports-ledger-row">
<div class="reports-ledger-main">
<strong><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= (int)($row['release_count'] ?? 0) ?> releases / <?= (int)($row['track_count'] ?? 0) ?> direct tracks</span>
</div>
<div class="reports-ledger-metrics">
<div class="reports-stat-chip"><small>Paid Orders</small><strong><?= (int)($row['paid_orders'] ?? 0) ?></strong></div>
<div class="reports-stat-chip"><small>Units</small><strong><?= (int)($row['units_sold'] ?? 0) ?></strong></div>
<div class="reports-stat-chip muted"><small>Refunded Units</small><strong><?= (int)($row['units_refunded'] ?? 0) ?></strong></div>
<div class="reports-money-stack"><small>Gross</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['gross_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>Refunded</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['refunded_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>PayPal Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['payment_fees'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack emphasis"><small>Net After Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></strong></div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php else: ?>
<section class="reports-panel reports-ledger-panel">
<div class="reports-panel-head reports-panel-head-large">
<div>
<h2>Track Ledger</h2>
<p>Track-level breakdown derived from direct sales, release sales, bundle allocations, and captured PayPal fees.</p>
</div>
<span><?= count($trackRows) ?> rows</span>
</div>
<?php if (!$trackRows): ?>
<div class="reports-empty-state compact">
<strong>No track sales found.</strong>
<span>Track metrics will populate once paid orders exist in the selected range.</span>
</div>
<?php else: ?>
<div class="reports-ledger-list reports-ledger-list-tracks">
<?php foreach ($trackRows as $row): ?>
<article class="reports-ledger-row reports-ledger-row-track">
<div class="reports-ledger-main">
<strong><?= htmlspecialchars((string)($row['track_display'] ?? 'Track'), ENT_QUOTES, 'UTF-8') ?></strong>
<span><?= htmlspecialchars((string)($row['artist_name'] ?? 'Unknown Artist'), ENT_QUOTES, 'UTF-8') ?> / <?= htmlspecialchars((string)($row['release_title'] ?? ''), ENT_QUOTES, 'UTF-8') ?><?= !empty($row['catalog_no']) ? ' / ' . htmlspecialchars((string)$row['catalog_no'], ENT_QUOTES, 'UTF-8') : '' ?></span>
</div>
<div class="reports-ledger-metrics">
<div class="reports-stat-chip"><small>Sold</small><strong><?= (int)($row['units_sold'] ?? 0) ?></strong></div>
<div class="reports-stat-chip muted"><small>Refunded Units</small><strong><?= (int)($row['units_refunded'] ?? 0) ?></strong></div>
<div class="reports-stat-chip"><small>Downloads</small><strong><?= (int)($row['download_count'] ?? 0) ?></strong></div>
<div class="reports-money-stack"><small>Gross</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['gross_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>Refunded</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['refunded_revenue'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack muted"><small>PayPal Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['payment_fees'] ?? 0), 2) ?></strong></div>
<div class="reports-money-stack emphasis"><small>Net After Fees</small><strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($row['net_after_fees'] ?? 0), 2) ?></strong></div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php endif; ?>
</section>
<style>
.reports-shell { display:grid; gap:18px; }
.reports-hero { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:18px; align-items:start; }
.reports-hero-copy { display:grid; gap:10px; }
.reports-hero-copy h1 { margin:0; font-size:34px; line-height:1; }
.reports-hero-copy p { margin:0; max-width:760px; color:var(--muted); font-size:15px; }
.reports-hero-actions { display:grid; gap:12px; justify-items:stretch; }
.reports-status-card { padding:16px; border-radius:16px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.015)); box-shadow:inset 0 1px 0 rgba(255,255,255,.04); }
.reports-status-card strong { display:block; font-size:20px; margin-top:6px; }
.reports-status-card small, .reports-status-label { color:var(--muted); display:block; }
.reports-status-label { font-size:11px; text-transform:uppercase; letter-spacing:.16em; }
.reports-toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.reports-tabset { display:flex; flex-wrap:wrap; gap:8px; }
.reports-export-actions { display:flex; gap:8px; flex-wrap:wrap; }
.reports-tab { padding:10px 14px; border-radius:999px; border:1px solid rgba(255,255,255,.08); color:var(--muted); text-decoration:none; font-size:12px; letter-spacing:.16em; text-transform:uppercase; background:rgba(255,255,255,.02); }
.reports-tab.is-active { color:#0b1015; background:linear-gradient(135deg, #48d3ff, #31f0a8); border-color:transparent; box-shadow:0 12px 30px rgba(49,240,168,.18); }
.reports-range-bar { display:grid; grid-template-columns:minmax(220px,.9fr) minmax(0,2.1fr); gap:16px; align-items:start; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.012)); }
.reports-range-copy { display:grid; gap:4px; }
.reports-range-label { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; }
.reports-range-copy strong { font-size:22px; line-height:1.1; }
.reports-range-copy small { color:var(--muted); }
.reports-range-chips { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-start; align-content:flex-start; }
.reports-chip { padding:9px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); color:var(--muted); text-decoration:none; font-size:11px; letter-spacing:.12em; text-transform:uppercase; background:rgba(255,255,255,.02); }
.reports-chip.is-active { color:#edf6ff; border-color:rgba(72,211,255,.28); background:rgba(72,211,255,.12); }
.reports-filter-bar { display:grid; grid-template-columns:minmax(280px,1.6fr) repeat(3, minmax(160px, .8fr)) auto; gap:12px; align-items:end; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); }
.reports-filter-field { display:grid; gap:8px; }
.reports-filter-field span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.14em; }
.reports-filter-actions { display:flex; gap:8px; align-items:center; justify-content:flex-end; flex-wrap:wrap; }
.reports-kpi-grid { display:grid; grid-template-columns:repeat(7, minmax(0,1fr)); gap:12px; }
.reports-kpi-grid-minimal { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-metric { padding:18px; border-radius:18px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.015)); display:grid; gap:10px; }
.reports-metric span { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; }
.reports-metric strong { font-size:28px; line-height:1; }
.reports-metric small { color:var(--muted); font-size:12px; }
.reports-metric-primary strong { font-size:32px; }
.reports-metric-focus { min-height:150px; }
.reports-showcase-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.reports-panel { padding:18px; border-radius:20px; border:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.015)); }
.reports-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:14px; }
.reports-panel-head h2 { margin:0; font-size:22px; }
.reports-panel-head p { margin:6px 0 0; color:var(--muted); max-width:560px; }
.reports-panel-head span { color:var(--muted); font-size:11px; letter-spacing:.16em; text-transform:uppercase; white-space:nowrap; }
.reports-panel-head-large { margin-bottom:18px; }
.reports-ranking-list, .reports-ledger-list { display:grid; gap:10px; }
.reports-ranking-row { display:grid; grid-template-columns:46px minmax(0,1fr) auto; gap:14px; align-items:center; padding:14px 16px; border-radius:16px; border:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.025); }
.reports-rank { width:46px; height:46px; border-radius:14px; display:grid; place-items:center; font-size:20px; font-weight:800; color:#d8eef9; background:linear-gradient(180deg, rgba(72,211,255,.18), rgba(49,240,168,.08)); border:1px solid rgba(72,211,255,.18); }
.reports-ranking-copy { display:grid; gap:4px; min-width:0; }
.reports-ranking-copy strong { font-size:16px; }
.reports-ranking-copy span { color:var(--muted); font-size:13px; }
.reports-ranking-value { font-size:20px; font-weight:800; white-space:nowrap; }
.reports-ledger-panel { display:grid; gap:12px; }
.reports-ledger-row { display:grid; grid-template-columns:minmax(220px,1.15fr) minmax(0,2.2fr); gap:14px; align-items:start; padding:16px; border-radius:18px; border:1px solid rgba(255,255,255,.07); background:linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.012)); }
.reports-ledger-row-track { grid-template-columns:minmax(280px,1.25fr) minmax(0,2.3fr); }
.reports-ledger-main { display:grid; gap:5px; align-content:center; min-width:0; }
.reports-ledger-main strong { font-size:18px; }
.reports-ledger-main span { color:var(--muted); font-size:13px; }
.reports-ledger-metrics { display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:10px; }
.reports-stat-chip, .reports-money-stack { padding:12px 14px; border-radius:14px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.05); display:grid; gap:6px; align-content:center; }
.reports-stat-chip small, .reports-money-stack small { color:var(--muted); font-size:10px; letter-spacing:.14em; text-transform:uppercase; }
.reports-stat-chip strong, .reports-money-stack strong { font-size:20px; line-height:1; }
.reports-money-stack.emphasis { background:linear-gradient(180deg, rgba(72,211,255,.12), rgba(49,240,168,.08)); border-color:rgba(72,211,255,.18); }
.reports-stat-chip.muted, .reports-money-stack.muted { opacity:.78; }
.reports-empty-state { padding:16px 18px; border-radius:16px; border:1px dashed rgba(255,255,255,.12); background:rgba(255,255,255,.015); display:grid; gap:6px; color:var(--muted); }
.reports-empty-state strong { color:#edf6ff; font-size:15px; }
.reports-empty-state.compact { min-height:132px; align-content:center; }
@media (max-width: 1380px) {
.reports-hero { grid-template-columns:1fr; }
.reports-kpi-grid { grid-template-columns:repeat(3, minmax(0,1fr)); }
.reports-range-bar { grid-template-columns:1fr; }
.reports-filter-bar { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-filter-wide, .reports-filter-actions { grid-column:1 / -1; }
.reports-filter-actions { justify-content:flex-start; }
.reports-showcase-grid { grid-template-columns:1fr; }
.reports-ledger-row, .reports-ledger-row-track { grid-template-columns:1fr; }
}
@media (max-width: 920px) {
.reports-kpi-grid { grid-template-columns:repeat(2, minmax(0,1fr)); }
.reports-ranking-row { grid-template-columns:40px minmax(0,1fr); }
.reports-ranking-value { grid-column:2; }
.reports-ledger-metrics { grid-template-columns:repeat(2, minmax(0,1fr)); }
}
@media (max-width: 640px) {
.reports-kpi-grid, .reports-filter-bar, .reports-ledger-row, .reports-ledger-row-track, .reports-ledger-metrics { grid-template-columns:1fr; }
.reports-toolbar { align-items:flex-start; }
.reports-range-chips, .reports-export-actions { width:100%; }
.reports-ranking-row { grid-template-columns:1fr; }
.reports-rank { width:38px; height:38px; }
.reports-ranking-value { grid-column:auto; }
}
</style>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -71,11 +71,13 @@ class ArtistsController
SELECT id, title, slug, release_date, cover_url, artist_name SELECT id, title, slug, release_date, cover_url, artist_name
FROM ac_releases FROM ac_releases
WHERE is_published = 1 WHERE is_published = 1
AND (release_date IS NULL OR release_date <= :today)
AND (artist_id = :artist_id OR artist_name = :artist_name) AND (artist_id = :artist_id OR artist_name = :artist_name)
ORDER BY release_date DESC, created_at DESC ORDER BY release_date DESC, created_at DESC
LIMIT 2 LIMIT 2
"); ");
$relStmt->execute([ $relStmt->execute([
':today' => date('Y-m-d'),
':artist_id' => (int)($artist['id'] ?? 0), ':artist_id' => (int)($artist['id'] ?? 0),
':artist_name' => (string)($artist['name'] ?? ''), ':artist_name' => (string)($artist['name'] ?? ''),
]); ]);
@@ -84,11 +86,13 @@ class ArtistsController
SELECT id, title, slug, release_date, cover_url, artist_name SELECT id, title, slug, release_date, cover_url, artist_name
FROM ac_releases FROM ac_releases
WHERE is_published = 1 WHERE is_published = 1
AND (release_date IS NULL OR release_date <= :today)
AND artist_name = :artist_name AND artist_name = :artist_name
ORDER BY release_date DESC, created_at DESC ORDER BY release_date DESC, created_at DESC
LIMIT 2 LIMIT 2
"); ");
$relStmt->execute([ $relStmt->execute([
':today' => date('Y-m-d'),
':artist_name' => (string)($artist['name'] ?? ''), ':artist_name' => (string)($artist['name'] ?? ''),
]); ]);
} }

View File

@@ -2,10 +2,63 @@
declare(strict_types=1); declare(strict_types=1);
use Core\Http\Router; use Core\Http\Router;
use Core\Services\Database;
use Core\Services\Shortcodes;
use Plugins\Artists\ArtistsController; use Plugins\Artists\ArtistsController;
require_once __DIR__ . '/ArtistsController.php'; require_once __DIR__ . '/ArtistsController.php';
Shortcodes::register('new-artists', static function (array $attrs = []): string {
$limit = max(1, min(20, (int)($attrs['limit'] ?? 6)));
$db = Database::get();
if (!($db instanceof \PDO)) {
return '';
}
try {
$stmt = $db->prepare("
SELECT name, slug, country, avatar_url
FROM ac_artists
WHERE is_active = 1
ORDER BY created_at DESC, id 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 artists available yet.</div>';
}
$cards = '';
foreach ($rows as $row) {
$name = htmlspecialchars((string)($row['name'] ?? ''), ENT_QUOTES, 'UTF-8');
$slug = rawurlencode((string)($row['slug'] ?? ''));
$country = htmlspecialchars(trim((string)($row['country'] ?? '')), ENT_QUOTES, 'UTF-8');
$avatar = trim((string)($row['avatar_url'] ?? ''));
$avatarHtml = $avatar !== ''
? '<img src="' . htmlspecialchars($avatar, ENT_QUOTES, 'UTF-8') . '" alt="" loading="lazy">'
: '<div class="ac-shortcode-cover-fallback">AC</div>';
$cards .= '<a class="ac-shortcode-artist-card" href="/artist?slug=' . $slug . '">'
. '<div class="ac-shortcode-artist-avatar">' . $avatarHtml . '</div>'
. '<div class="ac-shortcode-artist-meta">'
. '<div class="ac-shortcode-artist-name">' . $name . '</div>'
. ($country !== '' ? '<div class="ac-shortcode-artist-country">' . $country . '</div>' : '')
. '</div>'
. '</a>';
}
return '<section class="ac-shortcode-artists">'
. '<div class="ac-shortcode-block-head">New Artists</div>'
. '<div class="ac-shortcode-artists-grid">' . $cards . '</div>'
. '</section>';
});
return function (Router $router): void { return function (Router $router): void {
$controller = new ArtistsController(); $controller = new ArtistsController();
$router->get('/artists', [$controller, 'index']); $router->get('/artists', [$controller, 'index']);

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ Shortcodes::register('releases', static function (array $attrs = []): string {
FROM ac_releases r FROM ac_releases r
LEFT JOIN ac_artists a ON a.id = r.artist_id LEFT JOIN ac_artists a ON a.id = r.artist_id
WHERE r.is_published = 1 WHERE r.is_published = 1
AND (r.release_date IS NULL OR r.release_date <= CURDATE())
ORDER BY r.release_date DESC, r.created_at DESC ORDER BY r.release_date DESC, r.created_at DESC
LIMIT :limit LIMIT :limit
"); ");
@@ -38,6 +39,7 @@ Shortcodes::register('releases', static function (array $attrs = []): string {
SELECT title, slug, release_date, cover_url, artist_name SELECT title, slug, release_date, cover_url, artist_name
FROM ac_releases FROM ac_releases
WHERE is_published = 1 WHERE is_published = 1
AND (release_date IS NULL OR release_date <= CURDATE())
ORDER BY release_date DESC, created_at DESC ORDER BY release_date DESC, created_at DESC
LIMIT :limit LIMIT :limit
"); ");
@@ -73,7 +75,14 @@ Shortcodes::register('releases', static function (array $attrs = []): string {
. '</a>'; . '</a>';
} }
return '<section class="ac-shortcode-releases"><div class="ac-shortcode-release-grid">' . $cards . '</div></section>'; return '<section class="ac-shortcode-releases">'
. '<div class="ac-shortcode-block-head">Latest Releases</div>'
. '<div class="ac-shortcode-release-grid">' . $cards . '</div>'
. '</section>';
});
Shortcodes::register('latest-releases', static function (array $attrs = []): string {
return Shortcodes::render('[releases limit="' . max(1, min(20, (int)($attrs['limit'] ?? 8))) . '"]');
}); });
return function (Router $router): void { return function (Router $router): void {
@@ -106,4 +115,6 @@ return function (Router $router): void {
$router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']); $router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']);
$router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']); $router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']);
$router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']); $router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']);
$router->post('/admin/releases/tracks/sample/generate', [$controller, 'adminTrackGenerateSample']);
$router->get('/admin/releases/tracks/source', [$controller, 'adminTrackSource']);
}; };

View File

@@ -60,21 +60,6 @@ ob_start();
<button type="button" class="btn outline small" data-media-picker="release_cover_url" data-media-picker-mode="url">Pick from Media</button> <button type="button" class="btn outline small" data-media-picker="release_cover_url" data-media-picker-mode="url">Pick from Media</button>
</div> </div>
<input class="input" id="release_cover_url" name="cover_url" value="<?= htmlspecialchars((string)($release['cover_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://..."> <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> <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> <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> <label class="label">Release Credits</label>
@@ -137,30 +122,6 @@ ob_start();
coverName.textContent = coverFile.files.length ? coverFile.files[0].name : 'No file selected'; 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> </script>
<?php <?php

View File

@@ -5,6 +5,8 @@ $release = $release ?? null;
$error = $error ?? ''; $error = $error ?? '';
$uploadError = (string)($_GET['upload_error'] ?? ''); $uploadError = (string)($_GET['upload_error'] ?? '');
$storePluginEnabled = (bool)($store_plugin_enabled ?? false); $storePluginEnabled = (bool)($store_plugin_enabled ?? false);
$trackId = (int)($track['id'] ?? 0);
$fullSourceUrl = $trackId > 0 ? '/admin/releases/tracks/source?track_id=' . $trackId : '';
ob_start(); ob_start();
?> ?>
<section class="admin-card"> <section class="admin-card">
@@ -53,7 +55,7 @@ ob_start();
<div style="font-size:13px; color:var(--text);">or click to upload</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> <div id="trackSampleFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label> </label>
<input class="input" type="file" id="trackSampleFile" name="track_sample" accept="audio/mpeg" style="display:none;"> <input class="input" type="file" id="trackSampleFile" name="track_sample" accept=".mp3,audio/mpeg,audio/mp3" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;"> <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> <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>
@@ -70,12 +72,35 @@ ob_start();
<div style="font-size:13px; color:var(--text);">or click to upload</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> <div id="trackFullFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
</label> </label>
<input class="input" type="file" id="trackFullFile" name="track_sample" accept="audio/mpeg" style="display:none;"> <input class="input" type="file" id="trackFullFile" name="track_sample" accept=".mp3,audio/mpeg,audio/mp3" style="display:none;">
<div style="margin-top:10px; display:flex; justify-content:flex-end;"> <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> <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>
</div> </div>
<?php if ($trackId > 0 && (string)($track['full_file_url'] ?? '') !== ''): ?>
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
<div class="label">Generate sample from full track</div>
<div id="trackWaveform" style="margin-top:8px; height:96px; border-radius:12px; border:1px solid rgba(255,255,255,.12); background:rgba(0,0,0,.25);"></div>
<div style="display:grid; gap:10px; margin-top:10px;">
<input type="hidden" id="sampleStart" name="sample_start" value="0">
<input type="hidden" id="sampleDuration" name="sample_duration" value="90">
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:10px;">
<button type="button" class="btn outline small" id="previewSampleRegionBtn">Preview</button>
<div class="input" style="display:flex; align-items:center; gap:10px; min-height:42px; width:clamp(240px,42vw,440px);">
<span style="font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted);">Range</span>
<span id="sampleRangeText" style="font-family:'IBM Plex Mono',monospace; color:var(--text);">0s -> 90s</span>
<span id="sampleDurationText" style="margin-left:auto; font-family:'IBM Plex Mono',monospace; color:var(--muted);">90s</span>
</div>
<div style="margin-left:auto; display:flex; gap:10px;">
<button type="button" class="btn outline small" id="resetSampleRegionBtn">Reset range</button>
<button type="submit" class="btn small" formaction="/admin/releases/tracks/sample/generate" formmethod="post">Generate sample</button>
</div>
</div>
</div>
</div>
<?php endif; ?>
<label class="label">Full File URL (Store Download)</label> <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"> <input class="input" name="full_file_url" value="<?= htmlspecialchars((string)($track['full_file_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="/uploads/media/track-full.mp3">
@@ -105,6 +130,8 @@ ob_start();
</div> </div>
</form> </form>
</section> </section>
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script src="https://unpkg.com/wavesurfer.js@7/dist/plugins/regions.min.js"></script>
<script> <script>
(function () { (function () {
const drop = document.getElementById('trackSampleDropzone'); const drop = document.getElementById('trackSampleDropzone');
@@ -154,6 +181,113 @@ ob_start();
fullName.textContent = fullFile.files.length ? fullFile.files[0].name : 'No file selected'; fullName.textContent = fullFile.files.length ? fullFile.files[0].name : 'No file selected';
}); });
} }
const waveWrap = document.getElementById('trackWaveform');
const previewRegionBtn = document.getElementById('previewSampleRegionBtn');
const startInput = document.getElementById('sampleStart');
const durationInput = document.getElementById('sampleDuration');
const rangeText = document.getElementById('sampleRangeText');
const durationText = document.getElementById('sampleDurationText');
const resetRegionBtn = document.getElementById('resetSampleRegionBtn');
if (waveWrap && startInput && durationInput && window.WaveSurfer) {
let fixedDuration = 90;
let regionRef = null;
let isPreviewPlaying = false;
const ws = WaveSurfer.create({
container: waveWrap,
waveColor: 'rgba(175,190,220,0.35)',
progressColor: '#22f2a5',
cursorColor: '#22f2a5',
barWidth: 2,
barGap: 2,
height: 92,
});
const regions = ws.registerPlugin(window.WaveSurfer.Regions.create());
ws.load('<?= htmlspecialchars($fullSourceUrl, ENT_QUOTES, 'UTF-8') ?>');
function syncRange(startSec) {
const start = Math.max(0, Math.floor(startSec));
const end = Math.max(start, Math.floor(start + fixedDuration));
startInput.value = String(start);
durationInput.value = String(fixedDuration);
if (rangeText) rangeText.textContent = start + 's → ' + end + 's';
if (durationText) durationText.textContent = fixedDuration + 's';
}
ws.on('ready', () => {
const total = Math.floor(ws.getDuration() || 0);
fixedDuration = Math.max(10, Math.min(90, total > 0 ? total : 90));
syncRange(0);
regionRef = regions.addRegion({
start: 0,
end: fixedDuration,
drag: true,
resize: false,
color: 'rgba(34,242,165,0.20)',
});
const updateRegion = () => {
if (!regionRef) return;
let start = regionRef.start || 0;
const maxStart = Math.max(0, (ws.getDuration() || fixedDuration) - fixedDuration);
if (start < 0) start = 0;
if (start > maxStart) start = maxStart;
regionRef.setOptions({ start: start, end: start + fixedDuration });
syncRange(start);
};
regionRef.on('update-end', updateRegion);
});
ws.on('interaction', () => {});
ws.on('audioprocess', () => {
if (!isPreviewPlaying || !regionRef) return;
const t = ws.getCurrentTime() || 0;
if (t >= regionRef.end) {
ws.pause();
ws.setTime(regionRef.start);
isPreviewPlaying = false;
if (previewRegionBtn) previewRegionBtn.textContent = 'Preview';
}
});
ws.on('pause', () => {
if (isPreviewPlaying) {
isPreviewPlaying = false;
if (previewRegionBtn) previewRegionBtn.textContent = 'Preview';
}
});
if (resetRegionBtn) {
resetRegionBtn.addEventListener('click', () => {
if (!regionRef) return;
regionRef.setOptions({ start: 0, end: fixedDuration });
syncRange(0);
});
}
if (previewRegionBtn) {
previewRegionBtn.addEventListener('click', () => {
if (!regionRef) return;
if (isPreviewPlaying) {
ws.pause();
isPreviewPlaying = false;
previewRegionBtn.textContent = 'Preview';
return;
}
ws.setTime(regionRef.start);
ws.play();
isPreviewPlaying = true;
previewRegionBtn.textContent = 'Stop preview';
});
}
} else if (resetRegionBtn) {
resetRegionBtn.addEventListener('click', () => {
if (startInput) startInput.value = '0';
});
}
})(); })();
</script> </script>
<?php <?php

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
$pageTitle = $title ?? 'Release'; $pageTitle = $title ?? 'Release';
$release = $release ?? null; $release = $release ?? null;
$tracks = $tracks ?? []; $tracks = $tracks ?? [];
$bundles = is_array($bundles ?? null) ? $bundles : [];
$storePluginEnabled = (bool)($store_plugin_enabled ?? false); $storePluginEnabled = (bool)($store_plugin_enabled ?? false);
$releaseCover = (string)($release['cover_url'] ?? ''); $releaseCover = (string)($release['cover_url'] ?? '');
$returnUrl = (string)($_SERVER['REQUEST_URI'] ?? '/releases'); $returnUrl = (string)($_SERVER['REQUEST_URI'] ?? '/releases');
@@ -153,6 +154,64 @@ ob_start();
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($storePluginEnabled && $bundles): ?>
<div class="bundle-zone" style="display:grid; gap:10px;">
<div class="badge">Bundle Deals</div>
<div style="display:grid; gap:10px;">
<?php foreach ($bundles as $bundle): ?>
<?php
$bundleId = (int)($bundle['id'] ?? 0);
$bundleName = trim((string)($bundle['name'] ?? 'Bundle'));
$bundlePriceRow = (float)($bundle['bundle_price'] ?? 0);
$bundleCurrencyRow = (string)($bundle['currency'] ?? 'GBP');
$bundleLabelRow = trim((string)($bundle['purchase_label'] ?? ''));
$bundleLabelRow = $bundleLabelRow !== '' ? $bundleLabelRow : 'Buy Bundle';
$bundleCovers = is_array($bundle['covers'] ?? null) ? $bundle['covers'] : [];
$bundleCount = (int)($bundle['release_count'] ?? 0);
$regularTotal = (float)($bundle['regular_total'] ?? 0);
$saving = max(0, $regularTotal - $bundlePriceRow);
?>
<div class="bundle-card">
<div class="bundle-stack" style="--cover-count:<?= max(1, min(5, count($bundleCovers))) ?>;" aria-hidden="true">
<?php if ($bundleCovers): ?>
<?php foreach (array_slice($bundleCovers, 0, 5) as $i => $cover): ?>
<span class="bundle-cover" style="--i:<?= (int)$i ?>;">
<img src="<?= htmlspecialchars((string)$cover, ENT_QUOTES, 'UTF-8') ?>" alt="">
</span>
<?php endforeach; ?>
<?php else: ?>
<span class="bundle-cover bundle-fallback">AC</span>
<?php endif; ?>
</div>
<div class="bundle-copy">
<div class="bundle-title"><?= htmlspecialchars($bundleName, ENT_QUOTES, 'UTF-8') ?></div>
<div class="bundle-meta">
<?= $bundleCount > 0 ? $bundleCount . ' releases' : 'Multi-release bundle' ?>
<?php if ($saving > 0): ?>
<span class="bundle-save">Save <?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?> <?= number_format($saving, 2) ?></span>
<?php endif; ?>
</div>
</div>
<form method="post" action="/store/cart/add" style="margin:0;">
<input type="hidden" name="item_type" value="bundle">
<input type="hidden" name="item_id" value="<?= $bundleId ?>">
<input type="hidden" name="title" value="<?= htmlspecialchars($bundleName, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="cover_url" value="<?= htmlspecialchars((string)($bundleCovers[0] ?? $releaseCover), ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="currency" value="<?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="price" value="<?= htmlspecialchars(number_format($bundlePriceRow, 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($bundleLabelRow, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($bundleCurrencyRow, ENT_QUOTES, 'UTF-8') ?> <?= number_format($bundlePriceRow, 2) ?></span>
</button>
</form>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($release['credits'])): ?> <?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 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 class="badge" style="font-size:9px;">Credits</div>
@@ -192,6 +251,15 @@ ob_start();
.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{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-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} .track-play-btn[disabled]{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:var(--muted);cursor:not-allowed}
.bundle-card{display:grid;grid-template-columns:max-content minmax(0,1fr) auto;gap:14px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.1);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01))}
.bundle-stack{position:relative;height:70px;width:calc(70px + (max(0, var(--cover-count, 1) - 1) * 16px));}
.bundle-cover{position:absolute;left:calc(var(--i,0) * 16px);top:0;width:70px;height:70px;border-radius:10px;overflow:hidden;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);box-shadow:0 8px 20px rgba(0,0,0,.35)}
.bundle-cover img{width:100%;height:100%;object-fit:cover}
.bundle-fallback{display:grid;place-items:center;font-size:11px;color:var(--muted)}
.bundle-copy{min-width:0}
.bundle-title{font-size:17px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bundle-meta{margin-top:6px;font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.bundle-save{color:#9ff7d5;font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:11px}
.ac-dock{position:fixed;left:14px;right:14px;bottom:14px;z-index:50} .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-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-meta{display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-width:0}
@@ -221,6 +289,9 @@ ob_start();
gap: 10px !important; gap: 10px !important;
} }
.track-buy-btn { font-size: 11px; padding: 0 10px; } .track-buy-btn { font-size: 11px; padding: 0 10px; }
.bundle-card{grid-template-columns:1fr;gap:10px}
.bundle-stack{height:62px}
.bundle-cover{width:62px;height:62px}
.ac-dock { .ac-dock {
left: 8px; left: 8px;
right: 8px; right: 8px;

File diff suppressed because it is too large Load Diff

View File

@@ -39,18 +39,9 @@ Shortcodes::register('sale-chart', static function (array $attrs = []): string {
} }
$rows = []; $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 { try {
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT item_key, item_label AS title, units, revenue SELECT item_label AS title, units, revenue
FROM ac_store_sales_chart_cache FROM ac_store_sales_chart_cache
WHERE chart_scope = :scope WHERE chart_scope = :scope
AND chart_window = :window AND chart_window = :window
@@ -66,16 +57,19 @@ Shortcodes::register('sale-chart', static function (array $attrs = []): string {
$rows = []; $rows = [];
} }
if (!$rows && $scope === 'tracks') { if (!$rows) {
try { try {
$controller = new StoreController();
$controller->rebuildSalesChartCache();
$stmt = $db->prepare(" $stmt = $db->prepare("
SELECT item_key, item_label AS title, units, revenue SELECT item_label AS title, units, revenue
FROM ac_store_sales_chart_cache FROM ac_store_sales_chart_cache
WHERE chart_scope = 'releases' WHERE chart_scope = :scope
AND chart_window = :window AND chart_window = :window
ORDER BY rank_no ASC ORDER BY rank_no ASC
LIMIT :limit LIMIT :limit
"); ");
$stmt->bindValue(':scope', $scope, \PDO::PARAM_STR);
$stmt->bindValue(':window', $window, \PDO::PARAM_STR); $stmt->bindValue(':window', $window, \PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT);
$stmt->execute(); $stmt->execute();
@@ -89,98 +83,33 @@ Shortcodes::register('sale-chart', static function (array $attrs = []): string {
return '<div class="ac-shortcode-empty">No sales yet.</div>'; return '<div class="ac-shortcode-empty">No sales yet.</div>';
} }
$releaseIds = []; $currency = strtoupper(trim((string)Settings::get('store_currency', 'GBP')));
$trackIds = []; if (!preg_match('/^[A-Z]{3}$/', $currency)) {
foreach ($rows as $row) { $currency = 'GBP';
$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 = ''; $list = '';
$position = 1; $position = 1;
foreach ($rows as $row) { foreach ($rows as $row) {
$itemKey = trim((string)($row['item_key'] ?? '')); $title = htmlspecialchars((string)($row['title'] ?? ''), ENT_QUOTES, 'UTF-8');
$rel = $releaseMap[$itemKey] ?? null; $units = (int)($row['units'] ?? 0);
$revenue = number_format((float)($row['revenue'] ?? 0), 2);
$titleRaw = $rel ? (string)($rel['title'] ?? '') : (string)($row['title'] ?? ''); $list .= '<li class="ac-shortcode-sale-item">'
$artistRaw = $rel ? trim((string)($rel['artist_name'] ?? '')) : ''; . '<span class="ac-shortcode-sale-rank">#' . $position . '</span>'
$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>' . '<span class="ac-shortcode-sale-title">' . $title . '</span>'
. ($artist !== '' ? '<span class="ac-shortcode-sale-artist">' . $artist . '</span>' : '') . '<span class="ac-shortcode-sale-meta">' . $units . ' sold - ' . htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') . ' ' . $revenue . '</span>'
. '</span>' . '</li>';
. '<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++; $position++;
} }
$heading = htmlspecialchars((string)($attrs['title'] ?? 'Top Sellers'), ENT_QUOTES, 'UTF-8'); return '<section class="ac-shortcode-sale-chart"><ol class="ac-shortcode-sale-list">' . $list . '</ol></section>';
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('top-sellers', static function (array $attrs = []): string {
$type = trim((string)($attrs['type'] ?? 'tracks'));
$window = trim((string)($attrs['window'] ?? 'latest'));
$limit = max(1, min(50, (int)($attrs['limit'] ?? 10)));
return Shortcodes::render('[sale-chart type="' . $type . '" window="' . $window . '" limit="' . $limit . '"]');
}); });
Shortcodes::register('login-link', static function (array $attrs = []): string { Shortcodes::register('login-link', static function (array $attrs = []): string {
@@ -252,11 +181,15 @@ return function (Router $router): void {
$router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']); $router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']);
$router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']); $router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']);
$router->get('/checkout', [$controller, 'checkoutIndex']); $router->get('/checkout', [$controller, 'checkoutIndex']);
$router->post('/checkout/card/start', [$controller, 'checkoutCardStart']);
$router->get('/checkout/card', [$controller, 'checkoutCard']);
$router->get('/account', [$controller, 'accountIndex']); $router->get('/account', [$controller, 'accountIndex']);
$router->post('/account/request-login', [$controller, 'accountRequestLogin']); $router->post('/account/request-login', [$controller, 'accountRequestLogin']);
$router->get('/account/login', [$controller, 'accountLogin']); $router->get('/account/login', [$controller, 'accountLogin']);
$router->get('/account/logout', [$controller, 'accountLogout']); $router->get('/account/logout', [$controller, 'accountLogout']);
$router->post('/checkout/place', [$controller, 'checkoutPlace']); $router->post('/checkout/place', [$controller, 'checkoutPlace']);
$router->post('/checkout/paypal/create-order', [$controller, 'checkoutPaypalCreateOrder']);
$router->post('/checkout/paypal/capture-order', [$controller, 'checkoutPaypalCaptureJson']);
$router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']); $router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']);
$router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']); $router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']);
$router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']); $router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']);
@@ -270,6 +203,8 @@ return function (Router $router): void {
$router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']); $router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']);
$router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']); $router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']);
$router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']); $router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']);
$router->post('/admin/store/bundles/create', [$controller, 'adminBundleCreate']);
$router->post('/admin/store/bundles/delete', [$controller, 'adminBundleDelete']);
$router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']); $router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']);
$router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']); $router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']);
$router->get('/admin/store/customers', [$controller, 'adminCustomers']); $router->get('/admin/store/customers', [$controller, 'adminCustomers']);

View File

@@ -1,8 +1,11 @@
<?php <?php
use Core\Services\Plugins;
$pageTitle = $title ?? 'Store Customers'; $pageTitle = $title ?? 'Store Customers';
$customers = $customers ?? []; $customers = $customers ?? [];
$currency = (string)($currency ?? 'GBP'); $currency = (string)($currency ?? 'GBP');
$q = (string)($q ?? ''); $q = (string)($q ?? '');
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
ob_start(); ob_start();
?> ?>
<section class="admin-card customers-page"> <section class="admin-card customers-page">
@@ -20,6 +23,9 @@ ob_start();
<a href="/admin/store/settings" class="btn outline small">Settings</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/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a> <a href="/admin/store/customers" class="btn outline small">Customers</a>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline small">Sales Reports</a>
<?php endif; ?>
</div> </div>
<form method="get" action="/admin/store/customers" class="customers-search"> <form method="get" action="/admin/store/customers" class="customers-search">
@@ -39,7 +45,9 @@ ob_start();
<tr> <tr>
<th>Customer</th> <th>Customer</th>
<th>Orders</th> <th>Orders</th>
<th>Revenue</th> <th>Before Fees</th>
<th>Fees</th>
<th>After Fees</th>
<th>Latest Order</th> <th>Latest Order</th>
<th>Last Seen</th> <th>Last Seen</th>
</tr> </tr>
@@ -70,7 +78,9 @@ ob_start();
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="num"><?= (int)($customer['order_count'] ?? 0) ?></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 class="num"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($customer['before_fees'] ?? 0), 2) ?></td>
<td class="num"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($customer['paypal_fees'] ?? 0), 2) ?></td>
<td class="num"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($customer['after_fees'] ?? 0), 2) ?></td>
<td> <td>
<?php if ($lastOrderId > 0): ?> <?php if ($lastOrderId > 0): ?>
<a href="/admin/store/order?id=<?= $lastOrderId ?>" class="order-link"> <a href="/admin/store/order?id=<?= $lastOrderId ?>" class="order-link">
@@ -206,8 +216,10 @@ ob_start();
@media (max-width: 980px) { @media (max-width: 980px) {
.customers-table th:nth-child(3), .customers-table th:nth-child(3),
.customers-table td:nth-child(3), .customers-table td:nth-child(3),
.customers-table th:nth-child(5), .customers-table th:nth-child(4),
.customers-table td:nth-child(5) { display:none; } .customers-table td:nth-child(4),
.customers-table th:nth-child(7),
.customers-table td:nth-child(7) { display:none; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {

View File

@@ -1,4 +1,6 @@
<?php <?php
use Core\Services\Plugins;
$pageTitle = $title ?? 'Store'; $pageTitle = $title ?? 'Store';
$tablesReady = (bool)($tables_ready ?? false); $tablesReady = (bool)($tables_ready ?? false);
$privateRoot = (string)($private_root ?? ''); $privateRoot = (string)($private_root ?? '');
@@ -9,8 +11,11 @@ $newCustomers = is_array($new_customers ?? null) ? $new_customers : [];
$currency = (string)($currency ?? 'GBP'); $currency = (string)($currency ?? 'GBP');
$totalOrders = (int)($stats['total_orders'] ?? 0); $totalOrders = (int)($stats['total_orders'] ?? 0);
$paidOrders = (int)($stats['paid_orders'] ?? 0); $paidOrders = (int)($stats['paid_orders'] ?? 0);
$totalRevenue = (float)($stats['total_revenue'] ?? 0); $beforeFees = (float)($stats['before_fees'] ?? 0);
$paypalFees = (float)($stats['paypal_fees'] ?? 0);
$afterFees = (float)($stats['after_fees'] ?? 0);
$totalCustomers = (int)($stats['total_customers'] ?? 0); $totalCustomers = (int)($stats['total_customers'] ?? 0);
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
ob_start(); ob_start();
?> ?>
<section class="admin-card"> <section class="admin-card">
@@ -24,6 +29,9 @@ ob_start();
<a href="/admin/store/settings" class="btn outline">Settings</a> <a href="/admin/store/settings" class="btn outline">Settings</a>
<a href="/admin/store/orders" class="btn outline">Orders</a> <a href="/admin/store/orders" class="btn outline">Orders</a>
<a href="/admin/store/customers" class="btn outline">Customers</a> <a href="/admin/store/customers" class="btn outline">Customers</a>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline">Sales Reports</a>
<?php endif; ?>
</div> </div>
</div> </div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;"> <div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
@@ -31,6 +39,9 @@ ob_start();
<a href="/admin/store/settings" class="btn outline small">Settings</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/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a> <a href="/admin/store/customers" class="btn outline small">Customers</a>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline small">Sales Reports</a>
<?php endif; ?>
</div> </div>
<?php if (!$tablesReady): ?> <?php if (!$tablesReady): ?>
@@ -62,8 +73,14 @@ ob_start();
<div style="font-size:12px; color:var(--muted); margin-top:4px;">Paid: <?= $paidOrders ?></div> <div style="font-size:12px; color:var(--muted); margin-top:4px;">Paid: <?= $paidOrders ?></div>
</div> </div>
<div class="admin-card" style="padding:14px;"> <div class="admin-card" style="padding:14px;">
<div class="label">Revenue</div> <div class="label">Before Fees</div>
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($totalRevenue, 2) ?></div> <div style="font-size:26px; font-weight:700; margin-top:8px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($beforeFees, 2) ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">Gross paid sales</div>
</div>
<div class="admin-card" style="padding:14px;">
<div class="label">After Fees</div>
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($afterFees, 2) ?></div>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">PayPal fees: <?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($paypalFees, 2) ?></div>
</div> </div>
<div class="admin-card" style="padding:14px;"> <div class="admin-card" style="padding:14px;">
<div class="label">Total Customers</div> <div class="label">Total Customers</div>
@@ -86,7 +103,11 @@ ob_start();
</div> </div>
<div style="display:flex; justify-content:space-between; gap:10px; color:var(--muted); font-size:12px; margin-top:4px;"> <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['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span><?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></span> <span><?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_net'] ?? $order['total'] ?? 0), 2) ?></span>
</div>
<div style="display:flex; justify-content:space-between; gap:10px; color:var(--muted); font-size:12px; margin-top:4px;">
<span>Before fees <?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_gross'] ?? $order['total'] ?? 0), 2) ?></span>
<span>Fees <?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_fee'] ?? 0), 2) ?></span>
</div> </div>
<div style="margin-top:8px;"> <div style="margin-top:8px;">
<a href="/admin/store/order?id=<?= (int)($order['id'] ?? 0) ?>" class="btn outline small">View Order</a> <a href="/admin/store/order?id=<?= (int)($order['id'] ?? 0) ?>" class="btn outline small">View Order</a>

View File

@@ -1,9 +1,12 @@
<?php <?php
use Core\Services\Plugins;
$pageTitle = $title ?? 'Order Detail'; $pageTitle = $title ?? 'Order Detail';
$order = is_array($order ?? null) ? $order : []; $order = is_array($order ?? null) ? $order : [];
$items = is_array($items ?? null) ? $items : []; $items = is_array($items ?? null) ? $items : [];
$downloadsByItem = is_array($downloads_by_item ?? null) ? $downloads_by_item : []; $downloadsByItem = is_array($downloads_by_item ?? null) ? $downloads_by_item : [];
$downloadEvents = is_array($download_events ?? null) ? $download_events : []; $downloadEvents = is_array($download_events ?? null) ? $download_events : [];
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
ob_start(); ob_start();
?> ?>
<section class="admin-card"> <section class="admin-card">
@@ -20,33 +23,45 @@ ob_start();
<a href="/admin/store/settings" class="btn outline small">Settings</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/orders" class="btn outline small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a> <a href="/admin/store/customers" class="btn outline small">Customers</a>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline small">Sales Reports</a>
<?php endif; ?>
</div> </div>
<div class="admin-card" style="padding:14px; margin-top:16px;"> <div class="admin-card order-summary-card" style="margin-top:16px;">
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px;"> <div class="order-summary-top">
<div> <div class="order-summary-identity">
<div class="label">Order Number</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 class="order-summary-no"><?= htmlspecialchars((string)($order['order_no'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
<div class="order-summary-meta">Customer <?= htmlspecialchars((string)($order['email'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div> </div>
<div> <div class="order-summary-total-cluster">
<div class="order-summary-top-stat">
<div class="label">Status</div> <div class="label">Status</div>
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div> <div class="pill"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div> </div>
<div> <div class="order-summary-top-stat">
<div class="label">Total</div> <div class="label">After Fees</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 class="order-summary-total-value"><?= htmlspecialchars((string)($order['payment_currency'] ?? $order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_net'] ?? $order['total'] ?? 0), 2) ?></div>
</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> </div>
<div class="order-summary-grid">
<div class="order-stat">
<div class="label">Before Fees</div>
<div class="order-stat-value"><?= htmlspecialchars((string)($order['payment_currency'] ?? $order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_gross'] ?? $order['total'] ?? 0), 2) ?></div>
</div>
<div class="order-stat">
<div class="label">PayPal Fees</div>
<div class="order-stat-value"><?= htmlspecialchars((string)($order['payment_currency'] ?? $order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_fee'] ?? 0), 2) ?></div>
</div>
<div class="order-stat">
<div class="label">Order IP</div> <div class="label">Order IP</div>
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['customer_ip'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div> <div class="order-stat-value order-stat-text"><?= htmlspecialchars((string)($order['customer_ip'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div> </div>
<div> <div class="order-stat">
<div class="label">Created</div> <div class="label">Created</div>
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['created_at'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div> <div class="order-stat-value order-stat-text"><?= htmlspecialchars((string)($order['created_at'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
</div> </div>
</div> </div>
</div> </div>
@@ -118,6 +133,108 @@ ob_start();
<?php endif; ?> <?php endif; ?>
</div> </div>
</section> </section>
<style>
.order-summary-card {
padding: 18px;
display: grid;
gap: 18px;
}
.order-summary-top {
display: grid;
grid-template-columns: minmax(0, 1.6fr) auto;
gap: 20px;
align-items: start;
padding-bottom: 18px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.order-summary-identity {
min-width: 0;
}
.order-summary-no {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
line-height: 1.2;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: .02em;
color: #f3f6ff;
word-break: break-all;
}
.order-summary-meta {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.order-summary-total-cluster {
display: grid;
gap: 10px;
min-width: 220px;
}
.order-summary-top-stat {
display: grid;
gap: 6px;
justify-items: end;
text-align: right;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.025);
}
.order-summary-total-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.order-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.order-stat {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.025);
min-width: 0;
}
.order-stat-value {
margin-top: 8px;
font-size: 22px;
font-weight: 700;
line-height: 1.1;
}
.order-stat-text {
font-size: 16px;
font-weight: 600;
color: #eef2ff;
word-break: break-word;
}
@media (max-width: 900px) {
.order-summary-top {
grid-template-columns: 1fr;
}
.order-summary-total-cluster {
grid-template-columns: 1fr 1fr;
min-width: 0;
}
.order-summary-top-stat {
justify-items: start;
text-align: left;
}
.order-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.order-summary-total-cluster,
.order-summary-grid {
grid-template-columns: 1fr;
}
}
</style>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
require __DIR__ . '/../../../../modules/admin/views/layout.php'; require __DIR__ . '/../../../../modules/admin/views/layout.php';

View File

@@ -1,9 +1,12 @@
<?php <?php
use Core\Services\Plugins;
$pageTitle = $title ?? 'Store Orders'; $pageTitle = $title ?? 'Store Orders';
$orders = is_array($orders ?? null) ? $orders : []; $orders = is_array($orders ?? null) ? $orders : [];
$q = (string)($q ?? ''); $q = (string)($q ?? '');
$saved = (string)($saved ?? ''); $saved = (string)($saved ?? '');
$error = (string)($error ?? ''); $error = (string)($error ?? '');
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
ob_start(); ob_start();
?> ?>
<section class="admin-card store-orders"> <section class="admin-card store-orders">
@@ -21,6 +24,9 @@ ob_start();
<a href="/admin/store/settings" class="btn outline small">Settings</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/orders" class="btn small">Orders</a>
<a href="/admin/store/customers" class="btn outline small">Customers</a> <a href="/admin/store/customers" class="btn outline small">Customers</a>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline small">Sales Reports</a>
<?php endif; ?>
</div> </div>
<?php if ($saved !== ''): ?> <?php if ($saved !== ''): ?>
@@ -78,7 +84,11 @@ ob_start();
<div class="store-order-amount"> <div class="store-order-amount">
<?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>
<?= number_format((float)($order['total'] ?? 0), 2) ?> <?= number_format((float)($order['payment_net'] ?? $order['total'] ?? 0), 2) ?>
<div style="margin-top:6px; color:var(--muted); font-size:12px; font-weight:500;">
Before fees <?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_gross'] ?? $order['total'] ?? 0), 2) ?>
· Fees <?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_fee'] ?? 0), 2) ?>
</div>
</div> </div>
<div class="store-order-status pill"><?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></div> <div class="store-order-status pill"><?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></div>

View File

@@ -1,18 +1,23 @@
<?php <?php
use Core\Services\Plugins;
$pageTitle = $title ?? 'Store Settings'; $pageTitle = $title ?? 'Store Settings';
$settings = $settings ?? []; $settings = $settings ?? [];
$gateways = is_array($gateways ?? null) ? $gateways : []; $gateways = is_array($gateways ?? null) ? $gateways : [];
$error = (string)($error ?? ''); $error = (string)($error ?? '');
$saved = (string)($saved ?? ''); $saved = (string)($saved ?? '');
$tab = (string)($tab ?? 'general'); $tab = (string)($tab ?? 'general');
$tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general'; $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'bundles', 'sales_chart'], true) ? $tab : 'general';
$paypalTest = (string)($_GET['paypal_test'] ?? ''); $paypalTest = (string)($_GET['paypal_test'] ?? '');
$privateRootReady = (bool)($private_root_ready ?? false); $privateRootReady = (bool)($private_root_ready ?? false);
$discounts = is_array($discounts ?? null) ? $discounts : []; $discounts = is_array($discounts ?? null) ? $discounts : [];
$bundles = is_array($bundles ?? null) ? $bundles : [];
$bundleReleaseOptions = is_array($bundle_release_options ?? null) ? $bundle_release_options : [];
$chartRows = is_array($chart_rows ?? null) ? $chart_rows : []; $chartRows = is_array($chart_rows ?? null) ? $chart_rows : [];
$chartLastRebuildAt = (string)($chart_last_rebuild_at ?? ''); $chartLastRebuildAt = (string)($chart_last_rebuild_at ?? '');
$chartCronUrl = (string)($chart_cron_url ?? ''); $chartCronUrl = (string)($chart_cron_url ?? '');
$chartCronCmd = (string)($chart_cron_cmd ?? ''); $chartCronCmd = (string)($chart_cron_cmd ?? '');
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
ob_start(); ob_start();
?> ?>
<section class="admin-card"> <section class="admin-card">
@@ -25,11 +30,22 @@ ob_start();
<a href="/admin/store" class="btn outline">Back</a> <a href="/admin/store" class="btn outline">Back</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 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>
<?php if ($reportsEnabled): ?>
<a href="/admin/store/reports" class="btn outline small">Sales Reports</a>
<?php endif; ?>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;"> <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=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=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=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=discounts" class="btn <?= $tab === 'discounts' ? '' : 'outline' ?> small">Discounts</a>
<a href="/admin/store/settings?tab=bundles" class="btn <?= $tab === 'bundles' ? '' : 'outline' ?> small">Bundles</a>
<a href="/admin/store/settings?tab=sales_chart" class="btn <?= $tab === 'sales_chart' ? '' : 'outline' ?> small">Sales Chart</a> <a href="/admin/store/settings?tab=sales_chart" class="btn <?= $tab === 'sales_chart' ? '' : 'outline' ?> small">Sales Chart</a>
</div> </div>
@@ -69,6 +85,22 @@ ob_start();
<div class="label" style="margin-top:12px;">Order Number Prefix</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"> <input class="input" name="store_order_prefix" value="<?= htmlspecialchars((string)($settings['store_order_prefix'] ?? 'AC-ORD'), ENT_QUOTES, 'UTF-8') ?>" placeholder="AC-ORD">
<div class="label" style="margin-top:12px;">Store Timezone</div>
<input class="input" name="store_timezone" list="store-timezone-options" value="<?= htmlspecialchars((string)($settings['store_timezone'] ?? 'UTC'), ENT_QUOTES, 'UTF-8') ?>" placeholder="UTC">
<datalist id="store-timezone-options">
<option value="UTC"></option>
<option value="Europe/London"></option>
<option value="Europe/Berlin"></option>
<option value="America/New_York"></option>
<option value="America/Chicago"></option>
<option value="America/Los_Angeles"></option>
<option value="Australia/Sydney"></option>
<option value="Asia/Tokyo"></option>
</datalist>
<div style="margin-top:8px; font-size:12px; color:var(--muted);">
Used for order numbers, store timestamps, and expiry calculations. Invalid values fall back to UTC.
</div>
</div> </div>
<div style="display:flex; justify-content:flex-end;"> <div style="display:flex; justify-content:flex-end;">
@@ -99,6 +131,62 @@ ob_start();
<div class="label" style="margin-top:10px;">PayPal Secret</div> <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') ?>"> <input class="input" name="store_paypal_secret" value="<?= htmlspecialchars((string)($settings['store_paypal_secret'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-top:12px;">
<div>
<div class="label">Merchant Country</div>
<input class="input" name="store_paypal_merchant_country" value="<?= htmlspecialchars((string)($settings['store_paypal_merchant_country'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="GB">
</div>
<div>
<div class="label">Card Button Label</div>
<input class="input" name="store_paypal_card_branding_text" value="<?= htmlspecialchars((string)($settings['store_paypal_card_branding_text'] ?? 'Pay with card'), ENT_QUOTES, 'UTF-8') ?>" placeholder="Pay with card">
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-top:12px;">
<div>
<div class="label">Card Checkout Mode</div>
<select class="input" name="store_paypal_sdk_mode">
<?php $sdkMode = (string)($settings['store_paypal_sdk_mode'] ?? 'embedded_fields'); ?>
<option value="embedded_fields" <?= $sdkMode === 'embedded_fields' ? 'selected' : '' ?>>Embedded card fields</option>
<option value="paypal_only_fallback" <?= $sdkMode === 'paypal_only_fallback' ? 'selected' : '' ?>>PayPal-only fallback</option>
</select>
</div>
<div style="display:flex; align-items:end;">
<div style="padding:12px 14px; border:1px solid rgba(255,255,255,.08); border-radius:12px; background:rgba(255,255,255,.03); width:100%;">
<input type="hidden" name="store_paypal_cards_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_cards_enabled" value="1" <?= ((string)($settings['store_paypal_cards_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
Enable Credit / Debit Card Checkout
</label>
</div>
</div>
</div>
<?php
$capabilityStatus = (string)($settings['store_paypal_cards_capability_status'] ?? 'unknown');
$capabilityMessage = (string)($settings['store_paypal_cards_capability_message'] ?? 'Run a PayPal credentials test to check card-field support.');
$capabilityCheckedAt = (string)($settings['store_paypal_cards_capability_checked_at'] ?? '');
$capabilityMode = (string)($settings['store_paypal_cards_capability_mode'] ?? '');
$capabilityColor = '#c7cfdf';
if ($capabilityStatus === 'available') {
$capabilityColor = '#9be7c6';
} elseif ($capabilityStatus === 'unavailable') {
$capabilityColor = '#f3b0b0';
}
?>
<div style="margin-top:12px; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03); display:grid; gap:6px;">
<div class="label" style="font-size:10px;">Card Capability</div>
<div style="font-weight:700; color:<?= htmlspecialchars($capabilityColor, ENT_QUOTES, 'UTF-8') ?>; text-transform:uppercase; letter-spacing:.12em;">
<?= htmlspecialchars($capabilityStatus !== '' ? $capabilityStatus : 'unknown', ENT_QUOTES, 'UTF-8') ?>
</div>
<div style="font-size:13px; color:var(--muted);"><?= htmlspecialchars($capabilityMessage, ENT_QUOTES, 'UTF-8') ?></div>
<?php if ($capabilityCheckedAt !== ''): ?>
<div style="font-size:12px; color:var(--muted);">
Last checked: <?= htmlspecialchars($capabilityCheckedAt, ENT_QUOTES, 'UTF-8') ?><?= $capabilityMode !== '' ? ' (' . htmlspecialchars($capabilityMode, ENT_QUOTES, 'UTF-8') . ')' : '' ?>
</div>
<?php endif; ?>
</div>
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;"> <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="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 outline small" type="submit" name="paypal_probe_mode" value="sandbox" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Sandbox</button>
@@ -214,6 +302,89 @@ ob_start();
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php elseif ($tab === 'bundles'): ?>
<div class="admin-card" style="margin-top:16px; padding:16px;">
<div class="label" style="margin-bottom:10px;">Create Bundle</div>
<form method="post" action="/admin/store/bundles/create" style="display:grid; gap:12px;">
<div style="display:grid; grid-template-columns:1.3fr .8fr .7fr .6fr; gap:10px;">
<div>
<div class="label" style="font-size:10px;">Bundle Name</div>
<input class="input" name="name" placeholder="Hard Dance Essentials" required>
</div>
<div>
<div class="label" style="font-size:10px;">Slug (optional)</div>
<input class="input" name="slug" placeholder="hard-dance-essentials">
</div>
<div>
<div class="label" style="font-size:10px;">Bundle Price</div>
<input class="input" name="bundle_price" value="9.99" required>
</div>
<div>
<div class="label" style="font-size:10px;">Currency</div>
<input class="input" name="currency" value="<?= htmlspecialchars((string)($settings['store_currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?>" maxlength="3">
</div>
</div>
<div style="display:grid; grid-template-columns:1fr auto; gap:10px; align-items:end;">
<div>
<div class="label" style="font-size:10px;">Button Label (optional)</div>
<input class="input" name="purchase_label" placeholder="Buy Discography">
</div>
<label style="display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.16em; padding-bottom:6px;">
<input type="checkbox" name="is_enabled" value="1" checked> Active
</label>
</div>
<div>
<div class="label" style="font-size:10px;">Releases in Bundle (Ctrl/Cmd-click for multi-select)</div>
<select class="input" name="release_ids[]" multiple size="8" required style="height:auto;">
<?php foreach ($bundleReleaseOptions as $opt): ?>
<option value="<?= (int)($opt['id'] ?? 0) ?>"><?= htmlspecialchars((string)($opt['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="display:flex; justify-content:flex-end;">
<button class="btn small" type="submit">Save Bundle</button>
</div>
</form>
</div>
<div class="admin-card" style="margin-top:12px; padding:14px;">
<div class="label" style="margin-bottom:10px;">Existing Bundles</div>
<?php if (!$bundles): ?>
<div style="color:var(--muted); font-size:13px;">No bundles 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;">Bundle</th>
<th style="padding:0 10px;">Slug</th>
<th style="padding:0 10px;">Releases</th>
<th style="padding:0 10px;">Price</th>
<th style="padding:0 10px;">Status</th>
<th style="padding:0 10px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($bundles as $b): ?>
<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)($b['name'] ?? ''), 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)($b['slug'] ?? ''), 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; color:var(--muted);"><?= (int)($b['release_count'] ?? 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;"><?= htmlspecialchars((string)($b['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($b['bundle_price'] ?? 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);"><span class="pill"><?= (int)($b['is_enabled'] ?? 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/bundles/delete" onsubmit="return confirm('Delete this bundle?');" style="display:inline-flex;">
<input type="hidden" name="id" value="<?= (int)($b['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: ?> <?php else: ?>
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;"> <form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
<input type="hidden" name="tab" value="sales_chart"> <input type="hidden" name="tab" value="sales_chart">

View File

@@ -21,6 +21,9 @@ ob_start();
$key = (string)($item['key'] ?? ''); $key = (string)($item['key'] ?? '');
$title = (string)($item['title'] ?? 'Item'); $title = (string)($item['title'] ?? 'Item');
$coverUrl = (string)($item['cover_url'] ?? ''); $coverUrl = (string)($item['cover_url'] ?? '');
$itemType = (string)($item['item_type'] ?? 'track');
$releaseCount = (int)($item['release_count'] ?? 0);
$trackCount = (int)($item['track_count'] ?? 0);
$qty = max(1, (int)($item['qty'] ?? 1)); $qty = max(1, (int)($item['qty'] ?? 1));
$price = (float)($item['price'] ?? 0); $price = (float)($item['price'] ?? 0);
$currency = (string)($item['currency'] ?? ($totals['currency'] ?? 'GBP')); $currency = (string)($item['currency'] ?? ($totals['currency'] ?? 'GBP'));
@@ -35,6 +38,18 @@ ob_start();
</div> </div>
<div style="min-width:0;"> <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-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
<?php if ($itemType === 'bundle' && ($releaseCount > 0 || $trackCount > 0)): ?>
<div style="font-size:12px; color:var(--muted); margin-top:4px;">
Includes
<?php if ($releaseCount > 0): ?>
<?= $releaseCount ?> release<?= $releaseCount === 1 ? '' : 's' ?>
<?php endif; ?>
<?php if ($releaseCount > 0 && $trackCount > 0): ?> · <?php endif; ?>
<?php if ($trackCount > 0): ?>
<?= $trackCount ?> track<?= $trackCount === 1 ? '' : 's' ?>
<?php endif; ?>
</div>
<?php endif; ?>
<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 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>
<div style="font-weight:700;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div> <div style="font-weight:700;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>

View File

@@ -15,13 +15,17 @@ $downloadLinks = is_array($download_links ?? null) ? $download_links : [];
$downloadNotice = (string)($download_notice ?? ''); $downloadNotice = (string)($download_notice ?? '');
$downloadLimit = (int)($download_limit ?? 5); $downloadLimit = (int)($download_limit ?? 5);
$downloadExpiryDays = (int)($download_expiry_days ?? 30); $downloadExpiryDays = (int)($download_expiry_days ?? 30);
$paypalEnabled = (bool)($paypal_enabled ?? false);
$paypalCardsEnabled = (bool)($paypal_cards_enabled ?? false);
$paypalCardsAvailable = (bool)($paypal_cards_available ?? false);
ob_start(); ob_start();
?> ?>
<section class="card checkout-wrap"> <section class="card checkout-wrap">
<div class="badge">Store</div> <div class="badge">Store</div>
<h1 style="margin:0; font-size:32px;">Checkout</h1> <h1 style="margin:0; font-size:32px;">Checkout</h1>
<?php if ($success !== ''): ?> <?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 class="checkout-status checkout-status-success">
<div style="font-weight:700;">Order complete</div> <div style="font-weight:700;">Order complete</div>
<?php if ($orderNo !== ''): ?> <?php if ($orderNo !== ''): ?>
<div style="margin-top:4px; color:var(--muted);">Order: <?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div> <div style="margin-top:4px; color:var(--muted);">Order: <?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
@@ -52,15 +56,13 @@ ob_start();
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($error !== ''): ?> <?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;"> <div class="checkout-status checkout-status-error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php if (!$items): ?> <?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);"> <div class="checkout-status checkout-status-empty">Your cart is empty.</div>
Your cart is empty.
</div>
<div><a href="/releases" class="btn">Browse releases</a></div> <div><a href="/releases" class="btn">Browse releases</a></div>
<?php else: ?> <?php else: ?>
<div class="checkout-grid"> <div class="checkout-grid">
@@ -70,12 +72,23 @@ ob_start();
<?php foreach ($items as $item): ?> <?php foreach ($items as $item): ?>
<?php <?php
$title = (string)($item['title'] ?? 'Item'); $title = (string)($item['title'] ?? 'Item');
$itemType = (string)($item['item_type'] ?? 'track');
$releaseCount = (int)($item['release_count'] ?? 0);
$trackCount = (int)($item['track_count'] ?? 0);
$qty = max(1, (int)($item['qty'] ?? 1)); $qty = max(1, (int)($item['qty'] ?? 1));
$price = (float)($item['price'] ?? 0); $price = (float)($item['price'] ?? 0);
$lineCurrency = (string)($item['currency'] ?? $currency); $lineCurrency = (string)($item['currency'] ?? $currency);
?> ?>
<div class="checkout-line"> <div class="checkout-line">
<div class="checkout-line-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div> <div class="checkout-line-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
<?php if ($itemType === 'bundle' && ($releaseCount > 0 || $trackCount > 0)): ?>
<div class="checkout-line-meta">
Includes
<?php if ($releaseCount > 0): ?><?= $releaseCount ?> release<?= $releaseCount === 1 ? '' : 's' ?><?php endif; ?>
<?php if ($releaseCount > 0 && $trackCount > 0): ?> &middot; <?php endif; ?>
<?php if ($trackCount > 0): ?><?= $trackCount ?> track<?= $trackCount === 1 ? '' : 's' ?><?php endif; ?>
</div>
<?php endif; ?>
<div class="checkout-line-meta"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price, 2) ?> x <?= $qty ?></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 class="checkout-line-total"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
</div> </div>
@@ -99,23 +112,39 @@ ob_start();
<div class="checkout-panel"> <div class="checkout-panel">
<div class="badge" style="font-size:9px;">Buyer Details</div> <div class="badge" style="font-size:9px;">Buyer Details</div>
<form method="post" action="/checkout/place" style="display:grid; gap:12px; margin-top:10px;"> <form method="post" action="/checkout/place" class="checkout-form-stack" id="checkoutMethodForm">
<label style="font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em;">Email</label> <label class="checkout-label" for="checkoutEmail">Email</label>
<input name="email" type="email" value="" placeholder="you@example.com" class="checkout-input" required> <input id="checkoutEmail" name="email" type="email" value="" placeholder="you@example.com" class="checkout-input" required>
<div class="checkout-terms"> <div class="checkout-terms">
<div class="badge" style="font-size:9px;">Terms</div> <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;"> <p class="checkout-copy">
Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order. 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. Files are limited to <?= $downloadLimit ?> downloads and expire after <?= $downloadExpiryDays ?> days.
</p> </p>
<label style="margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px;"> <label class="checkout-terms-check">
<input type="checkbox" name="accept_terms" value="1" required style="margin-top:2px;"> <input id="checkoutTerms" type="checkbox" name="accept_terms" value="1" required>
<span>I agree to the terms and understand all sales are final.</span> <span>I agree to the terms and understand all sales are final.</span>
</label> </label>
</div> </div>
<button type="submit" class="checkout-place-btn">Place Order</button> <div class="checkout-payment-chooser">
<div class="checkout-payment-option">
<div class="badge" style="font-size:9px;">PayPal</div>
<div class="checkout-payment-title">Pay via PayPal</div>
<div class="checkout-copy">You will be redirected to PayPal to approve the payment.</div>
<button type="submit" class="checkout-place-btn"<?= $paypalEnabled ? '' : ' disabled' ?>>Pay via PayPal</button>
</div>
<?php if ($paypalCardsEnabled && $paypalCardsAvailable): ?>
<div class="checkout-payment-option">
<div class="badge" style="font-size:9px;">Cards</div>
<div class="checkout-payment-title">Pay via Credit / Debit Card</div>
<div class="checkout-copy">Open a dedicated secure card-payment page powered by PayPal.</div>
<button type="button" class="checkout-secondary-btn" id="checkoutCardStartBtn">Continue to card payment</button>
</div>
<?php endif; ?>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -123,86 +152,91 @@ ob_start();
</section> </section>
<style> <style>
.checkout-wrap { display:grid; gap:14px; } .checkout-wrap { display:grid; gap:14px; }
.checkout-grid { display:grid; grid-template-columns: minmax(0,1fr) 420px; gap:14px; } .checkout-grid { display:grid; grid-template-columns:minmax(0,1fr) 460px; gap:14px; }
.checkout-panel { .checkout-panel { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); }
padding:14px; .checkout-status { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); }
border-radius:12px; .checkout-status-success { border-color:rgba(34,242,165,.4); background:rgba(34,242,165,.12); }
border:1px solid rgba(255,255,255,.1); .checkout-status-error { border-color:rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6; }
background:rgba(0,0,0,.2); .checkout-status-empty { background:rgba(0,0,0,.2); color:var(--muted); }
} .checkout-form-stack { display:grid; gap:12px; margin-top:10px; }
.checkout-line { .checkout-label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em; }
display:grid; .checkout-input { height:44px; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#fff; padding:0 14px; }
grid-template-columns:minmax(0,1fr) auto; .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); }
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-title { font-weight:600; }
.checkout-line-meta { color:var(--muted); font-size:12px; margin-top:4px; grid-column:1/2; } .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-line-total { font-weight:700; grid-column:2/3; grid-row:1/3; align-self:center; }
.checkout-total { .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); }
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-total strong { font-size:22px; }
.checkout-input { .checkout-terms { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
height:40px; .checkout-copy { margin:8px 0 0; color:var(--muted); font-size:13px; line-height:1.5; }
border-radius:10px; .checkout-terms-check { margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px; }
border:1px solid rgba(255,255,255,.2); .checkout-payment-chooser { display:grid; gap:12px; }
background:rgba(255,255,255,.05); .checkout-payment-option { display:grid; gap:8px; padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
color:#fff; .checkout-payment-title { font-size:18px; font-weight:700; margin-top:4px; }
padding:0 12px; .checkout-place-btn, .checkout-secondary-btn { height:44px; border-radius:999px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; cursor:pointer; }
} .checkout-place-btn { border:1px solid rgba(34,242,165,.45); background:rgba(34,242,165,.18); color:#cbfff1; }
.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-place-btn:hover { background:rgba(34,242,165,.28); }
.checkout-download-link { .checkout-secondary-btn { border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#eef3ff; }
display:flex; .checkout-secondary-btn:hover { background:rgba(255,255,255,.1); }
align-items:center; .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; }
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:hover { background:rgba(34,242,165,.18); }
.checkout-download-link-arrow { .checkout-download-link-arrow { font-size:11px; text-transform:uppercase; letter-spacing:.14em; color:#8df7d1; }
font-size:11px; @media (max-width: 900px) { .checkout-grid { grid-template-columns:1fr; } }
text-transform:uppercase;
letter-spacing:.14em;
color:#8df7d1;
}
@media (max-width: 900px) {
.checkout-grid { grid-template-columns: 1fr; }
}
</style> </style>
<script>
(function () {
var cardBtn = document.getElementById('checkoutCardStartBtn');
var form = document.getElementById('checkoutMethodForm');
var email = document.getElementById('checkoutEmail');
var terms = document.getElementById('checkoutTerms');
if (!cardBtn || !form || !email || !terms) {
return;
}
cardBtn.addEventListener('click', function () {
if (!email.reportValidity()) {
return;
}
if (!terms.checked) {
terms.reportValidity();
return;
}
var tmp = document.createElement('form');
tmp.method = 'post';
tmp.action = '/checkout/card/start';
tmp.style.display = 'none';
var emailInput = document.createElement('input');
emailInput.type = 'hidden';
emailInput.name = 'email';
emailInput.value = email.value;
tmp.appendChild(emailInput);
var termsInput = document.createElement('input');
termsInput.type = 'hidden';
termsInput.name = 'accept_terms';
termsInput.value = '1';
tmp.appendChild(termsInput);
var methodInput = document.createElement('input');
methodInput.type = 'hidden';
methodInput.name = 'checkout_method';
methodInput.value = 'card';
tmp.appendChild(methodInput);
var csrfMeta = document.querySelector('meta[name=\"csrf-token\"]');
if (csrfMeta && csrfMeta.content) {
var csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfMeta.content;
tmp.appendChild(csrfInput);
}
document.body.appendChild(tmp);
tmp.submit();
});
})();
</script>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php'; require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
$pageTitle = $title ?? 'Card 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');
$email = (string)($email ?? '');
$acceptTerms = (bool)($accept_terms ?? false);
$downloadLimit = (int)($download_limit ?? 5);
$downloadExpiryDays = (int)($download_expiry_days ?? 30);
$paypalClientId = trim((string)($paypal_client_id ?? ''));
$paypalClientToken = trim((string)($paypal_client_token ?? ''));
$paypalMerchantCountry = strtoupper(trim((string)($paypal_merchant_country ?? '')));
$paypalCardBrandingText = trim((string)($paypal_card_branding_text ?? 'Pay with card'));
$sdkUrl = 'https://www.paypal.com/sdk/js?client-id=' . rawurlencode($paypalClientId)
. '&currency=' . rawurlencode($currency)
. '&intent=capture'
. '&components=buttons,card-fields';
ob_start();
?>
<section class="card checkout-wrap">
<div class="badge">Cards</div>
<div class="checkout-card-header">
<div>
<h1 style="margin:0; font-size:32px;">Credit / Debit Card</h1>
<p class="checkout-card-copy">Secure card payment powered by PayPal. The order completes immediately after capture succeeds.</p>
</div>
<a href="/checkout" class="btn outline">Back to checkout</a>
</div>
<div class="checkout-grid">
<div class="checkout-panel">
<div class="badge" style="font-size:9px;">Buyer Details</div>
<div class="checkout-form-stack">
<label class="checkout-label" for="cardCheckoutEmail">Email</label>
<input id="cardCheckoutEmail" type="email" value="<?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?>" placeholder="you@example.com" class="checkout-input" required>
<div class="checkout-terms">
<div class="badge" style="font-size:9px;">Terms</div>
<p class="checkout-card-copy">
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 class="checkout-terms-check">
<input id="cardCheckoutTerms" type="checkbox" value="1" <?= $acceptTerms ? 'checked' : '' ?>>
<span>I agree to the terms and understand all sales are final.</span>
</label>
</div>
<div id="cardCheckoutStatus" class="checkout-inline-status" hidden></div>
<div class="checkout-card-form">
<div class="checkout-card-field">
<label class="checkout-label">Cardholder name</label>
<div id="paypal-name-field" class="checkout-card-shell"></div>
</div>
<div class="checkout-card-field checkout-card-field-full">
<label class="checkout-label">Card number</label>
<div id="paypal-number-field" class="checkout-card-shell"></div>
</div>
<div class="checkout-card-field">
<label class="checkout-label">Expiry</label>
<div id="paypal-expiry-field" class="checkout-card-shell"></div>
</div>
<div class="checkout-card-field">
<label class="checkout-label">Security code</label>
<div id="paypal-cvv-field" class="checkout-card-shell"></div>
</div>
</div>
<button type="button" id="paypalCardSubmit" class="checkout-place-btn"><?= htmlspecialchars($paypalCardBrandingText !== '' ? $paypalCardBrandingText : 'Pay with card', ENT_QUOTES, 'UTF-8') ?></button>
</div>
</div>
<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>
</section>
<style>
.checkout-wrap { display:grid; gap:14px; }
.checkout-card-header { display:flex; align-items:start; justify-content:space-between; gap:16px; }
.checkout-grid { display:grid; grid-template-columns:minmax(0, 1.1fr) 420px; gap:14px; }
.checkout-panel { padding:16px; border-radius:14px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); }
.checkout-form-stack { display:grid; gap:12px; margin-top:10px; }
.checkout-label { font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em; }
.checkout-input { height:46px; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#fff; padding:0 14px; }
.checkout-terms { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
.checkout-card-copy { margin:8px 0 0; color:var(--muted); font-size:13px; line-height:1.55; }
.checkout-terms-check { margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px; }
.checkout-inline-status { padding:12px 14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); font-size:13px; }
.checkout-inline-status.error { border-color:rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6; }
.checkout-inline-status.info { border-color:rgba(255,255,255,.12); background:rgba(255,255,255,.04); color:#d7def2; }
.checkout-card-form { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; padding:12px; border-radius:16px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.02); }
.checkout-card-field-full { grid-column:1 / -1; }
.checkout-card-field { display:grid; gap:6px; }
.checkout-card-shell { min-height:44px; border-radius:12px; border:0; background:transparent; box-shadow:none; padding:0; display:flex; align-items:center; }
.checkout-card-shell iframe { width:100% !important; min-height:40px !important; border-radius:10px !important; }
.checkout-place-btn { height:48px; 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; margin-top:4px; }
.checkout-place-btn:hover { background:rgba(34,242,165,.28); }
.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; }
@media (max-width: 900px) {
.checkout-card-header { flex-direction:column; align-items:stretch; }
.checkout-grid { grid-template-columns:1fr; }
.checkout-card-form { grid-template-columns:1fr; }
.checkout-card-field-full { grid-column:auto; }
}
</style>
<script src="<?= htmlspecialchars($sdkUrl, ENT_QUOTES, 'UTF-8') ?>" data-client-token="<?= htmlspecialchars($paypalClientToken, ENT_QUOTES, 'UTF-8') ?>" data-sdk-integration-source="audiocore"></script>
<script>
(function () {
var emailEl = document.getElementById('cardCheckoutEmail');
var termsEl = document.getElementById('cardCheckoutTerms');
var statusEl = document.getElementById('cardCheckoutStatus');
var submitBtn = document.getElementById('paypalCardSubmit');
function setStatus(type, message) {
if (!statusEl) return;
if (!message) {
statusEl.hidden = true;
statusEl.textContent = '';
statusEl.className = 'checkout-inline-status';
return;
}
statusEl.hidden = false;
statusEl.textContent = message;
statusEl.className = 'checkout-inline-status ' + type;
}
function validateBuyer() {
var email = emailEl ? emailEl.value.trim() : '';
if (!email) {
setStatus('error', 'Enter your email address.');
return null;
}
if (!termsEl || !termsEl.checked) {
setStatus('error', 'Accept the terms to continue.');
return null;
}
setStatus('', '');
return { email: email, accept_terms: true };
}
function postJson(url, payload) {
var csrfMeta = document.querySelector('meta[name=\"csrf-token\"]');
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-Token': csrfMeta ? csrfMeta.content : '' },
body: JSON.stringify(payload)
}).then(function (response) {
return response.json().catch(function () {
return { ok: false, error: 'Unexpected server response.' };
});
});
}
function createOrder() {
var buyer = validateBuyer();
if (!buyer) {
return Promise.reject(new Error('Validation failed.'));
}
setStatus('info', 'Preparing secure card payment...');
return postJson('/checkout/paypal/create-order', buyer).then(function (data) {
if (!data || data.ok !== true) {
throw new Error((data && data.error) || 'Unable to start checkout.');
}
if (data.completed && data.redirect) {
window.location.href = data.redirect;
throw new Error('redirect');
}
return data.orderID || data.paypal_order_id;
});
}
function captureOrder(orderID) {
setStatus('info', 'Finalizing payment...');
return postJson('/checkout/paypal/capture-order', { orderID: orderID }).then(function (data) {
if (!data || data.ok !== true) {
throw new Error((data && data.error) || 'Unable to finalize payment.');
}
if (data.redirect) {
window.location.href = data.redirect;
}
});
}
function initCardFields() {
if (!(window.paypal && paypal.CardFields)) {
setStatus('error', 'PayPal card fields failed to load. Client token present: <?= $paypalClientToken !== '' ? 'yes' : 'no' ?>. Capability requires Advanced Card Payments on the PayPal account.');
if (submitBtn) submitBtn.disabled = true;
return;
}
var cardFields = paypal.CardFields({
style: {
'input': {
'appearance': 'none',
'background': '#151922',
'color': '#eef3ff',
'font-family': 'Syne, sans-serif',
'font-size': '17px',
'line-height': '24px',
'padding': '12px 14px',
'border': '1px solid rgba(255,255,255,0.10)',
'border-radius': '10px',
'box-shadow': 'none',
'outline': 'none',
'-webkit-appearance': 'none'
},
'input::placeholder': {
'color': 'rgba(238,243,255,0.42)'
},
'input:hover': {
'border': '1px solid rgba(255,255,255,0.18)'
},
'input:focus': {
'border': '1px solid #22f2a5',
'box-shadow': '0 0 0 2px rgba(34,242,165,0.12)'
},
'.valid': {
'color': '#eef3ff'
},
'.invalid': {
'color': '#ffd6d6',
'border': '1px solid rgba(255,107,107,0.72)',
'box-shadow': '0 0 0 2px rgba(255,107,107,0.10)'
}
},
createOrder: function () {
return createOrder();
},
onApprove: function (data) {
return captureOrder(data.orderID);
},
onError: function (err) {
setStatus('error', err && err.message ? err.message : 'Card payment failed.');
}
});
if (!cardFields.isEligible()) {
setStatus('error', 'Card checkout is not available for this account.');
if (submitBtn) submitBtn.disabled = true;
return;
}
cardFields.NameField().render('#paypal-name-field');
cardFields.NumberField().render('#paypal-number-field');
cardFields.ExpiryField().render('#paypal-expiry-field');
cardFields.CVVField().render('#paypal-cvv-field');
if (submitBtn) {
submitBtn.addEventListener('click', function () {
if (!validateBuyer()) {
return;
}
setStatus('info', 'Submitting card payment...');
cardFields.submit({});
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCardFields);
} else {
initCardFields();
}
}());
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../../../../views/site/layout.php';

View File

@@ -7,6 +7,7 @@ use Core\Http\Response;
use Core\Services\Auth; use Core\Services\Auth;
use Core\Services\Database; use Core\Services\Database;
use Core\Services\Mailer; use Core\Services\Mailer;
use Core\Services\RateLimiter;
use Core\Services\Settings; use Core\Services\Settings;
use Core\Views\View; use Core\Views\View;
use PDO; use PDO;
@@ -64,6 +65,10 @@ class SupportController
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return new Response('', 302, ['Location' => '/contact?error=Please+enter+a+valid+email']); return new Response('', 302, ['Location' => '/contact?error=Please+enter+a+valid+email']);
} }
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
if (RateLimiter::tooMany('support_contact_submit', $limitKey, 5, 600)) {
return new Response('', 302, ['Location' => '/contact?error=Too+many+support+requests.+Please+wait+10+minutes']);
}
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (($extraValues[(string)$requiredField] ?? '') === '') { if (($extraValues[(string)$requiredField] ?? '') === '') {
return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]); return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]);

View File

@@ -3,6 +3,10 @@ declare(strict_types=1);
require_once __DIR__ . '/../core/bootstrap.php'; require_once __DIR__ . '/../core/bootstrap.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$uriPath = is_string($uriPath) && $uriPath !== '' ? $uriPath : '/'; $uriPath = is_string($uriPath) && $uriPath !== '' ? $uriPath : '/';
$isAdminRoute = str_starts_with($uriPath, '/admin'); $isAdminRoute = str_starts_with($uriPath, '/admin');
@@ -16,12 +20,33 @@ if (
&& !$isAdminRoute && !$isAdminRoute
&& !in_array($uriPath, $maintenanceWhitelist, true) && !in_array($uriPath, $maintenanceWhitelist, true)
) { ) {
$maintenancePasswordHash = (string)Core\Services\Settings::get('site_maintenance_access_password_hash', '');
$maintenanceBypassKey = $maintenancePasswordHash !== '' ? hash('sha256', $maintenancePasswordHash) : '';
$hasMaintenanceBypass = $maintenanceBypassKey !== ''
&& (string)($_SESSION['ac_maintenance_bypass'] ?? '') === $maintenanceBypassKey;
$passwordError = '';
if ($maintenancePasswordHash !== '' && !$hasMaintenanceBypass && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$submittedPassword = (string)($_POST['ac_maintenance_access_password'] ?? '');
if ($submittedPassword !== '' && password_verify($submittedPassword, $maintenancePasswordHash)) {
$_SESSION['ac_maintenance_bypass'] = $maintenanceBypassKey;
$redirectTo = (string)($_SERVER['REQUEST_URI'] ?? '/');
(new Core\Http\Response('', 302, ['Location' => $redirectTo !== '' ? $redirectTo : '/']))->send();
exit;
}
$passwordError = 'Incorrect access password.';
}
if ($hasMaintenanceBypass) {
goto maintenance_bypass_complete;
}
$title = Core\Services\Settings::get('site_maintenance_title', 'Coming Soon'); $title = Core\Services\Settings::get('site_maintenance_title', 'Coming Soon');
$message = Core\Services\Settings::get('site_maintenance_message', 'We are currently updating the site. Please check back soon.'); $message = Core\Services\Settings::get('site_maintenance_message', 'We are currently updating the site. Please check back soon.');
$buttonLabel = Core\Services\Settings::get('site_maintenance_button_label', ''); $buttonLabel = Core\Services\Settings::get('site_maintenance_button_label', '');
$buttonUrl = Core\Services\Settings::get('site_maintenance_button_url', ''); $buttonUrl = Core\Services\Settings::get('site_maintenance_button_url', '');
$customHtml = Core\Services\Settings::get('site_maintenance_html', ''); $customHtml = Core\Services\Settings::get('site_maintenance_html', '');
$siteTitle = Core\Services\Settings::get('site_title', 'AudioCore V1.5'); $siteTitle = Core\Services\Settings::get('site_title', 'AudioCore V1.5.1');
$contentHtml = ''; $contentHtml = '';
if ($customHtml !== '') { if ($customHtml !== '') {
@@ -36,6 +61,18 @@ if (
. htmlspecialchars($buttonLabel, ENT_QUOTES, 'UTF-8') . htmlspecialchars($buttonLabel, ENT_QUOTES, 'UTF-8')
. '</a>'; . '</a>';
} }
if ($maintenancePasswordHash !== '') {
$contentHtml .= '<form method="post" class="ac-maintenance-form">'
. '<label class="ac-maintenance-label" for="acMaintenancePassword">Access Password</label>'
. '<div class="ac-maintenance-form-row">'
. '<input id="acMaintenancePassword" class="ac-maintenance-input" type="password" name="ac_maintenance_access_password" placeholder="Enter access password" autocomplete="current-password">'
. '<button class="ac-maintenance-submit" type="submit">Unlock Site</button>'
. '</div>';
if ($passwordError !== '') {
$contentHtml .= '<div class="ac-maintenance-error">' . htmlspecialchars($passwordError, ENT_QUOTES, 'UTF-8') . '</div>';
}
$contentHtml .= '</form>';
}
$contentHtml .= '</div>'; $contentHtml .= '</div>';
} }
@@ -50,12 +87,20 @@ if (
. 'p{font-size:18px;line-height:1.7;margin:16px 0 0;color:rgba(235,241,255,.8);}' . 'p{font-size:18px;line-height:1.7;margin:16px 0 0;color:rgba(235,241,255,.8);}'
. '.ac-maintenance-btn{margin-top:20px;display:inline-block;padding:10px 18px;border-radius:999px;border:1px solid rgba(255,255,255,.2);color:#f7f8ff;text-decoration:none;font-size:12px;text-transform:uppercase;letter-spacing:.18em;}' . '.ac-maintenance-btn{margin-top:20px;display:inline-block;padding:10px 18px;border-radius:999px;border:1px solid rgba(255,255,255,.2);color:#f7f8ff;text-decoration:none;font-size:12px;text-transform:uppercase;letter-spacing:.18em;}'
. '.ac-maintenance-btn:hover{background:rgba(255,255,255,.08);}' . '.ac-maintenance-btn:hover{background:rgba(255,255,255,.08);}'
. '.ac-maintenance-form{margin-top:24px;padding-top:20px;border-top:1px solid rgba(255,255,255,.12);}'
. '.ac-maintenance-label{display:block;font-family:IBM Plex Mono,monospace;text-transform:uppercase;font-size:11px;letter-spacing:.24em;color:rgba(255,255,255,.7);margin:0 0 10px;}'
. '.ac-maintenance-form-row{display:flex;gap:12px;flex-wrap:wrap;}'
. '.ac-maintenance-input{flex:1 1 260px;min-width:220px;padding:14px 16px;border-radius:14px;border:1px solid rgba(255,255,255,.16);background:rgba(9,11,16,.75);color:#eef2ff;font:inherit;}'
. '.ac-maintenance-submit{padding:14px 18px;border-radius:14px;border:1px solid rgba(34,242,165,.45);background:rgba(34,242,165,.16);color:#effff8;font-family:IBM Plex Mono,monospace;font-size:12px;text-transform:uppercase;letter-spacing:.18em;cursor:pointer;}'
. '.ac-maintenance-error{margin-top:10px;color:#ffb4b4;font-size:14px;}'
. '</style></head><body>' . $contentHtml . '</body></html>'; . '</style></head><body>' . $contentHtml . '</body></html>';
(new Core\Http\Response($maintenanceHtml, 503, ['Content-Type' => 'text/html; charset=utf-8']))->send(); (new Core\Http\Response($maintenanceHtml, 503, ['Content-Type' => 'text/html; charset=utf-8']))->send();
exit; exit;
} }
maintenance_bypass_complete:
$router = new Core\Http\Router(); $router = new Core\Http\Router();
$router->get('/', function (): Core\Http\Response { $router->get('/', function (): Core\Http\Response {
$db = Core\Services\Database::get(); $db = Core\Services\Database::get();
@@ -76,7 +121,7 @@ $router->get('/', function (): Core\Http\Response {
} }
$view = new Core\Views\View(__DIR__ . '/../views'); $view = new Core\Views\View(__DIR__ . '/../views');
return new Core\Http\Response($view->render('site/home.php', [ return new Core\Http\Response($view->render('site/home.php', [
'title' => 'AudioCore V1.5', 'title' => 'AudioCore V1.5.1',
])); ]));
}); });
$router->registerModules(__DIR__ . '/../modules'); $router->registerModules(__DIR__ . '/../modules');

0
storage/cache/.gitkeep vendored Normal file
View File

View File

@@ -1,16 +1,10 @@
{ {
"channels": { "channels": {
"stable": { "stable": {
"version": "1.5.0", "version": "1.5.1",
"download_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/archive/main.zip", "download_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/archive/v1.5.1.zip",
"changelog_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/commits/branch/main", "changelog_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/releases/tag/v1.5.1",
"notes": "Initial public v1.5.0 release" "notes": "AudioCore v1.5.1"
},
"beta": {
"version": "1.5.0-beta.1",
"download_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/archive/main.zip",
"changelog_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/commits/branch/main",
"notes": "Beta preview channel"
} }
} }
} }

0
views/admin/.gitkeep Normal file
View File

View File

@@ -26,7 +26,7 @@ if (!is_array($footerLinks)) {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?= htmlspecialchars(Core\Services\Settings::get('footer_text', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars(Core\Services\Settings::get('footer_text', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>
- <?= date('Y') ?> - <?= date('Y') ?>
</div> </div>
</footer> </footer>

View File

@@ -48,7 +48,7 @@ if ($storeEnabled) {
} }
} }
$headerTitle = Settings::get('site_header_title', 'AudioCore V1.5'); $headerTitle = Settings::get('site_header_title', 'AudioCore V1.5.1');
$headerTagline = Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'); $headerTagline = Settings::get('site_header_tagline', 'Core CMS for DJs & Producers');
$headerBadgeText = Settings::get('site_header_badge_text', 'Independent catalog'); $headerBadgeText = Settings::get('site_header_badge_text', 'Independent catalog');
$headerBrandMode = Settings::get('site_header_brand_mode', 'default'); $headerBrandMode = Settings::get('site_header_brand_mode', 'default');
@@ -74,7 +74,7 @@ if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') {
$effectiveMarkMode = 'icon'; $effectiveMarkMode = 'icon';
} }
?> ?>
<header class="shell"> <header class="shell site-header-shell">
<div class="card"> <div class="card">
<div class="site-head"> <div class="site-head">
<?php if ($headerBrandMode === 'logo_only' && $headerLogoUrl !== ''): ?> <?php if ($headerBrandMode === 'logo_only' && $headerLogoUrl !== ''): ?>
@@ -114,8 +114,12 @@ if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') {
<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($storeEnabled): ?>
<div class="nav-actions"> <div class="nav-actions">
<form method="get" action="/releases" class="nav-search-form" role="search">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<input type="text" name="q" placeholder="Search music" value="<?= htmlspecialchars((string)($_GET['q'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
</form>
<?php if ($storeEnabled): ?>
<?php if (!$hasAccountLink): ?> <?php if (!$hasAccountLink): ?>
<a class="nav-account" href="/account"> <a class="nav-account" href="/account">
<i class="fa-solid fa-user" aria-hidden="true"></i> <i class="fa-solid fa-user" aria-hidden="true"></i>
@@ -127,10 +131,10 @@ if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') {
<span><?= $cartCount ?></span> <span><?= $cartCount ?></span>
<span><?= number_format($cartTotal, 2) ?></span> <span><?= number_format($cartTotal, 2) ?></span>
</a> </a>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div>
</header> </header>
<script> <script>
(function () { (function () {

View File

@@ -1,10 +1,10 @@
<?php <?php
$pageTitle = 'AudioCore V1.5'; $pageTitle = 'AudioCore V1.5.1';
ob_start(); ob_start();
?> ?>
<section class="card"> <section class="card">
<div class="badge">Foundation</div> <div class="badge">Foundation</div>
<h1 style="margin-top:16px; font-size:32px;">AudioCore V1.5</h1> <h1 style="margin-top:16px; font-size:32px;">AudioCore V1.5.1</h1>
<p style="color:var(--muted); font-size:16px; max-width:680px;"> <p style="color:var(--muted); font-size:16px; max-width:680px;">
New core scaffold. Modules will live under /modules and admin will manage navigation. New core scaffold. Modules will live under /modules and admin will manage navigation.
</p> </p>

View File

@@ -7,12 +7,14 @@ $faUrl = Settings::get('fontawesome_pro_url', '');
if ($faUrl === '') { if ($faUrl === '') {
$faUrl = Settings::get('fontawesome_url', ''); $faUrl = Settings::get('fontawesome_url', '');
} }
$siteTitleSetting = Settings::get('site_title', Settings::get('site_header_title', 'AudioCore V1.5')); $siteTitleSetting = Settings::get('site_title', Settings::get('site_header_title', 'AudioCore V1.5.1'));
$seoTitleSuffix = trim(Settings::get('seo_title_suffix', $siteTitleSetting)); $seoTitleSuffix = trim(Settings::get('seo_title_suffix', $siteTitleSetting));
$seoDefaultDescription = trim(Settings::get('seo_meta_description', '')); $seoDefaultDescription = trim(Settings::get('seo_meta_description', ''));
$seoRobotsIndex = Settings::get('seo_robots_index', '1') === '1' ? 'index' : 'noindex'; $seoRobotsIndex = Settings::get('seo_robots_index', '1') === '1' ? 'index' : 'noindex';
$seoRobotsFollow = Settings::get('seo_robots_follow', '1') === '1' ? 'follow' : 'nofollow'; $seoRobotsFollow = Settings::get('seo_robots_follow', '1') === '1' ? 'follow' : 'nofollow';
$seoOgImage = trim(Settings::get('seo_og_image', '')); $seoOgImage = trim(Settings::get('seo_og_image', ''));
$siteCustomCssRaw = (string)Settings::get('site_custom_css', '');
$siteCustomCss = preg_replace('~<\s*/?\s*style\b[^>]*>~i', '', $siteCustomCssRaw) ?? $siteCustomCssRaw;
$pageTitleValue = trim((string)($pageTitle ?? $siteTitleSetting)); $pageTitleValue = trim((string)($pageTitle ?? $siteTitleSetting));
$metaTitle = $pageTitleValue; $metaTitle = $pageTitleValue;
if ($seoTitleSuffix !== '' && stripos($pageTitleValue, $seoTitleSuffix) === false) { if ($seoTitleSuffix !== '' && stripos($pageTitleValue, $seoTitleSuffix) === false) {
@@ -28,6 +30,7 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<?= csrf_meta() ?>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($metaTitle, ENT_QUOTES, 'UTF-8') ?></title> <title><?= htmlspecialchars($metaTitle, ENT_QUOTES, 'UTF-8') ?></title>
<?php if ($seoDefaultDescription !== ''): ?> <?php if ($seoDefaultDescription !== ''): ?>
@@ -65,7 +68,7 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
body { body {
margin: 0; margin: 0;
font-family: 'Syne', sans-serif; font-family: 'Syne', sans-serif;
background-color: #14151a; background-color: #0f1115;
color: var(--text); color: var(--text);
position: relative; position: relative;
min-height: 100vh; min-height: 100vh;
@@ -76,9 +79,8 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
inset: 0; inset: 0;
z-index: -2; z-index: -2;
background: background:
radial-gradient(120% 70% at 50% 12%, rgba(68, 94, 155, 0.12), transparent 62%), radial-gradient(120% 75% at 50% -10%, rgba(38, 78, 138, 0.16), transparent 55%),
radial-gradient(140% 90% at 50% 80%, rgba(12, 16, 26, 0.65), transparent 70%), linear-gradient(180deg, #13161d 0%, #0f1116 45%, #0d1014 100%);
linear-gradient(180deg, #1a1b1f 0%, #15161b 55%, #111217 100%);
} }
body::after { body::after {
content: ''; content: '';
@@ -89,7 +91,14 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
pointer-events: none; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
} }
.shell { max-width: 1080px; margin: 0 auto; padding: 18px 24px 28px; } .shell { max-width: 1400px; margin: 0 auto; padding: 16px 24px 24px; }
.site-header-shell {
position: sticky;
top: 0;
z-index: 40;
padding-top: 10px;
backdrop-filter: blur(10px);
}
.shell-main { padding-top: 8px; } .shell-main { padding-top: 8px; }
.shell-notice { padding-top: 2px; padding-bottom: 4px; } .shell-notice { padding-top: 2px; padding-bottom: 4px; }
.site-notice { .site-notice {
@@ -140,8 +149,8 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
border-radius: 24px; border-radius: 24px;
background: rgba(20, 22, 28, 0.75); background: rgba(20, 22, 28, 0.75);
border: 1px solid rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 20px 50px rgba(0,0,0,0.3); box-shadow: 0 18px 42px rgba(0,0,0,0.28);
padding: 28px; padding: 22px;
} }
.nav { .nav {
display: flex; display: flex;
@@ -169,6 +178,30 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.nav-search-form {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(8,12,18,0.72);
color: var(--muted);
padding: 0 10px;
min-width: 220px;
}
.nav-search-form input {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
}
.nav-search-form input::placeholder {
color: rgba(255,255,255,0.45);
}
.nav-account { .nav-account {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -281,20 +314,81 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
.ac-shortcode-releases,
.ac-shortcode-artists,
.ac-shortcode-sale-chart,
.ac-shortcode-newsletter-form {
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.02);
border-radius: 14px;
padding: 12px;
}
.ac-shortcode-block-head {
font-family: 'IBM Plex Mono', monospace;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.22em;
color: rgba(255,255,255,0.58);
margin: 2px 0 10px;
}
.page-content {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
gap: 14px;
align-items: start;
}
.page-content > .ac-shortcode-hero,
.page-content > .ac-shortcode-releases,
.page-content > .ac-shortcode-artists,
.page-content > .ac-shortcode-sale-chart,
.page-content > .ac-shortcode-newsletter-form,
.page-content > .ac-shortcode-empty {
margin: 0 0 16px;
line-height: normal;
}
.page-content > .ac-shortcode-hero {
grid-column: 1 / -1;
}
.page-content > .ac-shortcode-releases {
grid-column: 1;
}
.page-content > .ac-shortcode-artists {
grid-column: 2;
}
.page-content > .ac-shortcode-sale-chart,
.page-content > .ac-shortcode-newsletter-form,
.page-content > .ac-shortcode-empty {
grid-column: 1 / -1;
}
.page-content > .ac-shortcode-hero:last-child,
.page-content > .ac-shortcode-releases:last-child,
.page-content > .ac-shortcode-artists:last-child,
.page-content > .ac-shortcode-sale-chart:last-child,
.page-content > .ac-shortcode-newsletter-form:last-child,
.page-content > .ac-shortcode-empty:last-child {
margin-bottom: 0;
}
.ac-shortcode-release-grid { .ac-shortcode-release-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 10px;
} }
.ac-shortcode-release-card { .ac-shortcode-release-card {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
background: rgba(15,18,24,0.6); background: rgba(14,17,23,0.75);
border-radius: 14px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
grid-template-rows: auto 1fr;
min-height: 100%; min-height: 100%;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.ac-shortcode-release-card:hover {
transform: translateY(-2px);
border-color: rgba(57,244,179,0.35);
box-shadow: 0 10px 24px rgba(0,0,0,.25);
} }
.ac-shortcode-release-cover { .ac-shortcode-release-cover {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
@@ -316,12 +410,12 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
letter-spacing: 0.2em; letter-spacing: 0.2em;
} }
.ac-shortcode-release-meta { .ac-shortcode-release-meta {
padding: 10px; padding: 8px 10px 10px;
display: grid; display: grid;
gap: 4px; gap: 3px;
} }
.ac-shortcode-release-title { .ac-shortcode-release-title {
font-size: 18px; font-size: 16px;
line-height: 1.2; line-height: 1.2;
font-weight: 600; font-weight: 600;
} }
@@ -330,148 +424,223 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
.ac-shortcode-sale-chart { .ac-shortcode-artists-grid {
display: grid; display: grid;
gap: 12px; grid-template-columns: 1fr;
gap: 8px;
} }
.ac-shortcode-sale-head h3 { .ac-shortcode-artist-card {
margin: 0; text-decoration: none;
color: inherit;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(14,17,23,0.75);
border-radius: 12px;
overflow: hidden;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px;
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.ac-shortcode-artist-card:hover {
transform: translateY(-2px);
border-color: rgba(57,244,179,0.35);
box-shadow: 0 10px 24px rgba(0,0,0,.25);
}
.ac-shortcode-artist-avatar {
width: 52px;
height: 52px;
background: rgba(255,255,255,0.03);
display: grid;
place-items: center;
overflow: hidden;
border-radius: 10px;
}
.ac-shortcode-artist-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ac-shortcode-artist-meta {
display: grid;
gap: 2px;
}
.ac-shortcode-artist-name {
font-size: 15px;
line-height: 1.2;
font-weight: 600;
}
.ac-shortcode-artist-country {
color: var(--muted);
font-size: 12px; font-size: 12px;
letter-spacing: 0.2em; }
.ac-shortcode-hero {
border: 1px solid rgba(255,255,255,0.14);
border-radius: 18px;
padding: 16px 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01));
display: grid;
gap: 8px;
}
.ac-shortcode-hero-eyebrow {
font-size: 10px;
letter-spacing: 0.24em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
font-family: 'IBM Plex Mono', monospace; font-family: 'IBM Plex Mono', monospace;
font-weight: 600; }
.ac-shortcode-hero-title {
font-size: clamp(30px, 4vw, 50px);
line-height: 1.05;
font-weight: 700;
margin: 0;
}
.ac-shortcode-hero-subtitle {
font-size: 15px;
color: #d1d7e7;
max-width: 72ch;
margin: 0;
}
.ac-shortcode-hero-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.ac-shortcode-hero-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.05);
color: #f5f7ff;
text-decoration: none;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
}
.ac-shortcode-hero-btn.primary {
border-color: rgba(57,244,179,0.6);
background: rgba(57,244,179,0.16);
color: #9ff8d8;
} }
.ac-shortcode-sale-list { .ac-shortcode-sale-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: grid; display: grid;
gap: 10px; gap: 8px;
} }
.ac-shortcode-sale-item { .ac-shortcode-sale-item {
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
background: linear-gradient(180deg, rgba(15,18,24,0.78), rgba(12,14,20,0.72)); background: rgba(14,17,23,0.75);
border-radius: 12px;
padding: 8px;
transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease;
}
.ac-shortcode-sale-item:hover {
border-color: rgba(255,255,255,0.18);
box-shadow: 0 10px 24px rgba(0,0,0,0.24);
transform: translateY(-1px);
}
.ac-shortcode-sale-link {
display: grid;
grid-template-columns: auto 56px minmax(0, 1fr);
gap: 12px;
align-items: center;
color: inherit;
text-decoration: none;
}
.ac-shortcode-sale-link:hover .ac-shortcode-sale-title {
color: #ffffff;
}
.ac-shortcode-sale-link > .ac-shortcode-sale-rank {
order: 0;
justify-self: start;
min-width: 26px;
text-align: left;
margin-left: 2px;
position: relative;
padding-right: 18px;
margin-right: 6px;
}
.ac-shortcode-sale-link > .ac-shortcode-sale-rank::before {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 34px;
background: linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0.45) 50%, rgba(255,255,255,0.18) 100%);
box-shadow: 0 0 12px rgba(255,255,255,0.08);
border-radius: 1px;
}
.ac-shortcode-sale-link > .ac-shortcode-sale-rank::after {
right: 10px;
}
.ac-shortcode-sale-link > .ac-shortcode-sale-thumb {
order: 1;
}
.ac-shortcode-sale-link > .ac-shortcode-sale-copy {
order: 2;
}
.ac-shortcode-sale-thumb {
width: 56px;
height: 56px;
border-radius: 10px; border-radius: 10px;
overflow: hidden; padding: 8px 10px;
background: rgba(255,255,255,0.04);
display: grid; display: grid;
place-items: center; grid-template-columns: auto minmax(0,1fr) auto;
} gap: 8px;
.ac-shortcode-sale-thumb img { align-items: center;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ac-shortcode-sale-copy {
display: grid;
gap: 2px;
min-width: 0;
} }
.ac-shortcode-sale-rank { .ac-shortcode-sale-rank {
font-family: 'IBM Plex Mono', monospace; font-family: 'IBM Plex Mono', monospace;
font-size: 28px; font-size: 11px;
line-height: 1;
font-weight: 700;
letter-spacing: -0.02em;
min-width: 34px;
text-align: right;
position: relative;
display: inline-block;
background: linear-gradient(180deg, rgba(245,247,255,0.95) 0%, rgba(169,178,196,0.72) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 22px rgba(0,0,0,0.40);
transition: transform .2s ease, filter .2s ease;
}
.ac-shortcode-sale-rank::after {
content: '.';
color: var(--accent);
position: absolute;
right: -7px;
bottom: -1px;
font-size: 16px;
font-weight: 700;
line-height: 1;
text-shadow: none;
-webkit-text-fill-color: initial;
background: none;
}
.ac-shortcode-sale-link:hover .ac-shortcode-sale-rank {
transform: translateY(-1px) scale(1.03);
filter: brightness(1.08);
}
.ac-shortcode-sale-title {
font-size: 16px;
line-height: 1.25;
font-weight: 700;
}
.ac-shortcode-sale-artist {
font-size: 12px;
color: var(--muted); color: var(--muted);
line-height: 1.25; letter-spacing: 0.15em;
letter-spacing: 0.02em; }
.ac-shortcode-sale-title { font-size: 14px; line-height: 1.3; }
.ac-shortcode-sale-meta { font-size: 12px; color: var(--muted); white-space: nowrap; }
.ac-shortcode-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(15,18,24,.6);
color: #f5f7ff;
text-decoration: none;
font-size: 12px;
letter-spacing: .08em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
}
.ac-shortcode-link:hover {
border-color: rgba(57,244,179,.6);
color: #9ff8d8;
}
.ac-shortcode-newsletter-form {
display: grid;
gap: 10px;
border: 1px solid rgba(255,255,255,.15);
border-radius: 14px;
background: rgba(15,18,24,.6);
padding: 14px;
}
.ac-shortcode-newsletter-title {
font-size: 13px;
letter-spacing: .14em;
text-transform: uppercase;
color: #9aa0b2;
font-family: 'IBM Plex Mono', monospace;
}
.ac-shortcode-newsletter-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.ac-shortcode-newsletter-input {
height: 40px;
border: 1px solid rgba(255,255,255,.16);
border-radius: 10px;
background: rgba(8,10,16,.6);
color: #f5f7ff;
padding: 0 12px;
font-size: 14px;
width: 100%;
}
.ac-shortcode-newsletter-btn {
height: 40px;
padding: 0 14px;
border: 1px solid rgba(57,244,179,.6);
border-radius: 999px;
background: rgba(57,244,179,.16);
color: #9ff8d8;
font-size: 12px;
letter-spacing: .14em;
text-transform: uppercase;
font-family: 'IBM Plex Mono', monospace;
cursor: pointer;
}
.ac-home-catalog {
display: grid;
gap: 14px;
}
.ac-home-columns {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
gap: 14px;
align-items: start;
}
.ac-home-main,
.ac-home-side {
display: grid;
gap: 14px;
align-content: start;
}
@media (max-width: 1200px) {
.ac-shortcode-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ac-home-columns {
grid-template-columns: 1fr;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.shell { .shell {
padding: 12px 14px 18px; padding: 12px 14px 18px;
@@ -530,6 +699,10 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
flex: 1 1 auto; flex: 1 1 auto;
justify-content: center; justify-content: center;
} }
.nav-search-form {
min-width: 0;
width: 100%;
}
.nav-cart { .nav-cart {
flex: 1 1 auto; flex: 1 1 auto;
} }
@@ -539,8 +712,37 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
.shell-main { .shell-main {
padding-top: 2px; padding-top: 2px;
} }
.ac-shortcode-newsletter-row {
grid-template-columns: 1fr;
}
.page-content {
grid-template-columns: 1fr;
}
.ac-shortcode-release-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.page-content > .ac-shortcode-releases,
.page-content > .ac-shortcode-artists,
.page-content > .ac-shortcode-sale-chart,
.page-content > .ac-shortcode-newsletter-form,
.page-content > .ac-shortcode-empty {
grid-column: 1;
}
}
@media (max-width: 620px) {
.ac-shortcode-release-grid {
grid-template-columns: 1fr;
}
.ac-shortcode-hero-title {
font-size: clamp(28px, 10vw, 42px);
}
} }
</style> </style>
<?php if (trim($siteCustomCss) !== ''): ?>
<style id="ac-custom-css">
<?= $siteCustomCss . "\n" ?>
</style>
<?php endif; ?>
</head> </head>
<body> <body>
<?php require __DIR__ . '/../partials/header.php'; ?> <?php require __DIR__ . '/../partials/header.php'; ?>
@@ -558,20 +760,5 @@ if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']
<?= $content ?? '' ?> <?= $content ?? '' ?>
</main> </main>
<?php require __DIR__ . '/../partials/footer.php'; ?> <?php require __DIR__ . '/../partials/footer.php'; ?>
<script data-ac-csrf>
(function() {
const token = '<?= htmlspecialchars(Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8') ?>';
document.querySelectorAll('form[method="post"], form[method="POST"]').forEach((form) => {
if (form.querySelector('input[name="csrf_token"]')) return;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = token;
form.appendChild(input);
});
})();
</script>
</body> </body>
</html> </html>