Commit 2dff900b authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(branding): one resolved brand, and the fields that were collected but never read

S2 of the mobile-portal programme. Branding lived in four uncoordinated
stores with no sync, and each layout resolved it itself in an `@php` block
issuing one SettingsService::get() per field — about sixteen SELECTs per
admin render, repeated on every Livewire round-trip, each with its own
fallback. That is how primary_color came to be defined three times with
three different defaults.

BrandingService returns a readonly BrandProfile, cached under the academy's
new `branding_version` and bumped on save, so it is held until branding
actually changes rather than for a guessed number of minutes, and a queue
worker cannot serve last week's colours. Verified on the restored oc_sport
copy: a second resolve inside one request issues 0 queries.

Defects fixed, each verified against that copy:

- `academies.address` did not exist. AcademySettings has been reading and
  writing it on every save since it was written, and Eloquent silently
  dropped it — no academy has ever had an address stored.
- `branding.academy_name` was read by the parent layout and by every printed
  sheet and written by nothing, so both showed the literal string "الكابتن"
  on every tenant. It is seeded from the academy's own name and is now
  editable. The login page now reads "او سي سبورت" on the verified tenant.
- components/print/sheet.blade.php emitted the raw storage path into an
  <img src>, so the logo was broken on every printed sheet. Paths become
  URLs in BrandingService and nowhere else.
- Guests had no academy bound at all, so the login screen — and the member
  portal's own sign-in, when it exists — rendered under the fallback brand on
  every client. An installation with exactly one academy now resolves it for
  guests too; more than one is ambiguous and binds nothing.
- AcademySettings had no authorize() call while every sibling settings screen
  does.

Dead fields: the plan's rule is wire it or delete it, and none of them
survived as collect-but-ignore. login_background now grounds the login
screen, invoice_header and invoice_footer_text and show_logo_in_invoice
reach the printed invoice, header_bg colours the topbar, compact_sidebar
narrows the rail, and success_color/danger_color colour the flash strip.

Colour derivation. sidebar.blade.php hardcoded `color: #fff` on the brand
accent — this is a tenant-branded product, so a client whose brand is yellow
got white on yellow at 1.53:1. ColorRamp derives a 50…900 OKLCH ramp plus a
foreground chosen by WCAG contrast: that same yellow now gets #111827 at
11.58:1. Nine brand colours are asserted at AA or better.

The ramp is anchored on the tenant's own lightness rather than fixed
targets, because fixed targets are non-monotonic for an inherently light
brand: yellow sits at L 0.86, so a table putting 400 at L 0.70 makes 400
darker than 500. Chroma falls steeply at the pale end — at L 0.97 a chroma
of 0.10 is outside sRGB and clips to mud.

E1 decided as the addendum recommends: `@custom-variant dark` is declared
against the `.dark` class. Roughly 900 `dark:` utilities have been compiling
to prefers-color-scheme and rendering an untested dark ERP for every OS-dark
user, while the toggle did nothing. The OS-driven rendering stops here and
the toggle becomes the only thing that switches themes.

App icons are generated with GD directly rather than by adding
intervention/image: GD is the only image extension in the Dockerfile, and
the whole job is decode, letterbox, resize, write PNG. Dimensions are read
from the header before decoding, since a small file can declare enormous
dimensions. Filenames are content-hashed because nginx serves assets
`expires 1y; immutable`.

SVG uploads are refused everywhere they were accepted. An SVG on the
academy's own origin executes script with the site's privileges and
clean_html() never sees it.

npm run build byte baseline before portal.css exists:
app.css 208.10 kB / 31.02 kB gzip, website.css 217.08 kB / 33.25 kB gzip.

Suite: 67 passed, 0 failed.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 8d251d14
<?php
namespace App\Domain\Shared\Branding;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\Storage;
/**
* Produces the square PNG icon set a PWA manifest and a native shell need,
* from whatever logo the tenant uploaded.
*
* Written against GD directly rather than pulling in intervention/image: GD is
* the one image extension the Dockerfile installs (imagick, webp and avif are
* not there), the whole job is "decode, letterbox onto a square, resize, write
* PNG", and a new Composer dependency is a permanent supply-chain and
* build-time cost for about forty lines of work.
*
* Filenames are content-hashed — `icon-{size}-{sha}.png` — because the nginx
* rule serves assets with `expires 1y; immutable`. A stable filename would pin
* a rebranded icon on every installed device for a year.
*/
class AppIconGenerator
{
/** The sizes a manifest, iOS and Android between them actually ask for. */
public const SIZES = [48, 72, 96, 128, 144, 152, 180, 192, 256, 384, 512];
private const MAX_SOURCE_PIXELS = 40_000_000; // ~6300x6300, decoded ≈ 160MB
/**
* @return array<int, string> size → storage path on the public disk
*/
public function generate(int $academyId, string $sourcePath, string $backgroundHex = '#ffffff'): array
{
if (! extension_loaded('gd')) {
throw new DomainException('امتداد الصور GD غير مُفعّل على الخادم');
}
$disk = Storage::disk('public');
if (! $disk->exists($sourcePath)) {
throw new DomainException('ملف الشعار غير موجود');
}
$bytes = $disk->get($sourcePath);
// Dimensions are read from the header BEFORE decoding: a small file can
// declare enormous dimensions and exhaust memory on imagecreatefrom*.
$info = @getimagesizefromstring($bytes);
if ($info === false) {
throw new DomainException('تعذّرت قراءة ملف الشعار — يجب أن يكون PNG أو JPG');
}
[$width, $height, $type] = $info;
if (! in_array($type, [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_WEBP], true)) {
throw new DomainException('صيغة الشعار غير مدعومة — استخدم PNG أو JPG');
}
if ($width * $height > self::MAX_SOURCE_PIXELS) {
throw new DomainException('أبعاد الشعار كبيرة جداً');
}
$source = @imagecreatefromstring($bytes);
if ($source === false) {
throw new DomainException('تعذّر فك ترميز ملف الشعار');
}
[$bgR, $bgG, $bgB] = ColorRamp::hexToRgb($backgroundHex);
$written = [];
try {
foreach (self::SIZES as $size) {
$canvas = imagecreatetruecolor($size, $size);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
// Solid ground, not transparency: iOS composites a transparent
// icon onto black and the logo disappears.
$bg = imagecolorallocate($canvas, $bgR, $bgG, $bgB);
imagefilledrectangle($canvas, 0, 0, $size, $size, $bg);
imagealphablending($canvas, true);
// Letterbox with a 12% margin so the logo is never cropped and
// never touches the rounded-corner mask.
$box = (int) round($size * 0.76);
$scale = min($box / $width, $box / $height);
$w = max(1, (int) round($width * $scale));
$h = max(1, (int) round($height * $scale));
imagecopyresampled(
$canvas, $source,
(int) (($size - $w) / 2), (int) (($size - $h) / 2),
0, 0,
$w, $h, $width, $height
);
ob_start();
imagepng($canvas, null, 9);
$png = (string) ob_get_clean();
imagedestroy($canvas);
$hash = substr(hash('sha256', $png), 0, 12);
$path = "branding/{$academyId}/icons/icon-{$size}-{$hash}.png";
$disk->put($path, $png);
$written[$size] = $path;
}
} finally {
imagedestroy($source);
}
$this->pruneStaleIcons($academyId, $written);
return $written;
}
/** Content-hashed names accumulate; keep only the set just written. */
private function pruneStaleIcons(int $academyId, array $keep): void
{
$disk = Storage::disk('public');
$dir = "branding/{$academyId}/icons";
if (! $disk->exists($dir)) {
return;
}
$keepPaths = array_flip($keep);
foreach ($disk->files($dir) as $file) {
if (! isset($keepPaths[$file])) {
$disk->delete($file);
}
}
}
}
<?php
namespace App\Domain\Shared\Branding;
/**
* Everything a surface needs to render a tenant's brand, resolved once.
*
* Before this existed, each layout carried its own `@php` block issuing one
* SettingsService::get() per field — ~16 SELECTs per admin render, repeated on
* every Livewire round-trip — and each block chose its own fallback, so
* primary_color had three different defaults across three files.
*
* Every value is a finished value: colours are validated hex, images are URLs
* (never raw storage paths, which is what components/print/sheet.blade.php was
* emitting into an <img src> and why the logo was broken on every printed
* sheet), and each brand colour arrives with a shade ramp and a foreground
* colour that actually contrasts with it.
*/
final readonly class BrandProfile
{
/**
* @param array<string, ColorRamp> $ramps keyed by role: primary, secondary, accent…
*/
public function __construct(
public int $academyId,
public int $version,
public string $academyName,
public string $appShortName,
public ?string $address,
public ?string $phone,
public ?string $email,
public ?string $logoUrl,
public ?string $logoDarkUrl,
public ?string $faviconUrl,
public ?string $signatureUrl,
public ?string $loginBackgroundUrl,
public ?string $invoiceHeaderUrl,
public ?string $appIconUrl,
public ?string $splashImageUrl,
public string $primary,
public string $secondary,
public string $accent,
public string $sidebarBg,
public string $sidebarText,
public string $sidebarActive,
public string $headerBg,
public string $success,
public string $warning,
public string $danger,
public string $themeColor,
public string $themeMode,
public string $fontAr,
public string $fontEn,
public int $fontSizeBase,
public string $invoiceFooterText,
public string $receiptFooterText,
public string $termsAndConditions,
public bool $showLogoInSidebar,
public bool $showLogoInInvoice,
public bool $showSignatureInInvoice,
public bool $compactSidebar,
public array $ramps,
) {}
public function ramp(string $role): ColorRamp
{
return $this->ramps[$role] ?? $this->ramps['primary'];
}
/**
* The `:root` custom properties every surface binds to. Emitted server-side
* once per document; Tailwind's `@theme inline` block reads these at
* runtime rather than baking a copy at build time.
*
* @return array<string, string>
*/
public function cssVariables(): array
{
$vars = [
'--brand-primary' => $this->primary,
'--brand-secondary' => $this->secondary,
'--brand-accent' => $this->accent,
'--brand-sidebar-bg' => $this->sidebarBg,
'--brand-sidebar-text' => $this->sidebarText,
'--brand-sidebar-active' => $this->sidebarActive,
'--brand-header-bg' => $this->headerBg,
'--brand-success' => $this->success,
'--brand-warning' => $this->warning,
'--brand-danger' => $this->danger,
'--brand-font-size' => $this->fontSizeBase . 'px',
'--brand-font-ar' => "'{$this->fontAr}'",
'--brand-font-en' => "'{$this->fontEn}'",
];
foreach ($this->ramps as $role => $ramp) {
$prefix = $role === 'primary' ? '--brand' : "--brand-{$role}";
foreach ($ramp->shades as $step => $value) {
$vars["{$prefix}-{$step}"] = $value;
}
$vars["{$prefix}-fg"] = $ramp->foreground;
}
return $vars;
}
public function cssVariableBlock(): string
{
$lines = [];
foreach ($this->cssVariables() as $name => $value) {
$lines[] = " {$name}: {$value};";
}
return implode("\n", $lines);
}
}
<?php
namespace App\Domain\Shared\Branding;
/**
* A full 50…900 shade ramp derived from one tenant colour, plus a foreground
* colour that actually contrasts with the base.
*
* The ERP hardcoded `#fff` as the text colour on the brand accent
* (components/layouts/sidebar.blade.php:184). A tenant whose brand is yellow
* got white text on yellow — about 1.3:1, unreadable, and no amount of design
* work downstream can rescue it because the value is baked into the markup.
*
* The ramp is generated in OKLCH, where lightness is perceptual: stepping L
* evenly produces shades that look evenly spaced, which stepping HSL lightness
* does not. It is computed once per branding_version in PHP and emitted as
* custom properties, never with color-mix() at render time — a color-mix()
* against a non-inline @theme variable resolves against the build-time copy,
* not the tenant's runtime value.
*/
final readonly class ColorRamp
{
/**
* @param array<int, string> $shades 50…900 → oklch() strings
* @param string $foreground black or white, whichever contrasts with base
*/
public function __construct(
public string $base,
public array $shades,
public string $foreground,
) {}
/**
* step => [how far to travel from the base colour, share of its chroma].
*
* The lightness is anchored on the tenant's own colour and travels toward
* a near-white or near-black limit, rather than aiming at fixed targets.
* Fixed targets break for any brand that is inherently light or dark: a
* yellow sits at L 0.86, so a table that puts 400 at L 0.70 makes 400
* *darker* than 500 and the ramp stops meaning anything.
*
* The chroma share is deliberately steep at the pale end. At L 0.97 a
* chroma of 0.10 is outside sRGB entirely, and the browser clips it to
* something muddy — the pale shades of a real ramp keep only a few percent
* of the base saturation.
*
* 500 is the tenant's own colour, untouched.
*/
private const LIGHT_LIMIT = 0.985;
private const DARK_LIMIT = 0.180;
private const STEPS = [
50 => [-0.92, 0.06],
100 => [-0.82, 0.12],
200 => [-0.66, 0.22],
300 => [-0.46, 0.40],
400 => [-0.24, 0.70],
500 => [0.00, 1.00],
600 => [0.13, 0.98],
700 => [0.30, 0.90],
800 => [0.50, 0.78],
900 => [0.70, 0.62],
];
public static function fromHex(string $hex): self
{
[$r, $g, $b] = self::hexToRgb($hex);
[$l, $c, $h] = self::rgbToOklch($r, $g, $b);
$shades = [];
foreach (self::STEPS as $step => [$travel, $chromaShare]) {
$lightness = $travel < 0
? $l + (self::LIGHT_LIMIT - $l) * -$travel
: $l - ($l - self::DARK_LIMIT) * $travel;
$shades[$step] = self::oklch($lightness, $c * $chromaShare, $h);
}
return new self(
base: self::normalizeHex($hex),
shades: $shades,
foreground: self::readableForeground($r, $g, $b),
);
}
/**
* WCAG relative luminance, then whichever of black or white gives the
* higher contrast ratio against it. Both candidates are checked rather
* than assuming a luminance threshold, because the threshold sits at a
* different place for different hues.
*/
public static function readableForeground(int $r, int $g, int $b): string
{
$luminance = self::relativeLuminance($r, $g, $b);
$againstWhite = 1.05 / ($luminance + 0.05);
$againstBlack = ($luminance + 0.05) / 0.05;
return $againstBlack >= $againstWhite ? '#111827' : '#ffffff';
}
public static function contrastRatio(string $hexA, string $hexB): float
{
[$r1, $g1, $b1] = self::hexToRgb($hexA);
[$r2, $g2, $b2] = self::hexToRgb($hexB);
$l1 = self::relativeLuminance($r1, $g1, $b1);
$l2 = self::relativeLuminance($r2, $g2, $b2);
[$light, $dark] = $l1 >= $l2 ? [$l1, $l2] : [$l2, $l1];
return ($light + 0.05) / ($dark + 0.05);
}
private static function relativeLuminance(int $r, int $g, int $b): float
{
$channel = function (int $v): float {
$s = $v / 255;
return $s <= 0.03928 ? $s / 12.92 : (($s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * $channel($r) + 0.7152 * $channel($g) + 0.0722 * $channel($b);
}
/** @return array{int, int, int} */
public static function hexToRgb(string $hex): array
{
$hex = ltrim(trim($hex), '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (! preg_match('/^[0-9a-fA-F]{6}$/', $hex)) {
$hex = '2563eb';
}
return [
(int) hexdec(substr($hex, 0, 2)),
(int) hexdec(substr($hex, 2, 2)),
(int) hexdec(substr($hex, 4, 2)),
];
}
public static function normalizeHex(string $hex): string
{
[$r, $g, $b] = self::hexToRgb($hex);
return sprintf('#%02x%02x%02x', $r, $g, $b);
}
/**
* sRGB → linear → CIE XYZ (D65) → OKLab → OKLCH.
*
* @return array{float, float, float} [L 0..1, C, H degrees]
*/
public static function rgbToOklch(int $r, int $g, int $b): array
{
$lin = function (int $v): float {
$s = $v / 255;
return $s <= 0.04045 ? $s / 12.92 : (($s + 0.055) / 1.055) ** 2.4;
};
$lr = $lin($r);
$lg = $lin($g);
$lb = $lin($b);
$l = 0.4122214708 * $lr + 0.5363325363 * $lg + 0.0514459929 * $lb;
$m = 0.2119034982 * $lr + 0.6806995451 * $lg + 0.1073969566 * $lb;
$s = 0.0883024619 * $lr + 0.2817188376 * $lg + 0.6299787005 * $lb;
$l_ = $l ** (1 / 3);
$m_ = $m ** (1 / 3);
$s_ = $s ** (1 / 3);
$okL = 0.2104542553 * $l_ + 0.7936177850 * $m_ - 0.0040720468 * $s_;
$okA = 1.9779984951 * $l_ - 2.4285922050 * $m_ + 0.4505937099 * $s_;
$okB = 0.0259040371 * $l_ + 0.7827717662 * $m_ - 0.8086757660 * $s_;
$chroma = sqrt($okA ** 2 + $okB ** 2);
$hue = rad2deg(atan2($okB, $okA));
if ($hue < 0) {
$hue += 360;
}
return [$okL, $chroma, $hue];
}
private static function oklch(float $l, float $c, float $h): string
{
return sprintf('oklch(%.4f %.4f %.2f)', max(0, min(1, $l)), max(0, $c), $h);
}
}
......@@ -19,16 +19,19 @@ class Academy extends Model
'slug',
'email',
'phone',
'address',
'logo_path',
'currency',
'timezone',
'locale',
'settings',
'branding_version',
'status',
];
protected $casts = [
'settings' => 'array',
'branding_version' => 'integer',
];
public function users(): HasMany
......
<?php
namespace App\Domain\Shared\Services;
use App\Domain\Shared\Branding\BrandProfile;
use App\Domain\Shared\Branding\ColorRamp;
use App\Domain\Shared\Models\Academy;
use App\Domain\Shared\Models\SystemSetting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
/**
* The single read API for a tenant's brand — admin, portal, website, print.
*
* Every layout used to resolve branding itself in an `@php` block, one
* SettingsService::get() per field. Each of those is its own SELECT with no
* memoisation, so an admin page render issued about sixteen of them and did it
* again on every Livewire round-trip. Worse, each block picked its own
* fallback, which is how primary_color came to have three different defaults
* in three files.
*
* A whole profile is cached under the academy's `branding_version`, so it is
* held until branding actually changes rather than for a guessed number of
* minutes, and `bump()` on save invalidates it everywhere at once — including
* the queue workers and the scheduler, which a request-scoped memo would miss.
*/
class BrandingService
{
/** @var array<int, BrandProfile> per-request memo on top of the cache */
private array $memo = [];
private const COLOR_DEFAULTS = [
'primary_color' => '#2563eb',
'secondary_color' => '#7c3aed',
'accent_color' => '#059669',
'sidebar_bg' => '#0f172a',
'sidebar_text' => '#e2e8f0',
'sidebar_active' => '#2563eb',
'header_bg' => '#ffffff',
'success_color' => '#10b981',
'warning_color' => '#f59e0b',
'danger_color' => '#ef4444',
'theme_color' => '#2563eb',
];
public function forCurrentAcademy(): BrandProfile
{
$academy = app()->has('current_academy') ? app('current_academy') : null;
return $this->for($academy?->id);
}
public function for(?int $academyId): BrandProfile
{
if (! $academyId) {
return $this->fallbackProfile();
}
if (isset($this->memo[$academyId])) {
return $this->memo[$academyId];
}
$academy = Academy::find($academyId);
if (! $academy) {
return $this->fallbackProfile();
}
$version = (int) ($academy->branding_version ?? 1);
return $this->memo[$academyId] = Cache::rememberForever(
"branding:{$academyId}:v{$version}",
fn () => $this->build($academy, $version)
);
}
/**
* Invalidate every cached copy of an academy's brand.
*
* Bumping the version rather than forgetting a key means a worker holding
* a stale profile misses on its next read instead of serving last week's
* colours, and it needs no coordination between processes.
*/
public function bump(int $academyId): void
{
unset($this->memo[$academyId]);
Academy::whereKey($academyId)->increment('branding_version');
}
private function build(Academy $academy, int $version): BrandProfile
{
$settings = $this->loadBrandingGroup($academy->id);
$colors = [];
foreach (self::COLOR_DEFAULTS as $key => $default) {
$colors[$key] = $this->hex($settings["branding.{$key}"] ?? null, $default);
}
$ramps = [
'primary' => ColorRamp::fromHex($colors['primary_color']),
'secondary' => ColorRamp::fromHex($colors['secondary_color']),
'accent' => ColorRamp::fromHex($colors['accent_color']),
];
return new BrandProfile(
academyId: $academy->id,
version: $version,
academyName: (string) ($settings['branding.academy_name'] ?? '') ?: ($academy->name_ar ?: $academy->name),
appShortName: (string) ($settings['branding.app_short_name'] ?? '') ?: ($academy->name_ar ?: $academy->name),
address: $academy->address ?: null,
phone: $academy->phone ?: null,
email: $academy->email ?: null,
logoUrl: $this->url($settings['branding.logo'] ?? $academy->logo_path),
logoDarkUrl: $this->url($settings['branding.logo_dark'] ?? null),
faviconUrl: $this->url($settings['branding.favicon'] ?? null),
signatureUrl: $this->url($settings['branding.signature'] ?? null),
loginBackgroundUrl: $this->url($settings['branding.login_background'] ?? null),
invoiceHeaderUrl: $this->url($settings['branding.invoice_header'] ?? null),
appIconUrl: $this->url($settings['branding.app_icon'] ?? null),
splashImageUrl: $this->url($settings['branding.splash_image'] ?? null),
primary: $colors['primary_color'],
secondary: $colors['secondary_color'],
accent: $colors['accent_color'],
sidebarBg: $colors['sidebar_bg'],
sidebarText: $colors['sidebar_text'],
sidebarActive: $colors['sidebar_active'],
headerBg: $colors['header_bg'],
success: $colors['success_color'],
warning: $colors['warning_color'],
danger: $colors['danger_color'],
themeColor: $colors['theme_color'],
themeMode: in_array($settings['branding.theme_mode'] ?? 'light', ['light', 'dark', 'auto'], true)
? (string) $settings['branding.theme_mode']
: 'light',
fontAr: $this->fontName($settings['branding.font_family_ar'] ?? null, 'Cairo'),
fontEn: $this->fontName($settings['branding.font_family_en'] ?? null, 'Inter'),
fontSizeBase: max(12, min(18, (int) ($settings['branding.font_size_base'] ?? 14))),
invoiceFooterText: (string) ($settings['branding.invoice_footer_text'] ?? ''),
receiptFooterText: (string) ($settings['branding.receipt_footer_text'] ?? ''),
termsAndConditions: (string) ($settings['branding.terms_and_conditions'] ?? ''),
showLogoInSidebar: $this->bool($settings['branding.show_logo_in_sidebar'] ?? true),
showLogoInInvoice: $this->bool($settings['branding.show_logo_in_invoice'] ?? true),
showSignatureInInvoice: $this->bool($settings['branding.show_signature_in_invoice'] ?? false),
compactSidebar: $this->bool($settings['branding.compact_sidebar'] ?? false),
ramps: $ramps,
);
}
/** One query for the whole group, replacing one query per field. */
private function loadBrandingGroup(int $academyId): array
{
return SystemSetting::withoutGlobalScope('academy')
->where('academy_id', $academyId)
->where('group', 'branding')
->get()
->mapWithKeys(fn ($s) => [$s->key => $s->value])
->all();
}
/**
* A stored path becomes a URL here and nowhere else. Emitting the raw path
* is what broke the logo on every printed sheet.
*/
private function url(?string $path): ?string
{
if (! $path) {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return $path;
}
return Storage::disk('public')->url($path);
}
private function hex(?string $value, string $default): string
{
$value = trim((string) $value);
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $default;
}
/**
* The font name is interpolated into a Google Fonts URL and into a CSS
* font-family declaration, so it may only ever be a plain font name.
*/
private function fontName(?string $value, string $default): string
{
$value = trim((string) $value);
return preg_match('/^[A-Za-z0-9 ]{1,40}$/', $value) ? $value : $default;
}
private function bool(mixed $value): bool
{
return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value;
}
private function fallbackProfile(): BrandProfile
{
$colors = self::COLOR_DEFAULTS;
return new BrandProfile(
academyId: 0,
version: 0,
academyName: 'الكابتن',
appShortName: 'الكابتن',
address: null,
phone: null,
email: null,
logoUrl: null,
logoDarkUrl: null,
faviconUrl: null,
signatureUrl: null,
loginBackgroundUrl: null,
invoiceHeaderUrl: null,
appIconUrl: null,
splashImageUrl: null,
primary: $colors['primary_color'],
secondary: $colors['secondary_color'],
accent: $colors['accent_color'],
sidebarBg: $colors['sidebar_bg'],
sidebarText: $colors['sidebar_text'],
sidebarActive: $colors['sidebar_active'],
headerBg: $colors['header_bg'],
success: $colors['success_color'],
warning: $colors['warning_color'],
danger: $colors['danger_color'],
themeColor: $colors['theme_color'],
themeMode: 'light',
fontAr: 'Cairo',
fontEn: 'Inter',
fontSizeBase: 14,
invoiceFooterText: '',
receiptFooterText: '',
termsAndConditions: '',
showLogoInSidebar: true,
showLogoInInvoice: true,
showSignatureInInvoice: false,
compactSidebar: false,
ramps: [
'primary' => ColorRamp::fromHex($colors['primary_color']),
'secondary' => ColorRamp::fromHex($colors['secondary_color']),
'accent' => ColorRamp::fromHex($colors['accent_color']),
],
);
}
}
......@@ -5,12 +5,38 @@
use App\Domain\Shared\Models\Academy;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class SetCurrentAcademy
{
public function handle(Request $request, Closure $next): Response
{
// A guest had no academy bound at all, so the login screen, the public
// website and — once it exists — the member portal's own sign-in all
// rendered under the fallback brand: the literal name "الكابتن" and the
// stock blue, on every client.
//
// Every installation is one client with one academy, so when there is
// exactly one it is unambiguous. More than one means a shared
// installation where guessing would be wrong, and nothing is bound.
if (! $request->user() && ! app()->has('current_academy')) {
try {
$sole = Cache::remember('academy:sole', 300, function () {
return Academy::query()->where('status', 'active')->count() === 1
? Academy::query()->where('status', 'active')->first()?->id
: null;
});
if ($sole && $academy = Academy::find($sole)) {
app()->instance('current_academy', $academy);
}
} catch (\Throwable) {
// Guest branding is a nicety. A database that is not reachable
// — or not yet migrated — must not turn a 404 into a 500.
}
}
if ($user = $request->user()) {
// Load academy
if ($user->academy_id) {
......
......@@ -21,6 +21,10 @@ class AcademySettings extends Component
public function mount(): void
{
// Every sibling settings screen authorizes; this one did not, so any
// authenticated user who reached the route could rename the academy.
$this->authorize('settings.manage');
$academy = app('current_academy');
if ($academy) {
$this->name = $academy->name ?? '';
......@@ -43,6 +47,8 @@ public function save(): void
'address' => 'nullable|string',
]);
$this->authorize('settings.manage');
$academy = app('current_academy');
$academy->update([
'name' => $this->name,
......@@ -54,7 +60,10 @@ public function save(): void
'currency' => $this->currency,
]);
session()->flash('success', 'تم حفظ الإعدادات بنجاح');
// The academy name and address feed the resolved brand profile.
app(\App\Domain\Shared\Services\BrandingService::class)->bump($academy->id);
session()->flash('success', __('تم حفظ الإعدادات بنجاح'));
}
public function render()
......
......@@ -2,7 +2,10 @@
namespace App\Livewire\Settings;
use App\Domain\Shared\Branding\AppIconGenerator;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;
use App\Domain\Shared\Services\BrandingService;
use App\Domain\Shared\Services\SettingsService;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
......@@ -55,6 +58,16 @@ class BrandingSettings extends Component
public string $receipt_footer_text = '';
public string $terms_and_conditions = '';
// Mobile / PWA identity
public string $academy_name = '';
public string $app_short_name = '';
public string $theme_color = '#2563eb';
public string $theme_mode = 'light';
public ?string $current_app_icon = null;
public ?string $current_splash_image = null;
public $app_icon;
public $splash_image;
// Display options
public bool $show_logo_in_sidebar = true;
public bool $show_logo_in_invoice = true;
......@@ -97,26 +110,37 @@ public function mount(): void
$this->show_logo_in_invoice = (bool) $settings->get('branding.show_logo_in_invoice', true);
$this->show_signature_in_invoice = (bool) $settings->get('branding.show_signature_in_invoice', false);
$this->compact_sidebar = (bool) $settings->get('branding.compact_sidebar', false);
$academy = app('current_academy');
// branding.academy_name is read by the parent layout and by every
// printed sheet, and was written by nothing — so both fell back to the
// literal string "الكابتن" on every tenant.
$this->academy_name = $settings->get('branding.academy_name') ?: ($academy->name_ar ?: $academy->name);
$this->app_short_name = $settings->get('branding.app_short_name') ?: $this->academy_name;
$this->theme_color = $settings->get('branding.theme_color', '#2563eb');
$this->theme_mode = $settings->get('branding.theme_mode', 'light');
$this->current_app_icon = $settings->get('branding.app_icon') ?: null;
$this->current_splash_image = $settings->get('branding.splash_image') ?: null;
}
public function updatedLogo(): void
{
$this->validate(['logo' => 'image|max:2048|mimes:png,jpg,jpeg,svg,webp']);
$this->validate(['logo' => 'image|max:2048|mimes:png,jpg,jpeg,webp']);
}
public function updatedLogoDark(): void
{
$this->validate(['logo_dark' => 'image|max:2048|mimes:png,jpg,jpeg,svg,webp']);
$this->validate(['logo_dark' => 'image|max:2048|mimes:png,jpg,jpeg,webp']);
}
public function updatedFavicon(): void
{
$this->validate(['favicon' => 'image|max:512|mimes:png,ico,svg']);
$this->validate(['favicon' => 'image|max:512|mimes:png,ico']);
}
public function updatedSignature(): void
{
$this->validate(['signature' => 'image|max:1024|mimes:png,jpg,jpeg,svg']);
$this->validate(['signature' => 'image|max:1024|mimes:png,jpg,jpeg']);
}
public function updatedLoginBackground(): void
......@@ -126,7 +150,7 @@ public function updatedLoginBackground(): void
public function updatedInvoiceHeader(): void
{
$this->validate(['invoice_header' => 'image|max:2048|mimes:png,jpg,jpeg,svg']);
$this->validate(['invoice_header' => 'image|max:2048|mimes:png,jpg,jpeg']);
}
public function removeImage(string $field): void
......@@ -141,17 +165,25 @@ public function removeImage(string $field): void
$settings->set($key, null, 'branding', 'string');
$this->{"current_{$field}"} = null;
app(BrandingService::class)->bump(app('current_academy')->id);
}
public function save(): void
{
$this->validate([
'logo' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,svg,webp',
'logo_dark' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,svg,webp',
'favicon' => 'nullable|image|max:512|mimes:png,ico,svg',
'signature' => 'nullable|image|max:1024|mimes:png,jpg,jpeg,svg',
'logo' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,webp',
'logo_dark' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,webp',
'favicon' => 'nullable|image|max:512|mimes:png,ico',
'signature' => 'nullable|image|max:1024|mimes:png,jpg,jpeg',
'login_background' => 'nullable|image|max:5120|mimes:png,jpg,jpeg,webp',
'invoice_header' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,svg',
'invoice_header' => 'nullable|image|max:2048|mimes:png,jpg,jpeg',
'app_icon' => 'nullable|image|max:2048|mimes:png,jpg,jpeg',
'splash_image' => 'nullable|image|max:4096|mimes:png,jpg,jpeg',
'academy_name' => 'required|string|max:120',
'app_short_name' => 'required|string|max:24',
'theme_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
'theme_mode' => 'required|in:light,dark,auto',
'primary_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
'secondary_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
'accent_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
......@@ -173,6 +205,8 @@ public function save(): void
'signature' => $this->signature,
'login_background' => $this->login_background,
'invoice_header' => $this->invoice_header,
'app_icon' => $this->app_icon,
'splash_image' => $this->splash_image,
];
foreach ($uploads as $field => $file) {
......@@ -207,6 +241,11 @@ public function save(): void
$settings->set('branding.receipt_footer_text', $this->receipt_footer_text, 'branding', 'string');
$settings->set('branding.terms_and_conditions', $this->terms_and_conditions, 'branding', 'string');
$settings->set('branding.academy_name', $this->academy_name, 'branding', 'string');
$settings->set('branding.app_short_name', $this->app_short_name, 'branding', 'string');
$settings->set('branding.theme_color', $this->theme_color, 'branding', 'string');
$settings->set('branding.theme_mode', $this->theme_mode, 'branding', 'string');
$settings->set('branding.show_logo_in_sidebar', $this->show_logo_in_sidebar ? '1' : '0', 'branding', 'boolean');
$settings->set('branding.show_logo_in_invoice', $this->show_logo_in_invoice ? '1' : '0', 'branding', 'boolean');
$settings->set('branding.show_signature_in_invoice', $this->show_signature_in_invoice ? '1' : '0', 'branding', 'boolean');
......@@ -216,6 +255,26 @@ public function save(): void
$academy->update(['logo_path' => $this->current_logo]);
}
// Regenerate the square icon set the PWA manifest and the native shell
// read. Content-hashed filenames, because nginx serves assets with
// `expires 1y; immutable` — a stable name would pin a rebranded icon on
// every installed device for a year.
$iconSource = $this->current_app_icon ?: $this->current_logo;
if ($iconSource) {
try {
$icons = app(AppIconGenerator::class)->generate($academy->id, $iconSource, '#ffffff');
$settings->set('branding.icon_set', json_encode($icons), 'branding', 'json');
} catch (DomainException $e) {
// A bad logo must not block a colour change.
session()->flash('error', $e->getMessage());
}
}
// Everything above is cached under the academy's branding_version, so
// nothing changes anywhere until the version moves.
app(BrandingService::class)->bump($academy->id);
session()->flash('success', __('تم حفظ إعدادات الهوية البصرية بنجاح'));
}
......
......@@ -19,6 +19,14 @@ public function register(): void
// One branch context per request — and per Livewire round-trip, since
// each of those is its own request.
$this->app->scoped(BranchContext::class);
// One resolved brand per request. The per-request memo inside it is
// what stops a Livewire round-trip re-reading the whole branding group.
$this->app->scoped(\App\Domain\Shared\Services\BrandingService::class);
// One account resolver too — it caches chart-of-accounts lookups by
// code for the life of the request.
$this->app->scoped(\App\Domain\Financial\Services\LedgerAccountResolver::class);
}
/**
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Branding lives in four uncoordinated stores today — `academies` columns,
* `system_settings` (group `branding`), `website_settings` and `media` — with
* `primary_color` defined three times with three different defaults.
*
* Two concrete defects this fixes:
*
* - `academies.address` does not exist. AcademySettings reads and writes it
* on every save and Eloquent silently drops it, so an academy's address has
* never once been stored. It is also the only place the portal and the
* website can get a postal address from.
*
* - `branding.academy_name` is read by the parent layout and by every printed
* sheet, and written by nothing — so both fall back to the literal string
* "الكابتن" on every tenant. It is seeded here from the academy's own name.
*
* `branding_version` is the cache key BrandingService reads a whole profile
* under. It is bumped on save, which is what makes a cached profile safe to
* hold forever instead of re-issuing ~16 SELECTs per admin render.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('academies')) {
return;
}
Schema::table('academies', function (Blueprint $table) {
if (! Schema::hasColumn('academies', 'address')) {
$table->text('address')->nullable()->after('phone');
}
if (! Schema::hasColumn('academies', 'branding_version')) {
$table->unsignedInteger('branding_version')->default(1)->after('settings');
}
});
if (! Schema::hasTable('system_settings')) {
return;
}
foreach (DB::table('academies')->get(['id', 'name', 'name_ar']) as $academy) {
$this->seedSetting($academy->id, 'branding.academy_name', $academy->name_ar ?: $academy->name, 'string');
// New branding slots the portal, the PWA manifest and the Flutter
// shell all read. Seeded so a fresh tenant is never unbranded.
$this->seedSetting($academy->id, 'branding.theme_color', '#2563eb', 'string');
$this->seedSetting($academy->id, 'branding.theme_mode', 'light', 'string');
$this->seedSetting($academy->id, 'branding.app_icon', '', 'string');
$this->seedSetting($academy->id, 'branding.splash_image', '', 'string');
$this->seedSetting($academy->id, 'branding.app_short_name', $academy->name_ar ?: $academy->name, 'string');
}
}
private function seedSetting(int $academyId, string $key, ?string $value, string $type): void
{
$exists = DB::table('system_settings')
->where('academy_id', $academyId)
->where('key', $key)
->exists();
if ($exists) {
return;
}
DB::table('system_settings')->insert([
'academy_id' => $academyId,
'key' => $key,
'value' => (string) $value,
'group' => 'branding',
'type' => $type,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
if (Schema::hasTable('academies')) {
Schema::table('academies', function (Blueprint $table) {
if (Schema::hasColumn('academies', 'branding_version')) {
$table->dropColumn('branding_version');
}
if (Schema::hasColumn('academies', 'address')) {
$table->dropColumn('address');
}
});
}
}
};
{
"name": "el-captain-sports-management",
"name": "El-Captain",
"lockfileVersion": 3,
"requires": true,
"packages": {
......
@import 'tailwindcss';
/*
Dark mode: own it, not inherit it.
With no `@custom-variant dark` declared, Tailwind v4 compiles every `dark:`
utility to `prefers-color-scheme: dark` — so the ~900 `dark:` classes already
in this codebase have been rendering an untested dark ERP for every OS-dark
user, while the `.dark` class toggle in dark-mode-toggle.blade.php did nothing
at all. Binding the variant to the class stops the OS-driven rendering the
moment this ships and makes the toggle the only thing that switches themes.
*/
@custom-variant dark (&:where(.dark, .dark *));
/*
Tenant brand tokens as Tailwind utilities.
`inline` matters: without it Tailwind copies the variable's value at build time,
so every tenant would get whatever colour happened to be in the build. With it
the utility emits `var(--brand-500)`, which the server-rendered `:root` block
in the layout fills in per tenant per request.
*/
@theme inline {
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-brand-fg: var(--brand-fg);
--color-brand-accent: var(--brand-accent);
--color-brand-success: var(--brand-success);
--color-brand-warning: var(--brand-warning);
--color-brand-danger: var(--brand-danger);
}
/*
Dynamic status badge colors used via Blade interpolation — Tailwind scanner needs
these as complete strings to include them in the build output:
......
......@@ -155,17 +155,16 @@
<aside dir="rtl"
:class="sidebarOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'"
class="fixed top-0 start-0 h-screen w-64 flex flex-col z-40 overflow-hidden transition-transform duration-300 ease-in-out lg:translate-x-0"
class="app-sidebar fixed top-0 start-0 h-screen w-64 flex flex-col z-40 overflow-hidden transition-transform duration-300 ease-in-out lg:translate-x-0"
style="background-color: var(--brand-sidebar-bg, #0f172a); color: var(--brand-sidebar-text, #e2e8f0);">
{{-- Logo + Mobile Close --}}
<div class="flex items-center justify-between h-16 border-b border-white/10 px-4 shrink-0">
<div class="flex items-center justify-center flex-1">
@if(isset($brandLogoDark) && $brandLogoDark && isset($showLogoSidebar) && $showLogoSidebar)
<img src="{{ Storage::disk('public')->url($brandLogoDark) }}" alt="" class="h-10 max-w-[140px] object-contain">
@elseif(isset($brandLogo) && $brandLogo && isset($showLogoSidebar) && $showLogoSidebar)
<img src="{{ Storage::disk('public')->url($brandLogo) }}" alt="" class="h-10 max-w-[140px] object-contain">
@php $brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy(); @endphp
@if($brand->showLogoInSidebar && ($brand->logoDarkUrl ?? $brand->logoUrl))
<img src="{{ $brand->logoDarkUrl ?? $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="h-10 max-w-[140px] object-contain">
@else
<h1 class="text-xl font-bold">الكابتن</h1>
<h1 class="text-xl font-bold">{{ $brand->academyName }}</h1>
@endif
</div>
<button @click="sidebarOpen = false" class="lg:hidden flex items-center justify-center w-10 h-10 rounded-lg text-white/70 hover:text-white hover:bg-white/10 active:bg-white/20 transition-colors" aria-label="{{ __('إغلاق القائمة') }}">
......@@ -183,7 +182,7 @@ class="fixed top-0 start-0 h-screen w-64 flex flex-col z-40 overflow-hidden tran
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 hover:bg-white/10 active:bg-white/20 active:scale-[0.97]"
style="{{ request()->routeIs($item['route'] . '*') ? 'background-color: var(--brand-sidebar-active, #2563eb); color: #fff;' : 'color: var(--brand-sidebar-text, #e2e8f0);' }}">
<x-ui.icon :name="$item['icon']" class="w-5 h-5 shrink-0" />
<span>{{ $item['label'] }}</span>
<span class="sidebar-label">{{ $item['label'] }}</span>
</a>
@endif
......@@ -211,7 +210,7 @@ class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transi
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-150 hover:bg-white/10 active:bg-white/20 active:scale-[0.97]"
style="{{ request()->routeIs(Str::before($child['route'], '.index') . '.*') ? 'background-color: var(--brand-sidebar-active, #2563eb); color: #fff;' : 'color: var(--brand-sidebar-text, #e2e8f0);' }}">
<x-ui.icon :name="$child['icon']" class="w-5 h-5 shrink-0" />
<span>{{ $child['label'] }}</span>
<span class="sidebar-label">{{ $child['label'] }}</span>
</a>
@endforeach
</div>
......
<header class="sticky top-0 z-30 bg-white border-b border-gray-200 safe-top" data-topbar>
{{-- header_bg was collected by the branding screen and read by nothing. --}}
<header class="sticky top-0 z-30 border-b border-gray-200 safe-top" data-topbar
style="background-color: var(--brand-header-bg, #ffffff);">
<div class="flex items-center justify-between h-14 sm:h-16 px-3 sm:px-6 lg:px-8">
<!-- Mobile menu button -->
<button @click="sidebarOpen = !sidebarOpen" class="lg:hidden p-2 -ms-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100 active:bg-gray-200 transition-colors">
......
......@@ -7,12 +7,14 @@
])
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandLogo = $branding->get('branding.logo');
$brandPrimary = $branding->get('branding.primary_color', '#1e40af');
$academyName = $branding->get('branding.academy_name')
?? (app()->has('current_academy') ? app('current_academy')->name_ar : config('app.name'));
$footerText = $branding->get('branding.receipt_footer_text', '');
// The logo used to be emitted as its raw storage path — `branding/1/logo/x.png`
// straight into an <img src> — so it was broken on every printed sheet.
// BrandingService returns finished URLs and nothing else.
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
$brandLogo = $brand->logoUrl;
$brandPrimary = $brand->primary;
$academyName = $brand->academyName;
$footerText = $brand->receiptFooterText;
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="ar">
......
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandPrimary = $branding->get('branding.primary_color', '#2563eb');
$brandSecondary = $branding->get('branding.secondary_color', '#7c3aed');
$brandAccent = $branding->get('branding.accent_color', '#059669');
$brandSidebarBg = $branding->get('branding.sidebar_bg', '#0f172a');
$brandSidebarText = $branding->get('branding.sidebar_text', '#e2e8f0');
$brandSidebarActive = $branding->get('branding.sidebar_active', '#2563eb');
$brandSuccess = $branding->get('branding.success_color', '#10b981');
$brandWarning = $branding->get('branding.warning_color', '#f59e0b');
$brandDanger = $branding->get('branding.danger_color', '#ef4444');
$brandFontAr = $branding->get('branding.font_family_ar', 'Cairo');
$brandFontEn = $branding->get('branding.font_family_en', 'Inter');
$brandFontSize = $branding->get('branding.font_size_base', '14');
$brandLogo = $branding->get('branding.logo');
$brandLogoDark = $branding->get('branding.logo_dark');
$showLogoSidebar = $branding->get('branding.show_logo_in_sidebar', true);
// One resolved brand for the whole document. This block used to issue one
// SettingsService::get() per field — about sixteen SELECTs per render, and
// again on every Livewire round-trip — each with its own fallback, which is
// how primary_color came to have three different defaults across three
// layouts.
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="ar" class="h-full">
......@@ -22,12 +12,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'El Captain' }} - الكابتن</title>
@if($favicon = $branding->get('branding.favicon'))
<link rel="icon" href="{{ Storage::disk('public')->url($favicon) }}" type="image/png">
<title>{{ $title ?? 'El Captain' }} - {{ $brand->academyName }}</title>
<meta name="theme-color" content="{{ $brand->themeColor }}">
@if($brand->faviconUrl)
<link rel="icon" href="{{ $brand->faviconUrl }}">
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brandFontAr) }}:wght@300;400;500;600;700;800&family={{ urlencode($brandFontEn) }}:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brand->fontAr) }}:wght@300;400;500;600;700;800&family={{ urlencode($brand->fontEn) }}:wght@300;400;500;600;700&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
<style>
......@@ -35,24 +26,20 @@
flashed visible before Alpine booted. */
[x-cloak] { display: none !important; }
:root {
--brand-primary: {{ $brandPrimary }};
--brand-secondary: {{ $brandSecondary }};
--brand-accent: {{ $brandAccent }};
--brand-sidebar-bg: {{ $brandSidebarBg }};
--brand-sidebar-text: {{ $brandSidebarText }};
--brand-sidebar-active: {{ $brandSidebarActive }};
--brand-success: {{ $brandSuccess }};
--brand-warning: {{ $brandWarning }};
--brand-danger: {{ $brandDanger }};
--brand-font-size: {{ $brandFontSize }}px;
{!! $brand->cssVariableBlock() !!}
}
body {
font-family: '{{ $brandFontAr }}', '{{ $brandFontEn }}', sans-serif;
font-family: var(--brand-font-ar), var(--brand-font-en), sans-serif;
font-size: var(--brand-font-size);
}
/* compact_sidebar was collected by the settings screen and read by
nothing. It narrows the rail and hides the labels. */
body.brand-compact-sidebar .app-sidebar { width: 4.5rem; }
body.brand-compact-sidebar .app-sidebar .sidebar-label { display: none; }
body.brand-compact-sidebar .app-main { margin-inline-start: 4.5rem; }
</style>
</head>
<body class="h-full bg-gray-50">
<body class="h-full bg-gray-50 @if($brand->compactSidebar) brand-compact-sidebar @endif">
<div x-data="{ sidebarOpen: false }" class="min-h-full" @keydown.escape.window="sidebarOpen = false">
<!-- Mobile sidebar overlay -->
<div x-show="sidebarOpen" x-cloak
......@@ -67,21 +54,28 @@ class="fixed inset-0 z-30 bg-gray-900/60 backdrop-blur-sm lg:hidden" @click="sid
@include('components.layouts.sidebar')
<!-- Main content area -->
<div class="lg:ms-64 min-h-screen flex flex-col">
<div class="app-main lg:ms-64 min-h-screen flex flex-col">
<!-- Topbar -->
@include('components.layouts.topbar')
<!-- Page content -->
<main class="flex-1 p-3 sm:p-5 lg:p-8 pb-20 sm:pb-8">
<!-- Flash messages -->
{{-- success_color and danger_color were collected and read by
nothing; the flash strip is where a tenant expects to see
them. --}}
@if(session('success'))
<div class="mb-4 rounded-lg bg-green-50 p-3 sm:p-4 border border-green-200">
<p class="text-sm text-green-700">{{ session('success') }}</p>
<div class="mb-4 rounded-lg p-3 sm:p-4 border"
style="background-color: color-mix(in oklab, var(--brand-success) 10%, white);
border-color: color-mix(in oklab, var(--brand-success) 35%, white);">
<p class="text-sm" style="color: color-mix(in oklab, var(--brand-success) 80%, black);">{{ session('success') }}</p>
</div>
@endif
@if(session('error'))
<div class="mb-4 rounded-lg bg-red-50 p-3 sm:p-4 border border-red-200">
<p class="text-sm text-red-700">{{ session('error') }}</p>
<div class="mb-4 rounded-lg p-3 sm:p-4 border"
style="background-color: color-mix(in oklab, var(--brand-danger) 10%, white);
border-color: color-mix(in oklab, var(--brand-danger) 35%, white);">
<p class="text-sm" style="color: color-mix(in oklab, var(--brand-danger) 80%, black);">{{ session('error') }}</p>
</div>
@endif
......
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandPrimary = $branding->get('branding.primary_color', '#2563eb');
$brandSecondary = $branding->get('branding.secondary_color', '#7c3aed');
$brandFontAr = $branding->get('branding.font_family_ar', 'Cairo');
$brandFontEn = $branding->get('branding.font_family_en', 'Inter');
$brandLogo = $branding->get('branding.logo');
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
@endphp
<!DOCTYPE html>
<html dir="rtl" lang="ar" class="h-full">
......@@ -12,25 +7,35 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'تسجيل الدخول' }} - الكابتن</title>
@if($favicon = $branding->get('branding.favicon'))
<link rel="icon" href="{{ Storage::disk('public')->url($favicon) }}" type="image/png">
<title>{{ $title ?? 'تسجيل الدخول' }} - {{ $brand->academyName }}</title>
<meta name="theme-color" content="{{ $brand->themeColor }}">
@if($brand->faviconUrl)
<link rel="icon" href="{{ $brand->faviconUrl }}">
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brandFontAr) }}:wght@300;400;500;600;700;800&family={{ urlencode($brandFontEn) }}:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brand->fontAr) }}:wght@300;400;500;600;700;800&family={{ urlencode($brand->fontEn) }}:wght@300;400;500;600;700&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
<style>
:root {
--brand-primary: {{ $brandPrimary }};
--brand-secondary: {{ $brandSecondary }};
{!! $brand->cssVariableBlock() !!}
}
body {
font-family: '{{ $brandFontAr }}', '{{ $brandFontEn }}', sans-serif;
font-family: var(--brand-font-ar), var(--brand-font-en), sans-serif;
}
{{-- login_background was collected by the branding screen and read by
nothing. This is the surface it was always meant for. --}}
@if($brand->loginBackgroundUrl)
.login-ground {
background-image: linear-gradient(rgba(15, 23, 42, 0.55), rgba(15, 23, 42, 0.55)),
url('{{ $brand->loginBackgroundUrl }}');
background-size: cover;
background-position: center;
}
@endif
</style>
</head>
<body class="h-full bg-gray-100">
<body class="h-full bg-gray-100 login-ground">
<div class="min-h-full flex items-center justify-center py-6 sm:py-12 px-4 sm:px-6 lg:px-8 safe-top safe-bottom">
<div class="w-full max-w-md">
{{ $slot }}
......
......@@ -3,13 +3,12 @@
<!-- Logo/Title -->
<div class="text-center mb-6 sm:mb-8">
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$logo = $branding->get('branding.logo');
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
@endphp
@if($logo)
<img src="{{ Storage::disk('public')->url($logo) }}" alt="{{ __('الكابتن') }}" class="h-14 sm:h-16 mx-auto mb-3">
@if($brand->logoUrl)
<img src="{{ $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="h-14 sm:h-16 mx-auto mb-3">
@else
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--brand-primary, #2563eb);">{{ __('الكابتن') }}</h1>
<h1 class="text-2xl sm:text-3xl font-bold mb-2" style="color: var(--brand-primary, #2563eb);">{{ $brand->academyName }}</h1>
@endif
<p class="text-sm sm:text-base text-gray-500">{{ __('إدارة الأكاديميات الرياضية') }}</p>
</div>
......
......@@ -31,6 +31,7 @@
'typography' => 'الخطوط',
'invoice' => 'الفواتير والمطبوعات',
'display' => 'خيارات العرض',
'mobile' => 'التطبيق والهوية',
] as $key => $label)
<button type="button" @click="activeTab = '{{ $key }}'"
:class="activeTab === '{{ $key }}'
......@@ -115,7 +116,7 @@ class="px-3 sm:px-5 py-2.5 sm:py-3 text-xs sm:text-sm font-medium whitespace-now
<svg class="mx-auto h-10 w-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
<p class="mt-2 text-sm text-gray-500">{{ __('أيقونة المتصفح') }}</p>
@endif
<input type="file" wire:model="favicon" accept=".png,.ico,.svg" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<input type="file" wire:model="favicon" accept=".png,.ico" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
</div>
@error('favicon') <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</div>
......@@ -479,6 +480,69 @@ class="px-3 sm:px-5 py-2.5 sm:py-3 text-xs sm:text-sm font-medium whitespace-now
</div>
</div>
<!-- Mobile / app identity -->
<div x-show="activeTab === 'mobile'" x-transition>
<div class="bg-white rounded-xl border border-gray-200 p-4 sm:p-6 max-w-2xl space-y-6">
<div>
<h3 class="font-semibold text-gray-900">{{ __('اسم الأكاديمية المعروض') }}</h3>
<p class="text-xs text-gray-500 mt-1">{{ __('يظهر في عنوان الصفحة، على المطبوعات، وفي تطبيق الأعضاء') }}</p>
<input type="text" wire:model="academy_name" maxlength="120"
class="mt-3 w-full border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm">
@error('academy_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ __('الاسم المختصر للتطبيق') }}</h3>
<p class="text-xs text-gray-500 mt-1">{{ __('يظهر أسفل أيقونة التطبيق على شاشة الهاتف — 24 حرفاً كحد أقصى') }}</p>
<input type="text" wire:model="app_short_name" maxlength="24"
class="mt-3 w-full border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm">
@error('app_short_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ __('لون التطبيق') }}</h3>
<p class="text-xs text-gray-500 mt-1">{{ __('لون شريط الحالة وخلفية شاشة البدء') }}</p>
<div class="mt-3 flex items-center gap-2">
<input type="color" wire:model.live="theme_color" class="h-10 w-14 rounded-lg border border-gray-300 cursor-pointer p-0.5">
<input type="text" wire:model.live="theme_color" dir="ltr" class="flex-1 text-xs font-mono border-gray-300 rounded-lg px-2 py-2">
</div>
@error('theme_color') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ __('نمط العرض') }}</h3>
<select wire:model="theme_mode" class="mt-3 w-full border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="light">{{ __('فاتح') }}</option>
<option value="dark">{{ __('داكن') }}</option>
<option value="auto">{{ __('حسب إعداد الهاتف') }}</option>
</select>
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ __('أيقونة التطبيق') }}</h3>
<p class="text-xs text-gray-500 mt-1">{{ __('صورة مربعة بصيغة PNG، 512×512 على الأقل. تُستخدم لتوليد كل المقاسات تلقائياً. إن تُركت فارغة يُستخدم الشعار الرئيسي.') }}</p>
@if($current_app_icon)
<img src="{{ Storage::disk('public')->url($current_app_icon) }}" alt="" class="mt-3 h-20 w-20 rounded-2xl object-contain border border-gray-200 bg-white">
@endif
<input type="file" wire:model="app_icon" accept=".png,.jpg,.jpeg" class="mt-3 block w-full text-sm">
@error('app_icon') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ __('صورة شاشة البدء') }}</h3>
@if($current_splash_image)
<img src="{{ Storage::disk('public')->url($current_splash_image) }}" alt="" class="mt-3 h-24 rounded-lg object-contain border border-gray-200 bg-white">
@endif
<input type="file" wire:model="splash_image" accept=".png,.jpg,.jpeg" class="mt-3 block w-full text-sm">
@error('splash_image') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<p class="text-xs text-gray-500 border-t border-gray-100 pt-4">
{{ __('صيغة SVG غير مقبولة في الرفع: ملف SVG على نطاق الأكاديمية يمكن أن يحمل سكربتاً ينفَّذ بصلاحيات الموقع.') }}
</p>
</div>
</div>
<!-- Save Button (always visible) -->
<div class="mt-6 sm:mt-8 flex flex-col-reverse sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-gray-200 pt-4 sm:pt-6">
<button type="submit" wire:loading.attr="disabled" wire:target="save"
......
......@@ -6,9 +6,15 @@
<title>{{ __('فاتورة') }} - {{ $invoice->number }}</title>
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandLogo = $branding->get('branding.logo');
$brandPrimary = $branding->get('branding.primary_color', '#1e40af');
$academyName = app()->bound('current_academy') ? app('current_academy')->name_ar : 'الكابتن';
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
// show_logo_in_invoice, invoice_header and invoice_footer_text were all
// collected by the branding screen and read by nothing. This is the
// surface they were collected for.
$brandLogo = $brand->showLogoInInvoice ? $brand->logoUrl : null;
$invoiceHeaderUrl = $brand->invoiceHeaderUrl;
$brandPrimary = $brand->primary;
$academyName = $brand->academyName;
$academyAddress = $brand->address;
$branch = \App\Domain\Identity\Models\Branch::where('academy_id', app('current_academy')?->id)->first();
// Load invoice template for field visibility
......@@ -17,10 +23,10 @@
$tplSettings = $invoiceTpl?->settings ?? \App\Domain\POS\Models\ReceiptTemplate::defaultInvoiceSettings();
$receiptFooter = !empty($tplSettings['footer_text'])
? $tplSettings['footer_text']
: $branding->get('branding.receipt_footer_text', 'تم إنشاء هذه الفاتورة إلكترونياً ولا تحتاج إلى توقيع');
$showSignature = $branding->get('branding.show_signature_in_invoice', false);
$signature = $branding->get('branding.signature');
$termsText = $branding->get('branding.terms_and_conditions', '');
: ($brand->invoiceFooterText ?: ($brand->receiptFooterText ?: 'تم إنشاء هذه الفاتورة إلكترونياً ولا تحتاج إلى توقيع'));
$showSignature = $brand->showSignatureInInvoice;
$signature = $brand->signatureUrl;
$termsText = $brand->termsAndConditions;
$taxNumber = $branding->get('financial.tax_registration_number', '');
@endphp
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
......@@ -162,8 +168,10 @@
<div class="invoice-wrapper">
<div class="header">
<div class="brand">
@if(($f['header_logo'] ?? true) && $brandLogo)
<img src="{{ Storage::disk('public')->url($brandLogo) }}" alt="{{ $academyName }}">
@if(($f['header_logo'] ?? true) && $invoiceHeaderUrl)
<img src="{{ $invoiceHeaderUrl }}" alt="{{ $academyName }}">
@elseif(($f['header_logo'] ?? true) && $brandLogo)
<img src="{{ $brandLogo }}" alt="{{ $academyName }}">
@endif
<div>
<h1>{{ __('فاتورة') }}</h1>
......
......@@ -6,8 +6,9 @@
<title>{{ __('إيصال دفع') }} - {{ $payment->reference ?? $payment->uuid }}</title>
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandLogo = $branding->get('branding.logo');
$brandPrimary = $branding->get('branding.primary_color', '#1e40af');
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
$brandLogo = $brand->logoUrl;
$brandPrimary = $brand->primary;
$academyName = app()->bound('current_academy') ? app('current_academy')->name_ar : 'الكابتن';
$payBranch = \App\Domain\Identity\Models\Branch::where('academy_id', app('current_academy')?->id)->first();
$payTpl = $payBranch ? \App\Domain\POS\Models\ReceiptTemplate::resolveFor($payBranch->id, 'payment') : null;
......@@ -122,7 +123,7 @@
<div class="receipt-wrapper">
<div class="header">
@if(($pf['header_logo'] ?? true) && $brandLogo)
<img src="{{ Storage::disk('public')->url($brandLogo) }}" alt="{{ $academyName }}">
<img src="{{ $brandLogo }}" alt="{{ $academyName }}">
@endif
<h1>{{ __('إيصال دفع') }}</h1>
@if($pf['header_academy_name'] ?? true)
......
......@@ -6,8 +6,9 @@
<title>{{ __('إيصال مبيعات') }} - {{ $transaction->receipt_number }}</title>
@php
$branding = app(\App\Domain\Shared\Services\SettingsService::class);
$brandLogo = $branding->get('branding.logo');
$brandPrimary = $branding->get('branding.primary_color', '#1e40af');
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
$brandLogo = $brand->logoUrl;
$brandPrimary = $brand->primary;
$academyName = app()->bound('current_academy') ? app('current_academy')->name_ar : 'الكابتن';
@endphp
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&display=swap" rel="stylesheet">
......@@ -131,7 +132,7 @@
<div class="receipt-wrapper">
<div class="header">
@if($brandLogo)
<img src="{{ Storage::disk('public')->url($brandLogo) }}" alt="{{ $academyName }}">
<img src="{{ $brandLogo }}" alt="{{ $academyName }}">
@endif
<h1>{{ __('إيصال مبيعات') }}</h1>
<div class="academy">{{ $academyName }}</div>
......
<?php
namespace Tests\Unit;
use App\Domain\Shared\Branding\ColorRamp;
use PHPUnit\Framework\TestCase;
/**
* components/layouts/sidebar.blade.php hardcoded `color: #fff` on the brand
* accent. This is a tenant-branded product, so a client whose brand is yellow
* got white text on yellow at about 1.5:1 — unreadable, and unfixable
* downstream because the value was baked into the markup.
*/
class ColorRampTest extends TestCase
{
public function test_the_foreground_always_clears_wcag_aa_against_its_own_base(): void
{
$brands = [
'#2563eb', // blue
'#facc15', // yellow — the case that broke
'#dc2626', // red
'#059669', // green
'#0f172a', // near black
'#f1f5f9', // near white
'#7c3aed', // violet
'#f97316', // orange
'#06b6d4', // cyan
];
foreach ($brands as $hex) {
$ramp = ColorRamp::fromHex($hex);
$ratio = ColorRamp::contrastRatio($ramp->foreground, $hex);
$this->assertGreaterThanOrEqual(
4.5,
$ratio,
"{$ramp->foreground} on {$hex} is only " . round($ratio, 2) . ':1'
);
}
}
public function test_the_hardcoded_white_it_replaces_would_have_failed(): void
{
// Kept as the regression's own evidence: this is what shipped.
$this->assertLessThan(2.0, ColorRamp::contrastRatio('#ffffff', '#facc15'));
}
public function test_lightness_decreases_monotonically_across_the_ramp(): void
{
// Fixed lightness targets break for an inherently light brand: yellow
// sits at L 0.86, so a table putting 400 at L 0.70 makes 400 darker
// than 500 and the ramp stops meaning anything.
foreach (['#2563eb', '#facc15', '#0f172a', '#f1f5f9', '#059669'] as $hex) {
$previous = 2.0;
foreach (ColorRamp::fromHex($hex)->shades as $step => $value) {
preg_match('/oklch\(([\d.]+)/', $value, $m);
$lightness = (float) $m[1];
$this->assertLessThanOrEqual(
$previous + 0.0001,
$lightness,
"{$hex} step {$step} is lighter than the step before it"
);
$previous = $lightness;
}
}
}
public function test_the_pale_shades_stay_inside_a_plausible_srgb_chroma(): void
{
// At L 0.97 a chroma of 0.10 is outside sRGB and the browser clips it
// to something muddy.
foreach (['#2563eb', '#dc2626', '#7c3aed'] as $hex) {
$shades = ColorRamp::fromHex($hex)->shades;
preg_match('/oklch\([\d.]+ ([\d.]+)/', $shades[50], $m);
$this->assertLessThan(0.03, (float) $m[1], "{$hex} step 50 is over-saturated");
}
}
public function test_step_500_is_the_tenant_colour_untouched(): void
{
$ramp = ColorRamp::fromHex('#2563eb');
[$r, $g, $b] = ColorRamp::hexToRgb('#2563eb');
[$l, $c, $h] = ColorRamp::rgbToOklch($r, $g, $b);
$this->assertSame(sprintf('oklch(%.4f %.4f %.2f)', $l, $c, $h), $ramp->shades[500]);
}
public function test_a_malformed_colour_falls_back_rather_than_throwing(): void
{
$this->assertSame('#2563eb', ColorRamp::fromHex('not-a-colour')->base);
$this->assertSame('#2563eb', ColorRamp::fromHex('')->base);
$this->assertSame('#aabbcc', ColorRamp::fromHex('abc')->base);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment