Release v1.5.1
This commit is contained in:
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';
|
||||
@@ -1321,6 +1321,8 @@ class ReleasesController
|
||||
}
|
||||
}
|
||||
|
||||
$artistId = $this->resolveArtistId($db, $artistName);
|
||||
|
||||
$dupStmt = $id > 0
|
||||
? $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug AND id != :id LIMIT 1")
|
||||
: $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug LIMIT 1");
|
||||
@@ -1334,13 +1336,14 @@ class ReleasesController
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE ac_releases
|
||||
SET title = :title, slug = :slug, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date,
|
||||
SET title = :title, slug = :slug, artist_id = :artist_id, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date,
|
||||
cover_url = :cover_url, sample_url = :sample_url, is_published = :is_published
|
||||
WHERE id = :id
|
||||
");
|
||||
$stmt->execute([
|
||||
':title' => $title,
|
||||
':slug' => $slug,
|
||||
':artist_id' => $artistId > 0 ? $artistId : null,
|
||||
':artist_name' => $artistName !== '' ? $artistName : null,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':credits' => $credits !== '' ? $credits : null,
|
||||
@@ -1356,12 +1359,13 @@ class ReleasesController
|
||||
$releaseId = $id;
|
||||
} else {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_releases (title, slug, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published)
|
||||
VALUES (:title, :slug, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published)
|
||||
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' => $description !== '' ? $description : null,
|
||||
':credits' => $credits !== '' ? $credits : null,
|
||||
@@ -1527,6 +1531,7 @@ class ReleasesController
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL UNIQUE,
|
||||
artist_id INT UNSIGNED NULL,
|
||||
artist_name VARCHAR(200) NULL,
|
||||
description MEDIUMTEXT NULL,
|
||||
credits MEDIUMTEXT NULL,
|
||||
@@ -1539,6 +1544,7 @@ class ReleasesController
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN sample_url VARCHAR(255) NULL");
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN cover_url VARCHAR(255) NULL");
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN release_date DATE NULL");
|
||||
@@ -1628,6 +1634,40 @@ class ReleasesController
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'");
|
||||
$exists = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC));
|
||||
if (!$exists) {
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
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 function resolveArtistId(PDO $db, string $artistName): int
|
||||
{
|
||||
$artistName = trim($artistName);
|
||||
if ($artistName === '') {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT id FROM ac_artists WHERE name = :name LIMIT 1");
|
||||
$stmt->execute([':name' => $artistName]);
|
||||
return (int)($stmt->fetchColumn() ?: 0);
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,4 +115,6 @@ return function (Router $router): void {
|
||||
$router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']);
|
||||
$router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']);
|
||||
$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>
|
||||
</div>
|
||||
<input class="input" id="release_cover_url" name="cover_url" value="<?= htmlspecialchars((string)($release['cover_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
|
||||
<div class="admin-card" style="padding:14px; background: rgba(10,10,12,0.6);">
|
||||
<div class="label">Upload sample (MP3)</div>
|
||||
<input type="hidden" name="release_id" value="<?= (int)($release['id'] ?? 0) ?>">
|
||||
<label for="releaseSampleFile" id="releaseSampleDropzone" style="display:flex; flex-direction:column; gap:8px; align-items:center; justify-content:center; padding:18px; border-radius:14px; border:1px dashed rgba(255,255,255,0.2); background: rgba(0,0,0,0.2); cursor:pointer;">
|
||||
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.2em; color:var(--muted);">Drag & Drop</div>
|
||||
<div style="font-size:13px; color:var(--text);">or click to upload</div>
|
||||
<div id="releaseSampleFileName" style="font-size:11px; color:var(--muted);">No file selected</div>
|
||||
</label>
|
||||
<input class="input" type="file" id="releaseSampleFile" name="release_sample" accept="audio/mpeg" style="display:none;">
|
||||
<div style="margin-top:10px; display:flex; justify-content:flex-end;">
|
||||
<button type="submit" class="btn small" formaction="/admin/releases/upload" formmethod="post" formenctype="multipart/form-data" name="upload_type" value="sample">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="label">Sample URL (MP3)</label>
|
||||
<input class="input" name="sample_url" value="<?= htmlspecialchars((string)($release['sample_url'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" placeholder="https://...">
|
||||
<label class="label">Description</label>
|
||||
<textarea class="input" name="description" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace; font-size:13px; line-height:1.6;"><?= htmlspecialchars((string)($release['description'] ?? ''), ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
<label class="label">Release Credits</label>
|
||||
@@ -137,30 +122,6 @@ ob_start();
|
||||
coverName.textContent = coverFile.files.length ? coverFile.files[0].name : 'No file selected';
|
||||
});
|
||||
}
|
||||
|
||||
const sampleDrop = document.getElementById('releaseSampleDropzone');
|
||||
const sampleFile = document.getElementById('releaseSampleFile');
|
||||
const sampleName = document.getElementById('releaseSampleFileName');
|
||||
if (sampleDrop && sampleFile && sampleName) {
|
||||
sampleDrop.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
sampleDrop.style.borderColor = 'var(--accent)';
|
||||
});
|
||||
sampleDrop.addEventListener('dragleave', () => {
|
||||
sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)';
|
||||
});
|
||||
sampleDrop.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)';
|
||||
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) {
|
||||
sampleFile.files = event.dataTransfer.files;
|
||||
sampleName.textContent = event.dataTransfer.files[0].name;
|
||||
}
|
||||
});
|
||||
sampleFile.addEventListener('change', () => {
|
||||
sampleName.textContent = sampleFile.files.length ? sampleFile.files[0].name : 'No file selected';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
$pageTitle = $title ?? 'Release';
|
||||
$release = $release ?? null;
|
||||
$tracks = $tracks ?? [];
|
||||
$bundles = is_array($bundles ?? null) ? $bundles : [];
|
||||
$storePluginEnabled = (bool)($store_plugin_enabled ?? false);
|
||||
$releaseCover = (string)($release['cover_url'] ?? '');
|
||||
$returnUrl = (string)($_SERVER['REQUEST_URI'] ?? '/releases');
|
||||
@@ -153,6 +154,64 @@ ob_start();
|
||||
</div>
|
||||
<?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'])): ?>
|
||||
<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>
|
||||
@@ -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: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}
|
||||
.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-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}
|
||||
@@ -221,7 +289,10 @@ ob_start();
|
||||
gap: 10px !important;
|
||||
}
|
||||
.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;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -181,11 +181,15 @@ return function (Router $router): void {
|
||||
$router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']);
|
||||
$router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']);
|
||||
$router->get('/checkout', [$controller, 'checkoutIndex']);
|
||||
$router->post('/checkout/card/start', [$controller, 'checkoutCardStart']);
|
||||
$router->get('/checkout/card', [$controller, 'checkoutCard']);
|
||||
$router->get('/account', [$controller, 'accountIndex']);
|
||||
$router->post('/account/request-login', [$controller, 'accountRequestLogin']);
|
||||
$router->get('/account/login', [$controller, 'accountLogin']);
|
||||
$router->get('/account/logout', [$controller, 'accountLogout']);
|
||||
$router->post('/checkout/place', [$controller, 'checkoutPlace']);
|
||||
$router->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/cancel', [$controller, 'checkoutPaypalCancel']);
|
||||
$router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']);
|
||||
@@ -199,6 +203,8 @@ return function (Router $router): void {
|
||||
$router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']);
|
||||
$router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']);
|
||||
$router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']);
|
||||
$router->post('/admin/store/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-paypal', [$controller, 'adminTestPaypal']);
|
||||
$router->get('/admin/store/customers', [$controller, 'adminCustomers']);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
use Core\Services\Plugins;
|
||||
|
||||
$pageTitle = $title ?? 'Store Customers';
|
||||
$customers = $customers ?? [];
|
||||
$currency = (string)($currency ?? 'GBP');
|
||||
$q = (string)($q ?? '');
|
||||
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
|
||||
ob_start();
|
||||
?>
|
||||
<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/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>
|
||||
|
||||
<form method="get" action="/admin/store/customers" class="customers-search">
|
||||
@@ -39,7 +45,9 @@ ob_start();
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Orders</th>
|
||||
<th>Revenue</th>
|
||||
<th>Before Fees</th>
|
||||
<th>Fees</th>
|
||||
<th>After Fees</th>
|
||||
<th>Latest Order</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
@@ -70,7 +78,9 @@ ob_start();
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="num"><?= (int)($customer['order_count'] ?? 0) ?></td>
|
||||
<td class="num"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($customer['revenue'] ?? 0), 2) ?></td>
|
||||
<td 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>
|
||||
<?php if ($lastOrderId > 0): ?>
|
||||
<a href="/admin/store/order?id=<?= $lastOrderId ?>" class="order-link">
|
||||
@@ -206,8 +216,10 @@ ob_start();
|
||||
@media (max-width: 980px) {
|
||||
.customers-table th:nth-child(3),
|
||||
.customers-table td:nth-child(3),
|
||||
.customers-table th:nth-child(5),
|
||||
.customers-table td:nth-child(5) { display:none; }
|
||||
.customers-table th:nth-child(4),
|
||||
.customers-table td:nth-child(4),
|
||||
.customers-table th:nth-child(7),
|
||||
.customers-table td:nth-child(7) { display:none; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
use Core\Services\Plugins;
|
||||
|
||||
$pageTitle = $title ?? 'Store';
|
||||
$tablesReady = (bool)($tables_ready ?? false);
|
||||
$privateRoot = (string)($private_root ?? '');
|
||||
@@ -9,8 +11,11 @@ $newCustomers = is_array($new_customers ?? null) ? $new_customers : [];
|
||||
$currency = (string)($currency ?? 'GBP');
|
||||
$totalOrders = (int)($stats['total_orders'] ?? 0);
|
||||
$paidOrders = (int)($stats['paid_orders'] ?? 0);
|
||||
$totalRevenue = (float)($stats['total_revenue'] ?? 0);
|
||||
$beforeFees = (float)($stats['before_fees'] ?? 0);
|
||||
$paypalFees = (float)($stats['paypal_fees'] ?? 0);
|
||||
$afterFees = (float)($stats['after_fees'] ?? 0);
|
||||
$totalCustomers = (int)($stats['total_customers'] ?? 0);
|
||||
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
@@ -24,6 +29,9 @@ ob_start();
|
||||
<a href="/admin/store/settings" class="btn outline">Settings</a>
|
||||
<a href="/admin/store/orders" class="btn outline">Orders</a>
|
||||
<a href="/admin/store/customers" class="btn outline">Customers</a>
|
||||
<?php if ($reportsEnabled): ?>
|
||||
<a href="/admin/store/reports" class="btn outline">Sales Reports</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<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/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>
|
||||
|
||||
<?php if (!$tablesReady): ?>
|
||||
@@ -62,8 +73,14 @@ ob_start();
|
||||
<div style="font-size:12px; color:var(--muted); margin-top:4px;">Paid: <?= $paidOrders ?></div>
|
||||
</div>
|
||||
<div class="admin-card" style="padding:14px;">
|
||||
<div class="label">Revenue</div>
|
||||
<div style="font-size:26px; font-weight:700; margin-top:8px;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($totalRevenue, 2) ?></div>
|
||||
<div class="label">Before Fees</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 class="admin-card" style="padding:14px;">
|
||||
<div class="label">Total Customers</div>
|
||||
@@ -86,7 +103,11 @@ ob_start();
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; color:var(--muted); font-size:12px; margin-top:4px;">
|
||||
<span><?= htmlspecialchars((string)($order['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span><?= htmlspecialchars((string)($order['currency'] ?? $currency), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></span>
|
||||
<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 style="margin-top:8px;">
|
||||
<a href="/admin/store/order?id=<?= (int)($order['id'] ?? 0) ?>" class="btn outline small">View Order</a>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
use Core\Services\Plugins;
|
||||
|
||||
$pageTitle = $title ?? 'Order Detail';
|
||||
$order = is_array($order ?? null) ? $order : [];
|
||||
$items = is_array($items ?? null) ? $items : [];
|
||||
$downloadsByItem = is_array($downloads_by_item ?? null) ? $downloads_by_item : [];
|
||||
$downloadEvents = is_array($download_events ?? null) ? $download_events : [];
|
||||
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
|
||||
ob_start();
|
||||
?>
|
||||
<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/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 class="admin-card" style="padding:14px; margin-top:16px;">
|
||||
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px;">
|
||||
<div>
|
||||
<div class="admin-card order-summary-card" style="margin-top:16px;">
|
||||
<div class="order-summary-top">
|
||||
<div class="order-summary-identity">
|
||||
<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 class="label">Status</div>
|
||||
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['status'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="order-summary-total-cluster">
|
||||
<div class="order-summary-top-stat">
|
||||
<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 class="label">Total</div>
|
||||
<div style="font-weight:700; margin-top:6px;"><?= htmlspecialchars((string)($order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['total'] ?? 0), 2) ?></div>
|
||||
</div>
|
||||
<div class="order-summary-grid">
|
||||
<div class="order-stat">
|
||||
<div class="label">Before Fees</div>
|
||||
<div class="order-stat-value"><?= htmlspecialchars((string)($order['payment_currency'] ?? $order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_gross'] ?? $order['total'] ?? 0), 2) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Customer Email</div>
|
||||
<div style="margin-top:6px;"><?= htmlspecialchars((string)($order['email'] ?? '-'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="order-stat">
|
||||
<div class="label">PayPal Fees</div>
|
||||
<div class="order-stat-value"><?= htmlspecialchars((string)($order['payment_currency'] ?? $order['currency'] ?? 'GBP'), ENT_QUOTES, 'UTF-8') ?> <?= number_format((float)($order['payment_fee'] ?? 0), 2) ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="order-stat">
|
||||
<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 class="order-stat">
|
||||
<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>
|
||||
@@ -118,6 +133,108 @@ ob_start();
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</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
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../modules/admin/views/layout.php';
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
use Core\Services\Plugins;
|
||||
|
||||
$pageTitle = $title ?? 'Store Orders';
|
||||
$orders = is_array($orders ?? null) ? $orders : [];
|
||||
$q = (string)($q ?? '');
|
||||
$saved = (string)($saved ?? '');
|
||||
$error = (string)($error ?? '');
|
||||
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
|
||||
ob_start();
|
||||
?>
|
||||
<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/orders" class="btn 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>
|
||||
|
||||
<?php if ($saved !== ''): ?>
|
||||
@@ -78,7 +84,11 @@ ob_start();
|
||||
|
||||
<div class="store-order-amount">
|
||||
<?= 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 class="store-order-status pill"><?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Store Settings';
|
||||
$settings = $settings ?? [];
|
||||
<?php
|
||||
use Core\Services\Plugins;
|
||||
|
||||
$pageTitle = $title ?? 'Store Settings';
|
||||
$settings = $settings ?? [];
|
||||
$gateways = is_array($gateways ?? null) ? $gateways : [];
|
||||
$error = (string)($error ?? '');
|
||||
$saved = (string)($saved ?? '');
|
||||
$tab = (string)($tab ?? 'general');
|
||||
$tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general';
|
||||
$tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'bundles', 'sales_chart'], true) ? $tab : 'general';
|
||||
$paypalTest = (string)($_GET['paypal_test'] ?? '');
|
||||
$privateRootReady = (bool)($private_root_ready ?? false);
|
||||
$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 : [];
|
||||
$chartLastRebuildAt = (string)($chart_last_rebuild_at ?? '');
|
||||
$chartCronUrl = (string)($chart_cron_url ?? '');
|
||||
$chartCronCmd = (string)($chart_cron_cmd ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
$reportsEnabled = Plugins::isEnabled('advanced-reporting');
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
<div class="badge">Store</div>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:16px;">
|
||||
<div>
|
||||
<h1 style="font-size:28px; margin:0;">Store Settings</h1>
|
||||
<p style="color: var(--muted); margin-top:6px;">Configure defaults, payments, and transactional emails.</p>
|
||||
</div>
|
||||
<a href="/admin/store" class="btn outline">Back</a>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
|
||||
<a href="/admin/store/settings?tab=general" class="btn <?= $tab === 'general' ? '' : 'outline' ?> small">General</a>
|
||||
<a href="/admin/store" class="btn outline">Back</a>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:12px;">
|
||||
<a href="/admin/store" class="btn outline small">Overview</a>
|
||||
<a href="/admin/store/settings" class="btn 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;">
|
||||
<a href="/admin/store/settings?tab=general" class="btn <?= $tab === 'general' ? '' : 'outline' ?> small">General</a>
|
||||
<a href="/admin/store/settings?tab=payments" class="btn <?= $tab === 'payments' ? '' : 'outline' ?> small">Payments</a>
|
||||
<a href="/admin/store/settings?tab=emails" class="btn <?= $tab === 'emails' ? '' : 'outline' ?> small">Emails</a>
|
||||
<a href="/admin/store/settings?tab=discounts" class="btn <?= $tab === 'discounts' ? '' : 'outline' ?> small">Discounts</a>
|
||||
<a href="/admin/store/settings?tab=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>
|
||||
</div>
|
||||
|
||||
@@ -91,10 +107,10 @@ ob_start();
|
||||
<button class="btn" type="submit">Save General Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php elseif ($tab === 'payments'): ?>
|
||||
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
|
||||
<input type="hidden" name="tab" value="payments">
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<?php elseif ($tab === 'payments'): ?>
|
||||
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
|
||||
<input type="hidden" name="tab" value="payments">
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<div class="label" style="margin-bottom:10px;">Payment Mode</div>
|
||||
<input type="hidden" name="store_test_mode" value="0">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
|
||||
@@ -103,22 +119,78 @@ ob_start();
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<div class="label" style="margin-bottom:10px;">PayPal</div>
|
||||
<input type="hidden" name="store_paypal_enabled" value="0">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
|
||||
<input type="checkbox" name="store_paypal_enabled" value="1" <?= ((string)($settings['store_paypal_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
|
||||
Enable PayPal
|
||||
</label>
|
||||
<div class="label" style="margin-top:10px;">PayPal Client ID</div>
|
||||
<input class="input" name="store_paypal_client_id" value="<?= htmlspecialchars((string)($settings['store_paypal_client_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="label" style="margin-top:10px;">PayPal Secret</div>
|
||||
<input class="input" name="store_paypal_secret" value="<?= htmlspecialchars((string)($settings['store_paypal_secret'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
|
||||
<div style="margin-top:12px; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="live" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Live</button>
|
||||
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="sandbox" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Sandbox</button>
|
||||
<button class="btn" type="submit">Save Payment Settings</button>
|
||||
<div class="admin-card" style="padding:16px;">
|
||||
<div class="label" style="margin-bottom:10px;">PayPal</div>
|
||||
<input type="hidden" name="store_paypal_enabled" value="0">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:0.2em;">
|
||||
<input type="checkbox" name="store_paypal_enabled" value="1" <?= ((string)($settings['store_paypal_enabled'] ?? '0') === '1') ? 'checked' : '' ?>>
|
||||
Enable PayPal
|
||||
</label>
|
||||
<div class="label" style="margin-top:10px;">PayPal Client ID</div>
|
||||
<input class="input" name="store_paypal_client_id" value="<?= htmlspecialchars((string)($settings['store_paypal_client_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<div class="label" style="margin-top:10px;">PayPal Secret</div>
|
||||
<input class="input" name="store_paypal_secret" value="<?= htmlspecialchars((string)($settings['store_paypal_secret'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
|
||||
|
||||
<div style="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;">
|
||||
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="live" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Live</button>
|
||||
<button class="btn outline small" type="submit" name="paypal_probe_mode" value="sandbox" formaction="/admin/store/settings/test-paypal" formmethod="post">Test PayPal Sandbox</button>
|
||||
<button class="btn" type="submit">Save Payment Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,6 +302,89 @@ ob_start();
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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: ?>
|
||||
<form method="post" action="/admin/store/settings" style="margin-top:16px; display:grid; gap:16px;">
|
||||
<input type="hidden" name="tab" value="sales_chart">
|
||||
|
||||
@@ -21,6 +21,9 @@ ob_start();
|
||||
$key = (string)($item['key'] ?? '');
|
||||
$title = (string)($item['title'] ?? 'Item');
|
||||
$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));
|
||||
$price = (float)($item['price'] ?? 0);
|
||||
$currency = (string)($item['currency'] ?? ($totals['currency'] ?? 'GBP'));
|
||||
@@ -35,6 +38,18 @@ ob_start();
|
||||
</div>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?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>
|
||||
<div style="font-weight:700;"><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
|
||||
|
||||
@@ -1,208 +1,242 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$pageTitle = $title ?? 'Checkout';
|
||||
$items = is_array($items ?? null) ? $items : [];
|
||||
$total = (float)($total ?? 0);
|
||||
$subtotal = (float)($subtotal ?? $total);
|
||||
$discountAmount = (float)($discount_amount ?? 0);
|
||||
$discountCode = (string)($discount_code ?? '');
|
||||
$currency = (string)($currency ?? 'GBP');
|
||||
$success = (string)($success ?? '');
|
||||
$orderNo = (string)($order_no ?? '');
|
||||
$error = (string)($error ?? '');
|
||||
$downloadLinks = is_array($download_links ?? null) ? $download_links : [];
|
||||
$downloadNotice = (string)($download_notice ?? '');
|
||||
$downloadLimit = (int)($download_limit ?? 5);
|
||||
$downloadExpiryDays = (int)($download_expiry_days ?? 30);
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card checkout-wrap">
|
||||
<div class="badge">Store</div>
|
||||
<h1 style="margin:0; font-size:32px;">Checkout</h1>
|
||||
<?php if ($success !== ''): ?>
|
||||
<div style="padding:14px; border-radius:12px; border:1px solid rgba(34,242,165,.4); background:rgba(34,242,165,.12);">
|
||||
<div style="font-weight:700;">Order complete</div>
|
||||
<?php if ($orderNo !== ''): ?>
|
||||
<div style="margin-top:4px; color:var(--muted);">Order: <?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Your Downloads</div>
|
||||
<?php if ($downloadLinks): ?>
|
||||
<div style="display:grid; gap:10px; margin-top:10px;">
|
||||
<?php foreach ($downloadLinks as $link): ?>
|
||||
<?php
|
||||
$label = trim((string)($link['label'] ?? 'Download'));
|
||||
$url = trim((string)($link['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<a href="<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>" class="checkout-download-link">
|
||||
<span><?= htmlspecialchars($label !== '' ? $label : 'Download', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span class="checkout-download-link-arrow">Download</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p style="margin:10px 0 0; color:var(--muted); font-size:13px;">
|
||||
<?= htmlspecialchars($downloadNotice !== '' ? $downloadNotice : 'No downloads available for this order yet.', ENT_QUOTES, 'UTF-8') ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($error !== ''): ?>
|
||||
<div style="padding:14px; border-radius:12px; border:1px solid rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6;">
|
||||
<?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$items): ?>
|
||||
<div style="padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); color:var(--muted);">
|
||||
Your cart is empty.
|
||||
</div>
|
||||
<div><a href="/releases" class="btn">Browse releases</a></div>
|
||||
<?php else: ?>
|
||||
<div class="checkout-grid">
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Order Summary</div>
|
||||
<div style="display:grid; gap:10px; margin-top:10px;">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$title = (string)($item['title'] ?? 'Item');
|
||||
$qty = max(1, (int)($item['qty'] ?? 1));
|
||||
$price = (float)($item['price'] ?? 0);
|
||||
$lineCurrency = (string)($item['currency'] ?? $currency);
|
||||
?>
|
||||
<div class="checkout-line">
|
||||
<div class="checkout-line-title"><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div class="checkout-line-meta"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price, 2) ?> x <?= $qty ?></div>
|
||||
<div class="checkout-line-total"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="checkout-total">
|
||||
<span>Subtotal</span>
|
||||
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($subtotal, 2) ?></strong>
|
||||
</div>
|
||||
<?php if ($discountAmount > 0): ?>
|
||||
<div class="checkout-total" style="margin-top:8px;">
|
||||
<span>Discount (<?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?>)</span>
|
||||
<strong style="color:#9be7c6;">-<?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($discountAmount, 2) ?></strong>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="checkout-total" style="margin-top:8px;">
|
||||
<span>Order total</span>
|
||||
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($total, 2) ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Buyer Details</div>
|
||||
<form method="post" action="/checkout/place" style="display:grid; gap:12px; margin-top:10px;">
|
||||
<label style="font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.18em;">Email</label>
|
||||
<input name="email" type="email" value="" placeholder="you@example.com" class="checkout-input" required>
|
||||
|
||||
<div class="checkout-terms">
|
||||
<div class="badge" style="font-size:9px;">Terms</div>
|
||||
<p style="margin:8px 0 0; color:var(--muted); font-size:13px; line-height:1.5;">
|
||||
Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order.
|
||||
Files are limited to <?= $downloadLimit ?> downloads and expire after <?= $downloadExpiryDays ?> days.
|
||||
</p>
|
||||
<label style="margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px;">
|
||||
<input type="checkbox" name="accept_terms" value="1" required style="margin-top:2px;">
|
||||
<span>I agree to the terms and understand all sales are final.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="checkout-place-btn">Place Order</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<style>
|
||||
.checkout-wrap { display:grid; gap:14px; }
|
||||
.checkout-grid { display:grid; grid-template-columns: minmax(0,1fr) 420px; gap:14px; }
|
||||
.checkout-panel {
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
background:rgba(0,0,0,.2);
|
||||
}
|
||||
.checkout-line {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:8px;
|
||||
padding:10px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(255,255,255,.08);
|
||||
background:rgba(255,255,255,.03);
|
||||
}
|
||||
.checkout-line-title { font-weight:600; }
|
||||
.checkout-line-meta { color:var(--muted); font-size:12px; margin-top:4px; grid-column:1/2; }
|
||||
.checkout-line-total { font-weight:700; grid-column:2/3; grid-row:1/3; align-self:center; }
|
||||
.checkout-total {
|
||||
margin-top:10px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
padding:12px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
background:rgba(255,255,255,.04);
|
||||
}
|
||||
.checkout-total strong { font-size:22px; }
|
||||
.checkout-input {
|
||||
height:40px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(255,255,255,.2);
|
||||
background:rgba(255,255,255,.05);
|
||||
color:#fff;
|
||||
padding:0 12px;
|
||||
}
|
||||
.checkout-terms {
|
||||
padding:12px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(255,255,255,.1);
|
||||
background:rgba(255,255,255,.03);
|
||||
}
|
||||
.checkout-place-btn{
|
||||
height:40px;
|
||||
border-radius:999px;
|
||||
border:1px solid rgba(34,242,165,.45);
|
||||
background:rgba(34,242,165,.18);
|
||||
color:#cbfff1;
|
||||
font-weight:700;
|
||||
letter-spacing:.1em;
|
||||
text-transform:uppercase;
|
||||
cursor:pointer;
|
||||
}
|
||||
.checkout-place-btn:hover { background:rgba(34,242,165,.28); }
|
||||
.checkout-download-link {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
padding:12px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(34,242,165,.35);
|
||||
background:rgba(34,242,165,.1);
|
||||
color:#d7ffef;
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
}
|
||||
.checkout-download-link:hover { background:rgba(34,242,165,.18); }
|
||||
.checkout-download-link-arrow {
|
||||
font-size:11px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.14em;
|
||||
color:#8df7d1;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.checkout-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../views/site/layout.php';
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$pageTitle = $title ?? 'Checkout';
|
||||
$items = is_array($items ?? null) ? $items : [];
|
||||
$total = (float)($total ?? 0);
|
||||
$subtotal = (float)($subtotal ?? $total);
|
||||
$discountAmount = (float)($discount_amount ?? 0);
|
||||
$discountCode = (string)($discount_code ?? '');
|
||||
$currency = (string)($currency ?? 'GBP');
|
||||
$success = (string)($success ?? '');
|
||||
$orderNo = (string)($order_no ?? '');
|
||||
$error = (string)($error ?? '');
|
||||
$downloadLinks = is_array($download_links ?? null) ? $download_links : [];
|
||||
$downloadNotice = (string)($download_notice ?? '');
|
||||
$downloadLimit = (int)($download_limit ?? 5);
|
||||
$downloadExpiryDays = (int)($download_expiry_days ?? 30);
|
||||
$paypalEnabled = (bool)($paypal_enabled ?? false);
|
||||
$paypalCardsEnabled = (bool)($paypal_cards_enabled ?? false);
|
||||
$paypalCardsAvailable = (bool)($paypal_cards_available ?? false);
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card checkout-wrap">
|
||||
<div class="badge">Store</div>
|
||||
<h1 style="margin:0; font-size:32px;">Checkout</h1>
|
||||
|
||||
<?php if ($success !== ''): ?>
|
||||
<div class="checkout-status checkout-status-success">
|
||||
<div style="font-weight:700;">Order complete</div>
|
||||
<?php if ($orderNo !== ''): ?>
|
||||
<div style="margin-top:4px; color:var(--muted);">Order: <?= htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Your Downloads</div>
|
||||
<?php if ($downloadLinks): ?>
|
||||
<div style="display:grid; gap:10px; margin-top:10px;">
|
||||
<?php foreach ($downloadLinks as $link): ?>
|
||||
<?php
|
||||
$label = trim((string)($link['label'] ?? 'Download'));
|
||||
$url = trim((string)($link['url'] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
?>
|
||||
<a href="<?= htmlspecialchars($url, ENT_QUOTES, 'UTF-8') ?>" class="checkout-download-link">
|
||||
<span><?= htmlspecialchars($label !== '' ? $label : 'Download', ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<span class="checkout-download-link-arrow">Download</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p style="margin:10px 0 0; color:var(--muted); font-size:13px;">
|
||||
<?= htmlspecialchars($downloadNotice !== '' ? $downloadNotice : 'No downloads available for this order yet.', ENT_QUOTES, 'UTF-8') ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error !== ''): ?>
|
||||
<div class="checkout-status checkout-status-error"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$items): ?>
|
||||
<div class="checkout-status checkout-status-empty">Your cart is empty.</div>
|
||||
<div><a href="/releases" class="btn">Browse releases</a></div>
|
||||
<?php else: ?>
|
||||
<div class="checkout-grid">
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Order Summary</div>
|
||||
<div style="display:grid; gap:10px; margin-top:10px;">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php
|
||||
$title = (string)($item['title'] ?? 'Item');
|
||||
$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));
|
||||
$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>
|
||||
<?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-total"><?= htmlspecialchars($lineCurrency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($price * $qty, 2) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="checkout-total">
|
||||
<span>Subtotal</span>
|
||||
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($subtotal, 2) ?></strong>
|
||||
</div>
|
||||
<?php if ($discountAmount > 0): ?>
|
||||
<div class="checkout-total" style="margin-top:8px;">
|
||||
<span>Discount (<?= htmlspecialchars($discountCode, ENT_QUOTES, 'UTF-8') ?>)</span>
|
||||
<strong style="color:#9be7c6;">-<?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($discountAmount, 2) ?></strong>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="checkout-total" style="margin-top:8px;">
|
||||
<span>Order total</span>
|
||||
<strong><?= htmlspecialchars($currency, ENT_QUOTES, 'UTF-8') ?> <?= number_format($total, 2) ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-panel">
|
||||
<div class="badge" style="font-size:9px;">Buyer Details</div>
|
||||
<form method="post" action="/checkout/place" class="checkout-form-stack" id="checkoutMethodForm">
|
||||
<label class="checkout-label" for="checkoutEmail">Email</label>
|
||||
<input id="checkoutEmail" name="email" type="email" value="" placeholder="you@example.com" class="checkout-input" required>
|
||||
|
||||
<div class="checkout-terms">
|
||||
<div class="badge" style="font-size:9px;">Terms</div>
|
||||
<p class="checkout-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="checkoutTerms" type="checkbox" name="accept_terms" value="1" required>
|
||||
<span>I agree to the terms and understand all sales are final.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<style>
|
||||
.checkout-wrap { display:grid; gap:14px; }
|
||||
.checkout-grid { display:grid; grid-template-columns:minmax(0,1fr) 460px; gap:14px; }
|
||||
.checkout-panel { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(0,0,0,.2); }
|
||||
.checkout-status { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); }
|
||||
.checkout-status-success { border-color:rgba(34,242,165,.4); background:rgba(34,242,165,.12); }
|
||||
.checkout-status-error { border-color:rgba(243,176,176,.45); background:rgba(243,176,176,.12); color:#ffd6d6; }
|
||||
.checkout-status-empty { background:rgba(0,0,0,.2); color:var(--muted); }
|
||||
.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:44px; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#fff; padding:0 14px; }
|
||||
.checkout-line { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:10px; border-radius:10px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03); }
|
||||
.checkout-line-title { font-weight:600; }
|
||||
.checkout-line-meta { color:var(--muted); font-size:12px; margin-top:4px; grid-column:1/2; }
|
||||
.checkout-line-total { font-weight:700; grid-column:2/3; grid-row:1/3; align-self:center; }
|
||||
.checkout-total { margin-top:10px; display:flex; align-items:center; justify-content:space-between; padding:12px; border-radius:10px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.04); }
|
||||
.checkout-total strong { font-size:22px; }
|
||||
.checkout-terms { padding:14px; border-radius:12px; border:1px solid rgba(255,255,255,.1); background:rgba(255,255,255,.03); }
|
||||
.checkout-copy { margin:8px 0 0; color:var(--muted); font-size:13px; line-height:1.5; }
|
||||
.checkout-terms-check { margin-top:10px; display:flex; gap:8px; align-items:flex-start; color:#d7def2; font-size:13px; }
|
||||
.checkout-payment-chooser { display:grid; gap:12px; }
|
||||
.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); }
|
||||
.checkout-payment-title { font-size:18px; font-weight:700; margin-top:4px; }
|
||||
.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-place-btn:hover { background:rgba(34,242,165,.28); }
|
||||
.checkout-secondary-btn { border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#eef3ff; }
|
||||
.checkout-secondary-btn:hover { background:rgba(255,255,255,.1); }
|
||||
.checkout-download-link { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px; border-radius:10px; border:1px solid rgba(34,242,165,.35); background:rgba(34,242,165,.1); color:#d7ffef; text-decoration:none; font-weight:600; }
|
||||
.checkout-download-link:hover { background:rgba(34,242,165,.18); }
|
||||
.checkout-download-link-arrow { font-size:11px; text-transform:uppercase; letter-spacing:.14em; color:#8df7d1; }
|
||||
@media (max-width: 900px) { .checkout-grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
<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
|
||||
$content = ob_get_clean();
|
||||
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\Database;
|
||||
use Core\Services\Mailer;
|
||||
use Core\Services\RateLimiter;
|
||||
use Core\Services\Settings;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
@@ -64,6 +65,10 @@ class SupportController
|
||||
if (!filter_var($email, FILTER_VALIDATE_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) {
|
||||
if (($extraValues[(string)$requiredField] ?? '') === '') {
|
||||
return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]);
|
||||
|
||||
Reference in New Issue
Block a user