Compare commits
18 Commits
d00e6ff092
...
v1.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deabe1ec9 | ||
|
|
dc53051358 | ||
|
|
4f50b611b0 | ||
|
|
8d12c68c0c | ||
|
|
bfdeecade1 | ||
|
|
d33c16a20f | ||
|
|
2eaa61654b | ||
|
|
af6bb638ac | ||
|
|
93e829bd19 | ||
| 20a8582928 | |||
|
|
fd03d276f1 | ||
|
|
c5302c3b44 | ||
|
|
89b4b1eefd | ||
|
|
2e92b9f421 | ||
| 72b76397ef | |||
| d5677b2457 | |||
| 9793d340cd | |||
| d15470de67 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
46
README.md
Normal 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.
|
||||||
@@ -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 . '">';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
560
core/services/ApiLayer.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
80
core/services/RateLimiter.php
Normal file
80
core/services/RateLimiter.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,5 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'version' => '1.5.0',
|
'version' => '1.5.1',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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']) ? '✓' : '!' ?>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
760
modules/api/ApiController.php
Normal file
760
modules/api/ApiController.php
Normal 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
26
modules/api/module.php
Normal 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']);
|
||||||
|
};
|
||||||
189
modules/api/views/admin/index.php
Normal file
189
modules/api/views/admin/index.php
Normal 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 <api-key></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';
|
||||||
0
modules/artists/views/admin/.gitkeep
Normal file
0
modules/artists/views/admin/.gitkeep
Normal 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'])): ?> · <?php endif; ?>
|
|
||||||
<?= htmlspecialchars((string)$post['category'], ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<p style="color:var(--muted); line-height:1.6;">
|
|
||||||
<?= htmlspecialchars((string)($post['excerpt'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</p>
|
|
||||||
<?php if (!empty($post['tags'])): ?>
|
|
||||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
|
||||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$post['tags'] ?? ''))) as $tag): ?>
|
|
||||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
|
||||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</span>
|
|
||||||
<?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>
|
<div class="news-card-copy-minimal">
|
||||||
</article>
|
<div class="news-card-meta-minimal">
|
||||||
|
<?php $published = $formatDate((string)($post['published_at'] ?? '')); ?>
|
||||||
|
<?php if ($published !== ''): ?><span><?= htmlspecialchars($published, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
|
||||||
|
<?php if (!empty($post['author_name'])): ?><span><?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<h2><?= htmlspecialchars((string)$post['title'], ENT_QUOTES, 'UTF-8') ?></h2>
|
||||||
|
<?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; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
</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';
|
||||||
@@ -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="badge">News</div>
|
<div class="article-fluid-grid">
|
||||||
<h1 style="margin-top:12px; font-size:30px;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
|
<aside class="article-fluid-meta">
|
||||||
<?php if ($publishedAt !== '' || $authorName !== '' || $category !== ''): ?>
|
<div class="badge">News</div>
|
||||||
<div style="font-size:12px; color:var(--muted); margin-top:6px;">
|
<h1 class="article-fluid-title"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
|
||||||
<?php if ($publishedAt !== ''): ?>
|
<div class="article-fluid-meta-list">
|
||||||
<?= htmlspecialchars($publishedAt, ENT_QUOTES, 'UTF-8') ?>
|
<?php if ($publishedDisplay !== ''): ?>
|
||||||
<?php endif; ?>
|
<div class="article-fluid-meta-item">
|
||||||
<?php if ($authorName !== ''): ?>
|
<span>Date</span>
|
||||||
<?php if ($publishedAt !== ''): ?> · <?php endif; ?>
|
<strong><?= htmlspecialchars($publishedDisplay, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||||
<?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($category !== ''): ?>
|
<?php if ($authorName !== ''): ?>
|
||||||
<?php if ($publishedAt !== '' || $authorName !== ''): ?> · <?php endif; ?>
|
<div class="article-fluid-meta-item">
|
||||||
<?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?>
|
<span>Author</span>
|
||||||
|
<strong><?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($category !== ''): ?>
|
||||||
|
<div class="article-fluid-meta-item">
|
||||||
|
<span>Category</span>
|
||||||
|
<strong><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($tagList): ?>
|
||||||
|
<div class="article-fluid-tags">
|
||||||
|
<?php foreach ($tagList as $tag): ?>
|
||||||
|
<span><?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</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>
|
||||||
<?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 !== ''): ?>
|
|
||||||
<div style="margin-top:16px; display:flex; flex-wrap:wrap; gap:6px;">
|
|
||||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$tags))) as $tag): ?>
|
|
||||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
|
||||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</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';
|
||||||
|
|||||||
@@ -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'])) {
|
||||||
|
|||||||
@@ -39,12 +39,22 @@ class PagesController
|
|||||||
return $this->notFound();
|
return $this->notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$rendered = Shortcodes::render((string)$page['content_html'], [
|
||||||
|
'page_slug' => $slug,
|
||||||
|
'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', [
|
return new Response($this->view->render('site/show.php', [
|
||||||
'title' => (string)$page['title'],
|
'title' => (string)$page['title'],
|
||||||
'content_html' => Shortcodes::render((string)$page['content_html'], [
|
'content_html' => $rendered,
|
||||||
'page_slug' => $slug,
|
|
||||||
'page_title' => (string)$page['title'],
|
|
||||||
]),
|
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
536
plugins/advanced-reporting/ReportsController.php
Normal file
536
plugins/advanced-reporting/ReportsController.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
14
plugins/advanced-reporting/plugin.json
Normal file
14
plugins/advanced-reporting/plugin.json
Normal 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
|
||||||
|
}
|
||||||
13
plugins/advanced-reporting/plugin.php
Normal file
13
plugins/advanced-reporting/plugin.php
Normal 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']);
|
||||||
|
};
|
||||||
387
plugins/advanced-reporting/views/admin/index.php
Normal file
387
plugins/advanced-reporting/views/admin/index.php
Normal 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';
|
||||||
@@ -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'] ?? ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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']);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 & 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +289,10 @@ 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; }
|
||||||
.ac-dock {
|
.bundle-card{grid-template-columns:1fr;gap:10px}
|
||||||
|
.bundle-stack{height:62px}
|
||||||
|
.bundle-cover{width:62px;height:62px}
|
||||||
|
.ac-dock {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="label">Status</div>
|
<div class="order-summary-top-stat">
|
||||||
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
|
<div class="label">Status</div>
|
||||||
|
<div class="pill"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="order-summary-top-stat">
|
||||||
|
<div class="label">After Fees</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>
|
</div>
|
||||||
<div class="label">Total</div>
|
<div class="order-summary-grid">
|
||||||
<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-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>
|
||||||
<div>
|
<div class="order-stat">
|
||||||
<div class="label">Customer Email</div>
|
<div class="label">PayPal Fees</div>
|
||||||
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['email'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></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>
|
||||||
<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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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): ?> · <?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';
|
||||||
|
|||||||
310
plugins/store/views/site/checkout_card.php
Normal file
310
plugins/store/views/site/checkout_card.php
Normal 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)
|
||||||
|
. '¤cy=' . 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';
|
||||||
@@ -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')]);
|
||||||
|
|||||||
@@ -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
0
storage/cache/.gitkeep
vendored
Normal file
14
update.json
14
update.json
@@ -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
0
views/admin/.gitkeep
Normal 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>
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
<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 ($storeEnabled): ?>
|
||||||
<div class="nav-actions">
|
|
||||||
<?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,8 +131,8 @@ 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user