feat: Permettre la personnalisation du logo et de la couleur principale de l'établissement
Les administrateurs peuvent désormais configurer l'identité visuelle de leur établissement : upload d'un logo (PNG/JPG, redimensionné automatiquement via Imagick) et choix d'une couleur principale appliquée aux boutons et à la navigation. La couleur est validée côté client et serveur pour garantir la conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les personnalisations sont injectées dynamiquement via CSS variables et visibles immédiatement après sauvegarde.
This commit is contained in:
@@ -14,6 +14,7 @@ RUN apk add --no-cache \
|
||||
gettext \
|
||||
git \
|
||||
icu-dev \
|
||||
imagemagick-dev \
|
||||
libzip-dev \
|
||||
postgresql-dev \
|
||||
rabbitmq-c-dev \
|
||||
@@ -23,6 +24,9 @@ RUN apk add --no-cache \
|
||||
# Install PHP extensions (opcache is pre-installed in FrankenPHP)
|
||||
RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets
|
||||
|
||||
# Install Imagick extension for image processing (logo resize, etc.)
|
||||
RUN pecl install imagick && docker-php-ext-enable imagick
|
||||
|
||||
# Install AMQP extension for RabbitMQ
|
||||
RUN pecl install amqp && docker-php-ext-enable amqp
|
||||
|
||||
|
||||
@@ -187,6 +187,24 @@ services:
|
||||
arguments:
|
||||
$dataDirectory: '%kernel.project_dir%/var/data/calendar'
|
||||
|
||||
# School Branding (Story 2.13 - Personnalisation visuelle)
|
||||
App\Administration\Domain\Model\SchoolBranding\ContrastValidator:
|
||||
autowire: true
|
||||
|
||||
App\Administration\Domain\Repository\SchoolBrandingRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository
|
||||
|
||||
App\Administration\Application\Port\LogoStorage:
|
||||
alias: App\Administration\Infrastructure\Storage\LocalLogoStorage
|
||||
|
||||
App\Administration\Infrastructure\Storage\LocalLogoStorage:
|
||||
arguments:
|
||||
$uploadDir: '%kernel.project_dir%/public/uploads'
|
||||
$publicPath: '/uploads'
|
||||
|
||||
App\Administration\Application\Port\ImageProcessor:
|
||||
alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor
|
||||
|
||||
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
|
||||
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
||||
arguments:
|
||||
|
||||
42
backend/migrations/Version20260220071333.php
Normal file
42
backend/migrations/Version20260220071333.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260220071333 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create school_branding table for visual identity customization';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE school_branding (
|
||||
school_id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
logo_url TEXT,
|
||||
logo_updated_at TIMESTAMPTZ,
|
||||
primary_color VARCHAR(7),
|
||||
secondary_color VARCHAR(7),
|
||||
accent_color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE INDEX idx_branding_tenant ON school_branding(tenant_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS school_branding');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\DeleteLogo;
|
||||
|
||||
final readonly class DeleteLogoCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\DeleteLogo;
|
||||
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class DeleteLogoHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private LogoUploader $logoUploader,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(DeleteLogoCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
|
||||
$branding = $this->brandingRepository->get($schoolId, $tenantId);
|
||||
|
||||
if ($branding->logoUrl !== null) {
|
||||
$this->logoUploader->deleteByUrl($branding->logoUrl);
|
||||
}
|
||||
|
||||
$branding->supprimerLogo($this->clock->now());
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateBranding;
|
||||
|
||||
final readonly class UpdateBrandingCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public ?string $primaryColor,
|
||||
public ?string $secondaryColor,
|
||||
public ?string $accentColor,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UpdateBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UpdateBrandingHandler
|
||||
{
|
||||
private const string WHITE = '#FFFFFF';
|
||||
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private ContrastValidator $contrastValidator,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UpdateBrandingCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$primaryColor = $command->primaryColor !== null
|
||||
? new BrandColor($command->primaryColor)
|
||||
: null;
|
||||
|
||||
if ($primaryColor !== null) {
|
||||
$result = $this->contrastValidator->validate($primaryColor, new BrandColor(self::WHITE));
|
||||
|
||||
if (!$result->passesAA) {
|
||||
throw ContrasteInsuffisantException::pourRatio($result->ratio, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
$secondaryColor = $command->secondaryColor !== null
|
||||
? new BrandColor($command->secondaryColor)
|
||||
: null;
|
||||
$accentColor = $command->accentColor !== null
|
||||
? new BrandColor($command->accentColor)
|
||||
: null;
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: $secondaryColor,
|
||||
accentColor: $accentColor,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UploadLogo;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final readonly class UploadLogoCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
public UploadedFile $file,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\UploadLogo;
|
||||
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class UploadLogoHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private LogoUploader $logoUploader,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(UploadLogoCommand $command): SchoolBranding
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$schoolId = SchoolId::fromString($command->schoolId);
|
||||
$now = $this->clock->now();
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$logoUrl = $this->logoUploader->upload($command->file, $tenantId, $branding->logoUrl);
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour le traitement d'images (redimensionnement).
|
||||
*/
|
||||
interface ImageProcessor
|
||||
{
|
||||
/**
|
||||
* Redimensionne une image en respectant les proportions.
|
||||
*
|
||||
* @param string $sourcePath Chemin vers le fichier source
|
||||
* @param int $maxWidth Largeur maximale
|
||||
* @param int $maxHeight Hauteur maximale
|
||||
*
|
||||
* @return string Contenu binaire de l'image redimensionnée (PNG)
|
||||
*/
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string;
|
||||
}
|
||||
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal file
27
backend/src/Administration/Application/Port/LogoStorage.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour le stockage des logos d'établissement.
|
||||
*/
|
||||
interface LogoStorage
|
||||
{
|
||||
/**
|
||||
* Stocke un logo et retourne son URL publique.
|
||||
*
|
||||
* @param string $content Contenu binaire du fichier
|
||||
* @param string $key Clé de stockage (chemin)
|
||||
* @param string $contentType Type MIME du fichier
|
||||
*
|
||||
* @return string URL publique du fichier stocké
|
||||
*/
|
||||
public function store(string $content, string $key, string $contentType): string;
|
||||
|
||||
/**
|
||||
* Supprime un fichier du stockage.
|
||||
*/
|
||||
public function delete(string $key): void;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
use App\Administration\Domain\Exception\LogoFormatInvalideException;
|
||||
use App\Administration\Domain\Exception\LogoTropGrosException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function bin2hex;
|
||||
use function in_array;
|
||||
use function random_bytes;
|
||||
use function strpos;
|
||||
use function substr;
|
||||
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Service applicatif pour l'upload et le traitement des logos.
|
||||
*
|
||||
* Responsabilités :
|
||||
* - Validation du fichier (type MIME, taille)
|
||||
* - Redimensionnement via port ImageProcessor (max 200x200px)
|
||||
* - Stockage via port LogoStorage
|
||||
* - Suppression des anciens fichiers lors du remplacement
|
||||
*/
|
||||
final readonly class LogoUploader
|
||||
{
|
||||
private const int MAX_SIZE = 2 * 1024 * 1024; // 2 Mo
|
||||
private const int MAX_DIMENSION = 200;
|
||||
/** @var string[] */
|
||||
private const array ALLOWED_TYPES = ['image/png', 'image/jpeg'];
|
||||
private const string KEY_PREFIX = 'logos/';
|
||||
|
||||
public function __construct(
|
||||
private LogoStorage $storage,
|
||||
private ImageProcessor $imageProcessor,
|
||||
) {
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file, TenantId $tenantId, ?LogoUrl $oldLogoUrl = null): LogoUrl
|
||||
{
|
||||
$this->validerFichier($file);
|
||||
|
||||
$content = $this->imageProcessor->resize(
|
||||
$file->getPathname(),
|
||||
self::MAX_DIMENSION,
|
||||
self::MAX_DIMENSION,
|
||||
);
|
||||
|
||||
$key = self::KEY_PREFIX . $tenantId . '/' . bin2hex(random_bytes(8)) . '.png';
|
||||
$url = $this->storage->store($content, $key, 'image/png');
|
||||
|
||||
if ($oldLogoUrl !== null) {
|
||||
$this->deleteByUrl($oldLogoUrl);
|
||||
}
|
||||
|
||||
return new LogoUrl($url);
|
||||
}
|
||||
|
||||
public function deleteByUrl(LogoUrl $logoUrl): void
|
||||
{
|
||||
$url = $logoUrl->value;
|
||||
$pos = strpos($url, self::KEY_PREFIX);
|
||||
|
||||
if ($pos !== false) {
|
||||
$this->storage->delete(substr($url, $pos));
|
||||
}
|
||||
}
|
||||
|
||||
private function validerFichier(UploadedFile $file): void
|
||||
{
|
||||
$size = $file->getSize();
|
||||
if ($size > self::MAX_SIZE) {
|
||||
throw LogoTropGrosException::pourTaille($size, self::MAX_SIZE);
|
||||
}
|
||||
|
||||
$mimeType = $file->getMimeType() ?? 'unknown';
|
||||
if (!in_array($mimeType, self::ALLOWED_TYPES, true)) {
|
||||
throw LogoFormatInvalideException::pourType($mimeType, self::ALLOWED_TYPES);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
backend/src/Administration/Domain/Event/BrandingModifie.php
Normal file
38
backend/src/Administration/Domain/Event/BrandingModifie.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lors de la modification du branding d'un établissement.
|
||||
*/
|
||||
final readonly class BrandingModifie implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public SchoolId $schoolId,
|
||||
public TenantId $tenantId,
|
||||
public string $champ,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->schoolId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class BrandColorInvalideException extends DomainException
|
||||
{
|
||||
public static function pourFormat(string $value): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ContrasteInsuffisantException extends DomainException
|
||||
{
|
||||
public static function pourRatio(float $ratio, float $minimum): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le contraste de %.1f:1 est insuffisant. Le minimum requis pour la conformité WCAG AA est de %.1f:1. Choisissez une couleur plus foncée.',
|
||||
$ratio,
|
||||
$minimum,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function implode;
|
||||
use function sprintf;
|
||||
|
||||
final class LogoFormatInvalideException extends DomainException
|
||||
{
|
||||
/**
|
||||
* @param string[] $allowed
|
||||
*/
|
||||
public static function pourType(string $mimeType, array $allowed): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le format "%s" n\'est pas autorisé pour le logo. Formats acceptés : %s.',
|
||||
$mimeType,
|
||||
implode(', ', $allowed),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Throwable;
|
||||
|
||||
final class LogoTraitementImpossibleException extends DomainException
|
||||
{
|
||||
public static function pourErreur(Throwable $previous): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('Impossible de traiter l\'image du logo : %s', $previous->getMessage()),
|
||||
previous: $previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class LogoTropGrosException extends DomainException
|
||||
{
|
||||
public static function pourTaille(int $taille, int $max): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le fichier logo fait %d octets, la taille maximale autorisée est de %d octets (2 Mo).',
|
||||
$taille,
|
||||
$max,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class LogoUrlInvalideException extends DomainException
|
||||
{
|
||||
public static function pourUrl(string $value): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'URL du logo "%s" est invalide. Elle doit être une URL HTTP(S) valide.',
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class SchoolBrandingNotFoundException extends DomainException
|
||||
{
|
||||
public static function pourEcole(SchoolId $schoolId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune personnalisation visuelle trouvée pour l\'établissement "%s".',
|
||||
$schoolId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\BrandColorInvalideException;
|
||||
|
||||
use function hexdec;
|
||||
use function preg_match;
|
||||
use function strtoupper;
|
||||
use function substr;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Value Object représentant une couleur de branding (format hexadécimal).
|
||||
*
|
||||
* Contraintes :
|
||||
* - Format #RRGGBB
|
||||
* - Exemple: "#3B82F6" (bleu), "#1E40AF" (bleu foncé)
|
||||
*/
|
||||
final class BrandColor
|
||||
{
|
||||
private const string PATTERN = '/^#[0-9A-F]{6}$/';
|
||||
|
||||
public function __construct(
|
||||
public private(set) string $value {
|
||||
set(string $value) {
|
||||
$normalized = strtoupper(trim($value));
|
||||
|
||||
if (preg_match(self::PATTERN, $normalized) !== 1) {
|
||||
throw BrandColorInvalideException::pourFormat($value);
|
||||
}
|
||||
|
||||
$this->value = $normalized;
|
||||
}
|
||||
},
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit la couleur en composantes RGB.
|
||||
*
|
||||
* @return array{r: int, g: int, b: int}
|
||||
*/
|
||||
public function toRgb(): array
|
||||
{
|
||||
return [
|
||||
'r' => (int) hexdec(substr($this->value, 1, 2)),
|
||||
'g' => (int) hexdec(substr($this->value, 3, 2)),
|
||||
'b' => (int) hexdec(substr($this->value, 5, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
/**
|
||||
* Value Object représentant le résultat d'un calcul de contraste WCAG.
|
||||
*/
|
||||
final readonly class ContrastResult
|
||||
{
|
||||
public function __construct(
|
||||
public float $ratio,
|
||||
public bool $passesAA,
|
||||
public bool $passesAALarge,
|
||||
public ?BrandColor $suggestion,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use function max;
|
||||
use function min;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Service Domain qui valide le contraste entre deux couleurs
|
||||
* selon les normes WCAG AA.
|
||||
*
|
||||
* @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
|
||||
* @see NFR-A5: Contraste WCAG AA
|
||||
*/
|
||||
final class ContrastValidator
|
||||
{
|
||||
private const float WCAG_AA_RATIO = 4.5;
|
||||
private const float WCAG_AA_LARGE_RATIO = 3.0;
|
||||
|
||||
public function validate(BrandColor $foreground, BrandColor $background): ContrastResult
|
||||
{
|
||||
$ratio = $this->calculateRatio($foreground, $background);
|
||||
|
||||
$passesAA = $ratio >= self::WCAG_AA_RATIO;
|
||||
|
||||
return new ContrastResult(
|
||||
ratio: $ratio,
|
||||
passesAA: $passesAA,
|
||||
passesAALarge: $ratio >= self::WCAG_AA_LARGE_RATIO,
|
||||
suggestion: !$passesAA
|
||||
? $this->suggestAlternative($foreground, $background)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function calculateRatio(BrandColor $foreground, BrandColor $background): float
|
||||
{
|
||||
$l1 = $this->relativeLuminance($foreground);
|
||||
$l2 = $this->relativeLuminance($background);
|
||||
|
||||
$lighter = max($l1, $l2);
|
||||
$darker = min($l1, $l2);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
private function relativeLuminance(BrandColor $color): float
|
||||
{
|
||||
$rgb = $color->toRgb();
|
||||
$r = $this->linearize($rgb['r'] / 255.0);
|
||||
$g = $this->linearize($rgb['g'] / 255.0);
|
||||
$b = $this->linearize($rgb['b'] / 255.0);
|
||||
|
||||
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
|
||||
}
|
||||
|
||||
private function linearize(float $value): float
|
||||
{
|
||||
return $value <= 0.03928
|
||||
? $value / 12.92
|
||||
: (($value + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose une couleur alternative plus sombre qui passe le contraste WCAG AA.
|
||||
*/
|
||||
private function suggestAlternative(BrandColor $foreground, BrandColor $background): BrandColor
|
||||
{
|
||||
$rgb = $foreground->toRgb();
|
||||
$r = $rgb['r'];
|
||||
$g = $rgb['g'];
|
||||
$b = $rgb['b'];
|
||||
|
||||
for ($i = 0; $i < 256; ++$i) {
|
||||
$darkerR = max(0, $r - $i);
|
||||
$darkerG = max(0, $g - $i);
|
||||
$darkerB = max(0, $b - $i);
|
||||
|
||||
$candidate = new BrandColor(sprintf('#%02X%02X%02X', $darkerR, $darkerG, $darkerB));
|
||||
$ratio = $this->calculateRatio($candidate, $background);
|
||||
|
||||
if ($ratio >= self::WCAG_AA_RATIO) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return new BrandColor('#000000');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\LogoUrlInvalideException;
|
||||
|
||||
use const FILTER_VALIDATE_URL;
|
||||
|
||||
use function filter_var;
|
||||
use function str_starts_with;
|
||||
|
||||
/**
|
||||
* Value Object représentant l'URL du logo d'un établissement.
|
||||
*
|
||||
* Contraintes :
|
||||
* - URL valide
|
||||
* - Schéma HTTP ou HTTPS uniquement
|
||||
*/
|
||||
final readonly class LogoUrl
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
throw LogoUrlInvalideException::pourUrl($value);
|
||||
}
|
||||
|
||||
if (!str_starts_with($value, 'http://') && !str_starts_with($value, 'https://')) {
|
||||
throw LogoUrlInvalideException::pourUrl($value);
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Event\BrandingModifie;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant la personnalisation visuelle d'un établissement.
|
||||
*
|
||||
* Chaque établissement a un branding unique (1:1 avec School).
|
||||
* Le branding comprend un logo, une couleur principale, une couleur secondaire
|
||||
* et une couleur d'accent. Toutes les personnalisations sont optionnelles :
|
||||
* en l'absence de valeurs, le thème par défaut Classeo est utilisé.
|
||||
*
|
||||
* @see FR83: Configurer logo et couleurs établissement
|
||||
*/
|
||||
final class SchoolBranding extends AggregateRoot
|
||||
{
|
||||
public private(set) ?LogoUrl $logoUrl = null;
|
||||
public private(set) ?DateTimeImmutable $logoUpdatedAt = null;
|
||||
public private(set) ?BrandColor $primaryColor = null;
|
||||
public private(set) ?BrandColor $secondaryColor = null;
|
||||
public private(set) ?BrandColor $accentColor = null;
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) SchoolId $schoolId,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un branding vide pour un établissement (thème par défaut).
|
||||
*/
|
||||
public static function creer(
|
||||
SchoolId $schoolId,
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
return new self(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifie les couleurs du branding.
|
||||
*/
|
||||
public function modifierCouleurs(
|
||||
?BrandColor $primaryColor,
|
||||
?BrandColor $secondaryColor,
|
||||
?BrandColor $accentColor,
|
||||
DateTimeImmutable $at,
|
||||
): void {
|
||||
$primarySame = $this->colorsEqual($this->primaryColor, $primaryColor);
|
||||
$secondarySame = $this->colorsEqual($this->secondaryColor, $secondaryColor);
|
||||
$accentSame = $this->colorsEqual($this->accentColor, $accentColor);
|
||||
|
||||
if ($primarySame && $secondarySame && $accentSame) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->primaryColor = $primaryColor;
|
||||
$this->secondaryColor = $secondaryColor;
|
||||
$this->accentColor = $accentColor;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new BrandingModifie(
|
||||
schoolId: $this->schoolId,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'couleurs',
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change le logo de l'établissement.
|
||||
*/
|
||||
public function changerLogo(LogoUrl $logoUrl, DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->logoUrl !== null && $this->logoUrl->equals($logoUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logoUrl = $logoUrl;
|
||||
$this->logoUpdatedAt = $at;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new BrandingModifie(
|
||||
schoolId: $this->schoolId,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'logo',
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le logo de l'établissement.
|
||||
*/
|
||||
public function supprimerLogo(DateTimeImmutable $at): void
|
||||
{
|
||||
if ($this->logoUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logoUrl = null;
|
||||
$this->logoUpdatedAt = null;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new BrandingModifie(
|
||||
schoolId: $this->schoolId,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'logo',
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise le branding vers le thème par défaut.
|
||||
*/
|
||||
public function reinitialiser(DateTimeImmutable $at): void
|
||||
{
|
||||
if (!$this->estPersonnalise()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logoUrl = null;
|
||||
$this->logoUpdatedAt = null;
|
||||
$this->primaryColor = null;
|
||||
$this->secondaryColor = null;
|
||||
$this->accentColor = null;
|
||||
$this->updatedAt = $at;
|
||||
|
||||
$this->recordEvent(new BrandingModifie(
|
||||
schoolId: $this->schoolId,
|
||||
tenantId: $this->tenantId,
|
||||
champ: 'reinitialisation',
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'établissement a personnalisé son branding.
|
||||
*/
|
||||
public function estPersonnalise(): bool
|
||||
{
|
||||
return $this->logoUrl !== null
|
||||
|| $this->primaryColor !== null
|
||||
|| $this->secondaryColor !== null
|
||||
|| $this->accentColor !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue un SchoolBranding depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
SchoolId $schoolId,
|
||||
TenantId $tenantId,
|
||||
?LogoUrl $logoUrl,
|
||||
?DateTimeImmutable $logoUpdatedAt,
|
||||
?BrandColor $primaryColor,
|
||||
?BrandColor $secondaryColor,
|
||||
?BrandColor $accentColor,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$branding = new self(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$branding->logoUrl = $logoUrl;
|
||||
$branding->logoUpdatedAt = $logoUpdatedAt;
|
||||
$branding->primaryColor = $primaryColor;
|
||||
$branding->secondaryColor = $secondaryColor;
|
||||
$branding->accentColor = $accentColor;
|
||||
$branding->updatedAt = $updatedAt;
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
private function colorsEqual(?BrandColor $a, ?BrandColor $b): bool
|
||||
{
|
||||
if ($a === null && $b === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($a !== null && $b !== null) {
|
||||
return $a->equals($b);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface SchoolBrandingRepository
|
||||
{
|
||||
public function save(SchoolBranding $branding): void;
|
||||
|
||||
/**
|
||||
* @throws \App\Administration\Domain\Exception\SchoolBrandingNotFoundException
|
||||
*/
|
||||
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding;
|
||||
|
||||
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoCommand;
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoHandler;
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour supprimer le logo (DELETE /school/branding/logo).
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, null>
|
||||
*/
|
||||
final readonly class DeleteLogoProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DeleteLogoHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (SchoolBrandingNotFoundException) {
|
||||
throw new NotFoundHttpException('Branding non trouvé pour cet établissement.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingCommand;
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingHandler;
|
||||
use App\Administration\Domain\Exception\BrandColorInvalideException;
|
||||
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour modifier les couleurs du branding.
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class UpdateBrandingColorsProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateBrandingHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
primaryColor: $data->primaryColor,
|
||||
secondaryColor: $data->secondaryColor,
|
||||
accentColor: $data->accentColor,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
} catch (BrandColorInvalideException|ContrasteInsuffisantException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoCommand;
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoHandler;
|
||||
use App\Administration\Domain\Exception\LogoFormatInvalideException;
|
||||
use App\Administration\Domain\Exception\LogoTraitementImpossibleException;
|
||||
use App\Administration\Domain\Exception\LogoTropGrosException;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Processor API Platform pour l'upload du logo (POST /school/branding/logo).
|
||||
*
|
||||
* @implements ProcessorInterface<SchoolBrandingResource, SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class UploadLogoProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UploadLogoHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param SchoolBrandingResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::CONFIGURE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new BadRequestHttpException('Le fichier du logo est requis.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
|
||||
|
||||
try {
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$branding = ($this->handler)($command);
|
||||
|
||||
foreach ($branding->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
} catch (LogoTropGrosException|LogoFormatInvalideException|LogoTraitementImpossibleException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\SchoolBrandingResource;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SchoolBrandingVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* State Provider pour récupérer la configuration branding de l'établissement.
|
||||
*
|
||||
* @implements ProviderInterface<SchoolBrandingResource>
|
||||
*/
|
||||
final readonly class SchoolBrandingProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolBrandingRepository $brandingRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SchoolBrandingResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(SchoolBrandingVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir la configuration du branding.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId));
|
||||
|
||||
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
}
|
||||
|
||||
return SchoolBrandingResource::fromDomain($branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Infrastructure\Api\Processor\DeleteLogoProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateBrandingColorsProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\UploadLogoProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\SchoolBrandingProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* API Resource pour la gestion du branding de l'établissement.
|
||||
*
|
||||
* @see FR83 - Configurer logo et couleurs établissement
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'SchoolBranding',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/school/branding',
|
||||
provider: SchoolBrandingProvider::class,
|
||||
name: 'get_school_branding',
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/school/branding',
|
||||
read: false,
|
||||
processor: UpdateBrandingColorsProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'update']],
|
||||
name: 'update_school_branding',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/school/branding/logo',
|
||||
read: false,
|
||||
deserialize: false,
|
||||
processor: UploadLogoProcessor::class,
|
||||
name: 'upload_school_logo',
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/school/branding/logo',
|
||||
read: false,
|
||||
processor: DeleteLogoProcessor::class,
|
||||
name: 'delete_school_logo',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class SchoolBrandingResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $schoolId = null;
|
||||
|
||||
public ?string $logoUrl = null;
|
||||
|
||||
public ?DateTimeImmutable $logoUpdatedAt = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $primaryColor = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $secondaryColor = null;
|
||||
|
||||
#[Assert\Regex(
|
||||
pattern: '/^#[0-9A-Fa-f]{6}$/',
|
||||
message: 'La couleur doit être au format hexadécimal #RRGGBB.',
|
||||
groups: ['update'],
|
||||
)]
|
||||
public ?string $accentColor = null;
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromDomain(SchoolBranding $branding): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->schoolId = (string) $branding->schoolId;
|
||||
$resource->logoUrl = $branding->logoUrl !== null ? (string) $branding->logoUrl : null;
|
||||
$resource->logoUpdatedAt = $branding->logoUpdatedAt;
|
||||
$resource->primaryColor = $branding->primaryColor !== null ? (string) $branding->primaryColor : null;
|
||||
$resource->secondaryColor = $branding->secondaryColor !== null ? (string) $branding->secondaryColor : null;
|
||||
$resource->accentColor = $branding->accentColor !== null ? (string) $branding->accentColor : null;
|
||||
$resource->updatedAt = $branding->updatedAt;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineSchoolBrandingRepository implements SchoolBrandingRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolBranding $branding): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO school_branding (school_id, tenant_id, logo_url, logo_updated_at, primary_color, secondary_color, accent_color, created_at, updated_at)
|
||||
VALUES (:school_id, :tenant_id, :logo_url, :logo_updated_at, :primary_color, :secondary_color, :accent_color, :created_at, :updated_at)
|
||||
ON CONFLICT (school_id) DO UPDATE SET
|
||||
logo_url = EXCLUDED.logo_url,
|
||||
logo_updated_at = EXCLUDED.logo_updated_at,
|
||||
primary_color = EXCLUDED.primary_color,
|
||||
secondary_color = EXCLUDED.secondary_color,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'school_id' => (string) $branding->schoolId,
|
||||
'tenant_id' => (string) $branding->tenantId,
|
||||
'logo_url' => $branding->logoUrl !== null ? (string) $branding->logoUrl : null,
|
||||
'logo_updated_at' => $branding->logoUpdatedAt?->format(DateTimeImmutable::ATOM),
|
||||
'primary_color' => $branding->primaryColor !== null ? (string) $branding->primaryColor : null,
|
||||
'secondary_color' => $branding->secondaryColor !== null ? (string) $branding->secondaryColor : null,
|
||||
'accent_color' => $branding->accentColor !== null ? (string) $branding->accentColor : null,
|
||||
'created_at' => $branding->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $branding->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding
|
||||
{
|
||||
$branding = $this->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
throw SchoolBrandingNotFoundException::pourEcole($schoolId);
|
||||
}
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM school_branding WHERE school_id = :school_id AND tenant_id = :tenant_id',
|
||||
[
|
||||
'school_id' => (string) $schoolId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): SchoolBranding
|
||||
{
|
||||
/** @var string $schoolId */
|
||||
$schoolId = $row['school_id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string|null $logoUrl */
|
||||
$logoUrl = $row['logo_url'];
|
||||
/** @var string|null $logoUpdatedAt */
|
||||
$logoUpdatedAt = $row['logo_updated_at'];
|
||||
/** @var string|null $primaryColor */
|
||||
$primaryColor = $row['primary_color'];
|
||||
/** @var string|null $secondaryColor */
|
||||
$secondaryColor = $row['secondary_color'];
|
||||
/** @var string|null $accentColor */
|
||||
$accentColor = $row['accent_color'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return SchoolBranding::reconstitute(
|
||||
schoolId: SchoolId::fromString($schoolId),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
logoUrl: $logoUrl !== null ? new LogoUrl($logoUrl) : null,
|
||||
logoUpdatedAt: $logoUpdatedAt !== null ? new DateTimeImmutable($logoUpdatedAt) : null,
|
||||
primaryColor: $primaryColor !== null ? new BrandColor($primaryColor) : null,
|
||||
secondaryColor: $secondaryColor !== null ? new BrandColor($secondaryColor) : null,
|
||||
accentColor: $accentColor !== null ? new BrandColor($accentColor) : null,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SchoolBrandingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemorySchoolBrandingRepository implements SchoolBrandingRepository
|
||||
{
|
||||
/** @var array<string, SchoolBranding> Indexed by tenant:school */
|
||||
private array $byKey = [];
|
||||
|
||||
#[Override]
|
||||
public function save(SchoolBranding $branding): void
|
||||
{
|
||||
$this->byKey[$this->key($branding->schoolId, $branding->tenantId)] = $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(SchoolId $schoolId, TenantId $tenantId): SchoolBranding
|
||||
{
|
||||
$branding = $this->findBySchoolId($schoolId, $tenantId);
|
||||
|
||||
if ($branding === null) {
|
||||
throw SchoolBrandingNotFoundException::pourEcole($schoolId);
|
||||
}
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findBySchoolId(SchoolId $schoolId, TenantId $tenantId): ?SchoolBranding
|
||||
{
|
||||
return $this->byKey[$this->key($schoolId, $tenantId)] ?? null;
|
||||
}
|
||||
|
||||
private function key(SchoolId $schoolId, TenantId $tenantId): string
|
||||
{
|
||||
return $tenantId . ':' . $schoolId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur le branding établissement.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN et SUPER_ADMIN : accès complet (lecture + configuration)
|
||||
* - PROF, VIE_SCOLAIRE, SECRETARIAT : lecture seule
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class SchoolBrandingVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'BRANDING_VIEW';
|
||||
public const string CONFIGURE = 'BRANDING_CONFIGURE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CONFIGURE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof UserInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return match ($attribute) {
|
||||
self::VIEW => $this->canView($roles),
|
||||
self::CONFIGURE => $this->canConfigure($roles),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canView(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function canConfigure(array $roles): bool
|
||||
{
|
||||
return $this->hasAnyRole($roles, [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use App\Administration\Domain\Exception\LogoTraitementImpossibleException;
|
||||
use Imagick;
|
||||
use ImagickException;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Redimensionne les images via ImageMagick (ext-imagick).
|
||||
*
|
||||
* Conserve les proportions : l'image résultante tient dans
|
||||
* la boîte maxWidth × maxHeight sans être déformée.
|
||||
* Le résultat est toujours au format PNG.
|
||||
*/
|
||||
final class ImagickImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
try {
|
||||
$image = new Imagick($sourcePath);
|
||||
|
||||
$image->thumbnailImage($maxWidth, $maxHeight, true);
|
||||
$image->setImageFormat('png');
|
||||
|
||||
$content = $image->getImageBlob();
|
||||
$image->destroy();
|
||||
|
||||
return $content;
|
||||
} catch (ImagickException $e) {
|
||||
throw LogoTraitementImpossibleException::pourErreur($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Fake image processor pour les tests.
|
||||
* Retourne un contenu PNG factice sans traitement réel.
|
||||
*/
|
||||
final class InMemoryImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
return file_get_contents($sourcePath) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
|
||||
use function count;
|
||||
|
||||
use Override;
|
||||
|
||||
final class InMemoryLogoStorage implements LogoStorage
|
||||
{
|
||||
/** @var array<string, array{content: string, contentType: string}> */
|
||||
private array $files = [];
|
||||
|
||||
#[Override]
|
||||
public function store(string $content, string $key, string $contentType): string
|
||||
{
|
||||
$this->files[$key] = ['content' => $content, 'contentType' => $contentType];
|
||||
|
||||
return 'https://storage.example.com/' . $key;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(string $key): void
|
||||
{
|
||||
unset($this->files[$key]);
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return isset($this->files[$key]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\LogoStorage;
|
||||
|
||||
use function dirname;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Stockage local des logos sur le système de fichiers.
|
||||
*
|
||||
* TODO: Remplacer par un S3LogoStorage en production.
|
||||
*/
|
||||
final readonly class LocalLogoStorage implements LogoStorage
|
||||
{
|
||||
public function __construct(
|
||||
private string $uploadDir,
|
||||
private string $publicPath,
|
||||
private RequestStack $requestStack,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function store(string $content, string $key, string $contentType): string
|
||||
{
|
||||
$path = $this->uploadDir . '/' . $key;
|
||||
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0o755, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, $content);
|
||||
|
||||
return $this->buildAbsoluteUrl($key);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function delete(string $key): void
|
||||
{
|
||||
$path = $this->uploadDir . '/' . $key;
|
||||
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildAbsoluteUrl(string $key): string
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
if ($request !== null) {
|
||||
return $request->getSchemeAndHttpHost() . $this->publicPath . '/' . $key;
|
||||
}
|
||||
|
||||
return 'http://localhost' . $this->publicPath . '/' . $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Storage;
|
||||
|
||||
use App\Administration\Application\Port\ImageProcessor;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Processeur d'image passthrough (sans redimensionnement).
|
||||
*
|
||||
* Retourne le contenu du fichier source tel quel.
|
||||
* TODO: Remplacer par GdImageProcessor quand ext-gd sera disponible.
|
||||
*/
|
||||
final class PassthroughImageProcessor implements ImageProcessor
|
||||
{
|
||||
#[Override]
|
||||
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string
|
||||
{
|
||||
return file_get_contents($sourcePath) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\DeleteLogo;
|
||||
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoCommand;
|
||||
use App\Administration\Application\Command\DeleteLogo\DeleteLogoHandler;
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Event\BrandingModifie;
|
||||
use App\Administration\Domain\Exception\SchoolBrandingNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
|
||||
use App\Administration\Infrastructure\Storage\InMemoryImageProcessor;
|
||||
use App\Administration\Infrastructure\Storage\InMemoryLogoStorage;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DeleteLogoHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemorySchoolBrandingRepository $brandingRepository;
|
||||
private InMemoryLogoStorage $logoStorage;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->brandingRepository = new InMemorySchoolBrandingRepository();
|
||||
$this->logoStorage = new InMemoryLogoStorage();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeletesLogoFromBranding(): void
|
||||
{
|
||||
$this->seedBrandingWithLogo();
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNull($branding->logoUrl);
|
||||
self::assertNull($branding->logoUpdatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeletesFileFromStorage(): void
|
||||
{
|
||||
$this->seedBrandingWithLogo();
|
||||
self::assertSame(1, $this->logoStorage->count());
|
||||
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertSame(0, $this->logoStorage->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsDomainEventOnDeletion(): void
|
||||
{
|
||||
$this->seedBrandingWithLogo();
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsBrandingAfterDeletion(): void
|
||||
{
|
||||
$this->seedBrandingWithLogo();
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
$persisted = $this->brandingRepository->findBySchoolId(
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertNull($persisted->logoUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNothingWhenNoLogoExists(): void
|
||||
{
|
||||
$this->seedBrandingWithoutLogo();
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(0, $events);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenBrandingDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
|
||||
$command = new DeleteLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
);
|
||||
|
||||
$this->expectException(SchoolBrandingNotFoundException::class);
|
||||
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function createHandler(): DeleteLogoHandler
|
||||
{
|
||||
return new DeleteLogoHandler(
|
||||
$this->brandingRepository,
|
||||
new LogoUploader($this->logoStorage, new InMemoryImageProcessor()),
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedBrandingWithLogo(): void
|
||||
{
|
||||
// Store a file first so we can verify deletion
|
||||
$this->logoStorage->store('fake-content', 'logos/tenant/test.png', 'image/png');
|
||||
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
|
||||
$branding->changerLogo(
|
||||
new LogoUrl('https://storage.example.com/logos/tenant/test.png'),
|
||||
new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
}
|
||||
|
||||
private function seedBrandingWithoutLogo(): void
|
||||
{
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\UpdateBranding;
|
||||
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingCommand;
|
||||
use App\Administration\Application\Command\UpdateBranding\UpdateBrandingHandler;
|
||||
use App\Administration\Domain\Event\BrandingModifie;
|
||||
use App\Administration\Domain\Exception\BrandColorInvalideException;
|
||||
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class UpdateBrandingHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemorySchoolBrandingRepository $brandingRepository;
|
||||
private ContrastValidator $contrastValidator;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->brandingRepository = new InMemorySchoolBrandingRepository();
|
||||
$this->contrastValidator = new ContrastValidator();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesBrandingWhenNoneExists(): void
|
||||
{
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: '#1E40AF',
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNotNull($branding->primaryColor);
|
||||
self::assertSame('#1E40AF', (string) $branding->primaryColor);
|
||||
self::assertNull($branding->secondaryColor);
|
||||
self::assertNull($branding->accentColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesExistingBranding(): void
|
||||
{
|
||||
$this->seedBranding();
|
||||
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: '#B91C1C',
|
||||
secondaryColor: '#1E40AF',
|
||||
accentColor: '#60A5FA',
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNotNull($branding->primaryColor);
|
||||
self::assertSame('#B91C1C', (string) $branding->primaryColor);
|
||||
self::assertNotNull($branding->secondaryColor);
|
||||
self::assertSame('#1E40AF', (string) $branding->secondaryColor);
|
||||
self::assertNotNull($branding->accentColor);
|
||||
self::assertSame('#60A5FA', (string) $branding->accentColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResetsColorsToNull(): void
|
||||
{
|
||||
$this->seedBranding(primaryColor: '#1E40AF');
|
||||
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: null,
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNull($branding->primaryColor);
|
||||
self::assertNull($branding->secondaryColor);
|
||||
self::assertNull($branding->accentColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsDomainEvent(): void
|
||||
{
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: '#1E40AF',
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsBranding(): void
|
||||
{
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: '#1E40AF',
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
$persisted = $this->brandingRepository->findBySchoolId(
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertNotNull($persisted->primaryColor);
|
||||
self::assertSame('#1E40AF', (string) $persisted->primaryColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsColorWithInsufficientContrast(): void
|
||||
{
|
||||
$this->expectException(ContrasteInsuffisantException::class);
|
||||
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: '#FFFF00',
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsExceptionForInvalidColor(): void
|
||||
{
|
||||
$this->expectException(BrandColorInvalideException::class);
|
||||
|
||||
$handler = new UpdateBrandingHandler($this->brandingRepository, $this->contrastValidator, $this->clock);
|
||||
$command = new UpdateBrandingCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
primaryColor: 'not-a-color',
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function seedBranding(?string $primaryColor = null): void
|
||||
{
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
|
||||
if ($primaryColor !== null) {
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: new BrandColor($primaryColor),
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
$branding->pullDomainEvents();
|
||||
}
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\UploadLogo;
|
||||
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoCommand;
|
||||
use App\Administration\Application\Command\UploadLogo\UploadLogoHandler;
|
||||
use App\Administration\Application\Service\LogoUploader;
|
||||
use App\Administration\Domain\Event\BrandingModifie;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolBrandingRepository;
|
||||
use App\Administration\Infrastructure\Storage\InMemoryImageProcessor;
|
||||
use App\Administration\Infrastructure\Storage\InMemoryLogoStorage;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final class UploadLogoHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemorySchoolBrandingRepository $brandingRepository;
|
||||
private InMemoryLogoStorage $logoStorage;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->brandingRepository = new InMemorySchoolBrandingRepository();
|
||||
$this->logoStorage = new InMemoryLogoStorage();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUploadsLogoAndCreatesBranding(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNotNull($branding->logoUrl);
|
||||
self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUploadsLogoToExistingBranding(): void
|
||||
{
|
||||
$this->seedBranding();
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
self::assertNotNull($branding->logoUrl);
|
||||
self::assertStringStartsWith('https://storage.example.com/logos/', (string) $branding->logoUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeletesOldFileWhenReplacingLogo(): void
|
||||
{
|
||||
$this->seedBranding();
|
||||
self::assertSame(1, $this->logoStorage->count());
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
// Old file deleted, new file stored → still 1
|
||||
self::assertSame(1, $this->logoStorage->count());
|
||||
self::assertFalse($this->logoStorage->has('logos/old-tenant/old-logo.png'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsDomainEvent(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$branding = $handler($command);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsBrandingAfterUpload(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
$persisted = $this->brandingRepository->findBySchoolId(
|
||||
SchoolId::fromString(self::SCHOOL_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertNotNull($persisted->logoUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itStoresFileInStorage(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$file = $this->createTestPngFile();
|
||||
|
||||
$command = new UploadLogoCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolId: self::SCHOOL_ID,
|
||||
file: $file,
|
||||
);
|
||||
|
||||
$handler($command);
|
||||
|
||||
self::assertSame(1, $this->logoStorage->count());
|
||||
}
|
||||
|
||||
private function createHandler(): UploadLogoHandler
|
||||
{
|
||||
return new UploadLogoHandler(
|
||||
$this->brandingRepository,
|
||||
new LogoUploader($this->logoStorage, new InMemoryImageProcessor()),
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTestPngFile(): UploadedFile
|
||||
{
|
||||
// Minimal valid PNG (1x1 pixel, transparent)
|
||||
$pngData = base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', true,
|
||||
);
|
||||
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'logo_test_');
|
||||
|
||||
if ($tmpFile === false) {
|
||||
self::fail('Failed to create temp file');
|
||||
}
|
||||
|
||||
file_put_contents($tmpFile, $pngData);
|
||||
|
||||
return new UploadedFile(
|
||||
$tmpFile,
|
||||
'test-logo.png',
|
||||
'image/png',
|
||||
test: true,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedBranding(): void
|
||||
{
|
||||
// Store an old file so we can verify it gets deleted on replacement
|
||||
$this->logoStorage->store('old-content', 'logos/old-tenant/old-logo.png', 'image/png');
|
||||
|
||||
$branding = SchoolBranding::creer(
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
|
||||
$branding->changerLogo(
|
||||
new LogoUrl('https://storage.example.com/logos/old-tenant/old-logo.png'),
|
||||
new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$this->brandingRepository->save($branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\BrandColorInvalideException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BrandColorTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function constructWithValidColor(): void
|
||||
{
|
||||
$color = new BrandColor('#3B82F6');
|
||||
|
||||
self::assertSame('#3B82F6', $color->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructNormalizesToUppercase(): void
|
||||
{
|
||||
$color = new BrandColor('#3b82f6');
|
||||
|
||||
self::assertSame('#3B82F6', $color->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructTrimsWhitespace(): void
|
||||
{
|
||||
$color = new BrandColor(' #3B82F6 ');
|
||||
|
||||
self::assertSame('#3B82F6', $color->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validColorsProvider')]
|
||||
public function constructWithValidColors(string $input, string $expected): void
|
||||
{
|
||||
$color = new BrandColor($input);
|
||||
|
||||
self::assertSame($expected, $color->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
public static function validColorsProvider(): iterable
|
||||
{
|
||||
yield 'blue' => ['#3B82F6', '#3B82F6'];
|
||||
yield 'red' => ['#EF4444', '#EF4444'];
|
||||
yield 'green' => ['#10B981', '#10B981'];
|
||||
yield 'black' => ['#000000', '#000000'];
|
||||
yield 'white' => ['#FFFFFF', '#FFFFFF'];
|
||||
yield 'lowercase' => ['#aabbcc', '#AABBCC'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidColorsProvider')]
|
||||
public function constructThrowsExceptionForInvalidColor(string $invalidColor): void
|
||||
{
|
||||
$this->expectException(BrandColorInvalideException::class);
|
||||
|
||||
new BrandColor($invalidColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function invalidColorsProvider(): iterable
|
||||
{
|
||||
yield 'empty string' => [''];
|
||||
yield 'no hash' => ['3B82F6'];
|
||||
yield 'short format' => ['#FFF'];
|
||||
yield 'too short' => ['#3B82F'];
|
||||
yield 'too long' => ['#3B82F6F'];
|
||||
yield 'invalid characters' => ['#GGGGGG'];
|
||||
yield 'rgb format' => ['rgb(59,130,246)'];
|
||||
yield 'named color' => ['blue'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toRgbReturnsCorrectValues(): void
|
||||
{
|
||||
$color = new BrandColor('#3B82F6');
|
||||
$rgb = $color->toRgb();
|
||||
|
||||
self::assertSame(59, $rgb['r']);
|
||||
self::assertSame(130, $rgb['g']);
|
||||
self::assertSame(246, $rgb['b']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toRgbForBlack(): void
|
||||
{
|
||||
$color = new BrandColor('#000000');
|
||||
$rgb = $color->toRgb();
|
||||
|
||||
self::assertSame(0, $rgb['r']);
|
||||
self::assertSame(0, $rgb['g']);
|
||||
self::assertSame(0, $rgb['b']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toRgbForWhite(): void
|
||||
{
|
||||
$color = new BrandColor('#FFFFFF');
|
||||
$rgb = $color->toRgb();
|
||||
|
||||
self::assertSame(255, $rgb['r']);
|
||||
self::assertSame(255, $rgb['g']);
|
||||
self::assertSame(255, $rgb['b']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameValue(): void
|
||||
{
|
||||
$color1 = new BrandColor('#3B82F6');
|
||||
$color2 = new BrandColor('#3B82F6');
|
||||
|
||||
self::assertTrue($color1->equals($color2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForDifferentCase(): void
|
||||
{
|
||||
$color1 = new BrandColor('#3B82F6');
|
||||
$color2 = new BrandColor('#3b82f6');
|
||||
|
||||
self::assertTrue($color1->equals($color2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentValue(): void
|
||||
{
|
||||
$color1 = new BrandColor('#3B82F6');
|
||||
$color2 = new BrandColor('#EF4444');
|
||||
|
||||
self::assertFalse($color1->equals($color2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toStringReturnsValue(): void
|
||||
{
|
||||
$color = new BrandColor('#3B82F6');
|
||||
|
||||
self::assertSame('#3B82F6', (string) $color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ContrastValidatorTest extends TestCase
|
||||
{
|
||||
private ContrastValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new ContrastValidator();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blackOnWhitePassesWcagAA(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#000000'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertGreaterThanOrEqual(4.5, $result->ratio);
|
||||
self::assertTrue($result->passesAA);
|
||||
self::assertTrue($result->passesAALarge);
|
||||
self::assertNull($result->suggestion);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function whiteOnBlackPassesWcagAA(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#FFFFFF'),
|
||||
new BrandColor('#000000'),
|
||||
);
|
||||
|
||||
self::assertGreaterThanOrEqual(4.5, $result->ratio);
|
||||
self::assertTrue($result->passesAA);
|
||||
self::assertTrue($result->passesAALarge);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blackOnWhiteHasMaxContrast(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#000000'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertEqualsWithDelta(21.0, $result->ratio, 0.1);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sameColorHasMinContrast(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#3B82F6'),
|
||||
new BrandColor('#3B82F6'),
|
||||
);
|
||||
|
||||
self::assertEqualsWithDelta(1.0, $result->ratio, 0.01);
|
||||
self::assertFalse($result->passesAA);
|
||||
self::assertFalse($result->passesAALarge);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lightGrayOnWhiteFailsWcagAA(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#CCCCCC'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertLessThan(4.5, $result->ratio);
|
||||
self::assertFalse($result->passesAA);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lightGrayOnWhitePassesAALarge(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#767676'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertGreaterThanOrEqual(3.0, $result->ratio);
|
||||
self::assertTrue($result->passesAALarge);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failingContrastSuggestsAlternative(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#CCCCCC'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertFalse($result->passesAA);
|
||||
self::assertNotNull($result->suggestion);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passingContrastDoesNotSuggestAlternative(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#000000'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertTrue($result->passesAA);
|
||||
self::assertNull($result->suggestion);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function suggestedAlternativePassesWcagAA(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#CCCCCC'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertNotNull($result->suggestion);
|
||||
|
||||
$suggestionResult = $this->validator->validate(
|
||||
$result->suggestion,
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertTrue($suggestionResult->passesAA);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blueOnWhiteCalculatesCorrectRatio(): void
|
||||
{
|
||||
$result = $this->validator->validate(
|
||||
new BrandColor('#3B82F6'),
|
||||
new BrandColor('#FFFFFF'),
|
||||
);
|
||||
|
||||
self::assertGreaterThan(1.0, $result->ratio);
|
||||
self::assertLessThan(21.0, $result->ratio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Exception\LogoUrlInvalideException;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LogoUrlTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function constructWithValidHttpsUrl(): void
|
||||
{
|
||||
$url = new LogoUrl('https://s3.example.com/logos/school-logo.png');
|
||||
|
||||
self::assertSame('https://s3.example.com/logos/school-logo.png', $url->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructWithValidHttpUrl(): void
|
||||
{
|
||||
$url = new LogoUrl('http://localhost:9000/logos/test.png');
|
||||
|
||||
self::assertSame('http://localhost:9000/logos/test.png', $url->value);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validUrlsProvider')]
|
||||
public function constructWithValidUrls(string $input): void
|
||||
{
|
||||
$url = new LogoUrl($input);
|
||||
|
||||
self::assertSame($input, $url->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function validUrlsProvider(): iterable
|
||||
{
|
||||
yield 'https with path' => ['https://s3.amazonaws.com/bucket/logos/school.png'];
|
||||
yield 'https with query' => ['https://cdn.example.com/logo.png?v=12345'];
|
||||
yield 'http localhost' => ['http://localhost:9000/logos/test.jpg'];
|
||||
yield 'https with subdomain' => ['https://storage.googleapis.com/classeo/logos/abc.png'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('invalidUrlsProvider')]
|
||||
public function constructThrowsExceptionForInvalidUrl(string $invalidUrl): void
|
||||
{
|
||||
$this->expectException(LogoUrlInvalideException::class);
|
||||
|
||||
new LogoUrl($invalidUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function invalidUrlsProvider(): iterable
|
||||
{
|
||||
yield 'empty string' => [''];
|
||||
yield 'not a url' => ['not-a-url'];
|
||||
yield 'just path' => ['/logos/school.png'];
|
||||
yield 'missing scheme' => ['s3.example.com/logo.png'];
|
||||
yield 'ftp scheme' => ['ftp://files.example.com/logo.png'];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsTrueForSameValue(): void
|
||||
{
|
||||
$url1 = new LogoUrl('https://s3.example.com/logo.png');
|
||||
$url2 = new LogoUrl('https://s3.example.com/logo.png');
|
||||
|
||||
self::assertTrue($url1->equals($url2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function equalsReturnsFalseForDifferentValue(): void
|
||||
{
|
||||
$url1 = new LogoUrl('https://s3.example.com/logo1.png');
|
||||
$url2 = new LogoUrl('https://s3.example.com/logo2.png');
|
||||
|
||||
self::assertFalse($url1->equals($url2));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toStringReturnsValue(): void
|
||||
{
|
||||
$url = new LogoUrl('https://s3.example.com/logo.png');
|
||||
|
||||
self::assertSame('https://s3.example.com/logo.png', (string) $url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\SchoolBranding;
|
||||
|
||||
use App\Administration\Domain\Event\BrandingModifie;
|
||||
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
|
||||
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
|
||||
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SchoolBrandingTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
#[Test]
|
||||
public function creerWithDefaultValues(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
|
||||
self::assertTrue($branding->schoolId->equals(SchoolId::fromString(self::SCHOOL_ID)));
|
||||
self::assertTrue($branding->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertNull($branding->logoUrl);
|
||||
self::assertNull($branding->primaryColor);
|
||||
self::assertNull($branding->secondaryColor);
|
||||
self::assertNull($branding->accentColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function creerRecordsNoEvent(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierCouleursUpdatesPrimaryColor(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$primaryColor = new BrandColor('#3B82F6');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
self::assertNotNull($branding->primaryColor);
|
||||
self::assertTrue($branding->primaryColor->equals($primaryColor));
|
||||
self::assertNull($branding->secondaryColor);
|
||||
self::assertNull($branding->accentColor);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierCouleursUpdatesAllColors(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$primary = new BrandColor('#3B82F6');
|
||||
$secondary = new BrandColor('#1E40AF');
|
||||
$accent = new BrandColor('#60A5FA');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primary,
|
||||
secondaryColor: $secondary,
|
||||
accentColor: $accent,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
self::assertNotNull($branding->primaryColor);
|
||||
self::assertTrue($branding->primaryColor->equals($primary));
|
||||
self::assertNotNull($branding->secondaryColor);
|
||||
self::assertTrue($branding->secondaryColor->equals($secondary));
|
||||
self::assertNotNull($branding->accentColor);
|
||||
self::assertTrue($branding->accentColor->equals($accent));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierCouleursRecordsEvent(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$primaryColor = new BrandColor('#3B82F6');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
self::assertSame($now, $events[0]->occurredOn());
|
||||
self::assertTrue($branding->schoolId->value->equals($events[0]->aggregateId()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function modifierCouleursDoesNotRecordEventWhenNothingChanges(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$primaryColor = new BrandColor('#3B82F6');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: $primaryColor,
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: new DateTimeImmutable('2026-02-20 11:00:00'),
|
||||
);
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerLogoSetsLogoUrl(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
|
||||
self::assertNotNull($branding->logoUrl);
|
||||
self::assertTrue($branding->logoUrl->equals($logoUrl));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerLogoRecordsEvent(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerLogoDoesNotRecordEventWhenSame(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$branding->changerLogo($logoUrl, new DateTimeImmutable('2026-02-20 11:00:00'));
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function supprimerLogoRemovesLogo(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$logoUrl = new LogoUrl('https://s3.example.com/logos/school.png');
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->changerLogo($logoUrl, $now);
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$branding->supprimerLogo(new DateTimeImmutable('2026-02-20 11:00:00'));
|
||||
|
||||
self::assertNull($branding->logoUrl);
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function supprimerLogoDoesNotRecordEventWhenAlreadyNull(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
|
||||
$branding->supprimerLogo(new DateTimeImmutable('2026-02-20 10:00:00'));
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reinitialiserResetsAllToDefaults(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$now = new DateTimeImmutable('2026-02-20 10:00:00');
|
||||
|
||||
$branding->changerLogo(new LogoUrl('https://s3.example.com/logo.png'), $now);
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: new BrandColor('#3B82F6'),
|
||||
secondaryColor: new BrandColor('#1E40AF'),
|
||||
accentColor: new BrandColor('#60A5FA'),
|
||||
at: $now,
|
||||
);
|
||||
|
||||
$branding->pullDomainEvents();
|
||||
|
||||
$branding->reinitialiser(new DateTimeImmutable('2026-02-20 12:00:00'));
|
||||
|
||||
self::assertNull($branding->logoUrl);
|
||||
self::assertNull($branding->primaryColor);
|
||||
self::assertNull($branding->secondaryColor);
|
||||
self::assertNull($branding->accentColor);
|
||||
|
||||
$events = $branding->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(BrandingModifie::class, $events[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reinitialiserDoesNotRecordEventWhenAlreadyDefault(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
|
||||
$branding->reinitialiser(new DateTimeImmutable('2026-02-20 10:00:00'));
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estPersonnaliseReturnsFalseByDefault(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
|
||||
self::assertFalse($branding->estPersonnalise());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estPersonnaliseReturnsTrueWhenColorSet(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$branding->modifierCouleurs(
|
||||
primaryColor: new BrandColor('#3B82F6'),
|
||||
secondaryColor: null,
|
||||
accentColor: null,
|
||||
at: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertTrue($branding->estPersonnalise());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function estPersonnaliseReturnsTrueWhenLogoSet(): void
|
||||
{
|
||||
$branding = $this->creerBranding();
|
||||
$branding->changerLogo(
|
||||
new LogoUrl('https://s3.example.com/logo.png'),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
self::assertTrue($branding->estPersonnalise());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteFromStorage(): void
|
||||
{
|
||||
$schoolId = SchoolId::fromString(self::SCHOOL_ID);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
$logoUrl = new LogoUrl('https://s3.example.com/logo.png');
|
||||
$primary = new BrandColor('#3B82F6');
|
||||
$secondary = new BrandColor('#1E40AF');
|
||||
$accent = new BrandColor('#60A5FA');
|
||||
$createdAt = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
$updatedAt = new DateTimeImmutable('2026-02-15 14:30:00');
|
||||
$logoUpdatedAt = new DateTimeImmutable('2026-02-10 09:00:00');
|
||||
|
||||
$branding = SchoolBranding::reconstitute(
|
||||
schoolId: $schoolId,
|
||||
tenantId: $tenantId,
|
||||
logoUrl: $logoUrl,
|
||||
logoUpdatedAt: $logoUpdatedAt,
|
||||
primaryColor: $primary,
|
||||
secondaryColor: $secondary,
|
||||
accentColor: $accent,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
|
||||
self::assertTrue($branding->schoolId->equals($schoolId));
|
||||
self::assertTrue($branding->tenantId->equals($tenantId));
|
||||
self::assertNotNull($branding->logoUrl);
|
||||
self::assertTrue($branding->logoUrl->equals($logoUrl));
|
||||
self::assertNotNull($branding->primaryColor);
|
||||
self::assertTrue($branding->primaryColor->equals($primary));
|
||||
self::assertNotNull($branding->secondaryColor);
|
||||
self::assertTrue($branding->secondaryColor->equals($secondary));
|
||||
self::assertNotNull($branding->accentColor);
|
||||
self::assertTrue($branding->accentColor->equals($accent));
|
||||
self::assertSame($createdAt, $branding->createdAt);
|
||||
self::assertSame($updatedAt, $branding->updatedAt);
|
||||
|
||||
self::assertCount(0, $branding->pullDomainEvents());
|
||||
}
|
||||
|
||||
private function creerBranding(): SchoolBranding
|
||||
{
|
||||
return SchoolBranding::creer(
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-01 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
313
frontend/e2e/branding.spec.ts
Normal file
313
frontend/e2e/branding.spec.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
// Test credentials — unique to this spec to avoid cross-spec collisions
|
||||
const ADMIN_EMAIL = 'e2e-branding-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'BrandingAdmin123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
// Minimal valid 1x1 transparent PNG for logo upload tests
|
||||
const TEST_LOGO_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
test.describe('Branding Visual Customization', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Clean up branding data from previous test runs
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_branding WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Clean up logo files from previous test runs
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php sh -c "rm -rf /app/public/uploads/logos/${TENANT_ID}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to login as admin
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the branding page to finish loading.
|
||||
*
|
||||
* After hydration, the page shows the card sections (logo + colors).
|
||||
* Waiting for the heading and the first .card ensures the component
|
||||
* is interactive and API data has been fetched.
|
||||
*/
|
||||
async function waitForPageLoaded(page: import('@playwright/test').Page) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /identité visuelle/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Wait for at least one card section to appear (loading finished)
|
||||
await expect(
|
||||
page.locator('.card').first()
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// [P2] Page displays logo and color sections (AC1)
|
||||
// ============================================================================
|
||||
test('[P2] page affiche les sections logo et couleurs', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Title
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /identité visuelle/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Subtitle
|
||||
await expect(
|
||||
page.getByText(/personnalisez le logo et les couleurs/i)
|
||||
).toBeVisible();
|
||||
|
||||
// Logo section heading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /logo de l'établissement/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Format info
|
||||
await expect(
|
||||
page.getByText(/formats acceptés/i)
|
||||
).toBeVisible();
|
||||
|
||||
// Logo placeholder (no logo initially)
|
||||
await expect(
|
||||
page.getByText(/aucun logo configuré/i)
|
||||
).toBeVisible();
|
||||
|
||||
// Upload button
|
||||
await expect(
|
||||
page.getByText('Importer un logo')
|
||||
).toBeVisible();
|
||||
|
||||
// Color section heading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /couleur principale/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Color picker and text input
|
||||
await expect(page.locator('#primaryColorPicker')).toBeVisible();
|
||||
await expect(page.locator('#primaryColor')).toBeVisible();
|
||||
|
||||
// Reset and save buttons
|
||||
await expect(
|
||||
page.getByRole('button', { name: /réinitialiser/i })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /enregistrer/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [P1] Changing color updates contrast indicator and preview (AC3)
|
||||
// ============================================================================
|
||||
test('[P1] modifier la couleur met à jour l\'indicateur de contraste et l\'aperçu', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
const colorInput = page.locator('#primaryColor');
|
||||
|
||||
// --- Dark blue: passes AA (ratio ~10.3) → "Lisible" ---
|
||||
await colorInput.fill('#1E3A5F');
|
||||
await expect(page.locator('.contrast-indicator.pass')).toBeVisible();
|
||||
await expect(page.locator('.contrast-badge')).toContainText('Lisible');
|
||||
await expect(page.locator('.preview-swatch').first()).toBeVisible();
|
||||
await expect(page.locator('.preview-swatch').first()).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(30, 58, 95)'
|
||||
);
|
||||
|
||||
// --- Yellow: fails AA completely (ratio ~1.07) → "Illisible" ---
|
||||
await colorInput.fill('#FFFF00');
|
||||
await expect(page.locator('.contrast-indicator.fail')).toBeVisible();
|
||||
await expect(page.locator('.contrast-badge')).toContainText('Illisible');
|
||||
|
||||
// --- Dark yellow: passes AA Large only (ratio ~3.7) → "Attention" ---
|
||||
await colorInput.fill('#8B8000');
|
||||
await expect(page.locator('.contrast-indicator.warning')).toBeVisible();
|
||||
await expect(page.locator('.contrast-badge')).toContainText('Attention');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [P1] Saving colors applies CSS variables immediately (AC3, AC5)
|
||||
// ============================================================================
|
||||
test('[P1] enregistrer les couleurs applique les CSS variables immédiatement', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Set a dark blue color
|
||||
await page.locator('#primaryColor').fill('#1E3A5F');
|
||||
|
||||
// Click save and wait for API response
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
await responsePromise;
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
||||
|
||||
// CSS variables applied to document root
|
||||
const accentPrimary = await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--accent-primary')
|
||||
);
|
||||
expect(accentPrimary).toBe('#1E3A5F');
|
||||
|
||||
const btnPrimaryBg = await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--btn-primary-bg')
|
||||
);
|
||||
expect(btnPrimaryBg).toBe('#1E3A5F');
|
||||
|
||||
// Save button should be disabled (no pending changes)
|
||||
await expect(
|
||||
page.getByRole('button', { name: /enregistrer/i })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [P2] Upload logo displays preview (AC2)
|
||||
// ============================================================================
|
||||
test('[P2] upload logo affiche l\'aperçu', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Initially no logo
|
||||
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
|
||||
|
||||
// Trigger file chooser and upload the test PNG
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByText('Importer un logo').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'POST'
|
||||
);
|
||||
await fileChooser.setFiles({
|
||||
name: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: TEST_LOGO_PNG
|
||||
});
|
||||
await responsePromise;
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/logo mis à jour/i);
|
||||
|
||||
// Logo image is now visible
|
||||
await expect(page.locator('.logo-image')).toBeVisible();
|
||||
|
||||
// "Changer le logo" and "Supprimer" buttons visible
|
||||
await expect(page.getByText('Changer le logo')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /supprimer/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Placeholder text is gone
|
||||
await expect(page.getByText(/aucun logo configuré/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [P2] Delete logo returns to no-logo state (AC2)
|
||||
// ============================================================================
|
||||
test('[P2] supprimer logo revient à l\'état sans logo', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Logo should be visible from previous test
|
||||
await expect(page.locator('.logo-image')).toBeVisible();
|
||||
|
||||
// Accept the confirmation dialog, wait for DELETE response, then click
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/school/branding/logo') && resp.request().method() === 'DELETE'
|
||||
);
|
||||
await page.getByRole('button', { name: /supprimer/i }).click();
|
||||
await responsePromise;
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/logo supprimé/i);
|
||||
|
||||
// Back to placeholder state
|
||||
await expect(page.getByText(/aucun logo configuré/i)).toBeVisible();
|
||||
await expect(page.getByText('Importer un logo')).toBeVisible();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// [P2] Reset restores default theme (AC4)
|
||||
// ============================================================================
|
||||
test('[P2] réinitialiser restaure le thème par défaut', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/branding`);
|
||||
await waitForPageLoaded(page);
|
||||
|
||||
// Color should be set from test 3
|
||||
await expect(page.locator('#primaryColor')).toHaveValue('#1E3A5F');
|
||||
|
||||
// Accept the confirmation dialog, wait for PUT response, then click
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/school/branding') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: /réinitialiser/i }).click();
|
||||
await responsePromise;
|
||||
|
||||
// Success message
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
|
||||
|
||||
// Color input is now empty
|
||||
await expect(page.locator('#primaryColor')).toHaveValue('');
|
||||
|
||||
// CSS variables removed
|
||||
const accentPrimary = await page.evaluate(() =>
|
||||
document.documentElement.style.getPropertyValue('--accent-primary')
|
||||
);
|
||||
expect(accentPrimary).toBe('');
|
||||
|
||||
// Preview swatch should not be visible (no primary color set)
|
||||
await expect(page.locator('.preview-swatch')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,7 @@
|
||||
}
|
||||
|
||||
.pagination-page.active {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -448,6 +448,6 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.child-button.selected {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
<span class="action-label">Pédagogie</span>
|
||||
<span class="action-hint">Mode de notation</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/branding">
|
||||
<span class="action-icon">🎨</span>
|
||||
<span class="action-label">Identité visuelle</span>
|
||||
<span class="action-hint">Logo et couleurs</span>
|
||||
</a>
|
||||
<div class="action-card disabled" aria-disabled="true">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-label">Importer des données</span>
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
.replacement-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
@@ -274,7 +274,7 @@
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.alert {
|
||||
@@ -492,7 +492,7 @@
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
@@ -501,7 +501,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
177
frontend/src/lib/features/branding/brandingStore.svelte.ts
Normal file
177
frontend/src/lib/features/branding/brandingStore.svelte.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
|
||||
/**
|
||||
* Store réactif pour le branding de l'établissement.
|
||||
*
|
||||
* Charge la configuration branding depuis l'API et injecte
|
||||
* les CSS variables correspondantes dans :root.
|
||||
*
|
||||
* @see FR83 - Configurer logo et couleurs établissement
|
||||
* @see Story 2.13 - Personnalisation visuelle établissement
|
||||
*/
|
||||
|
||||
export interface BrandingConfig {
|
||||
schoolId: string;
|
||||
logoUrl: string | null;
|
||||
logoUpdatedAt: string | null;
|
||||
primaryColor: string | null;
|
||||
secondaryColor: string | null;
|
||||
accentColor: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let branding = $state<BrandingConfig | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let isFetched = $state(false);
|
||||
|
||||
const CSS_VAR_PREFIX = '--brand';
|
||||
|
||||
/**
|
||||
* Charge le branding depuis l'API.
|
||||
*/
|
||||
export async function fetchBranding(): Promise<void> {
|
||||
if (!browser || isFetched || isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/school/branding`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data: BrandingConfig = await response.json();
|
||||
branding = data;
|
||||
isFetched = true;
|
||||
applyCssVariables(data);
|
||||
} catch (error) {
|
||||
console.error('[brandingStore] Failed to fetch branding:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le branding local après une mutation.
|
||||
*/
|
||||
export function updateBranding(data: BrandingConfig): void {
|
||||
branding = data;
|
||||
applyCssVariables(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne la configuration branding actuelle.
|
||||
*/
|
||||
export function getBranding(): BrandingConfig | null {
|
||||
return branding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'URL du logo (réactif via $state).
|
||||
*/
|
||||
export function getLogoUrl(): string | null {
|
||||
return branding?.logoUrl ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'état de chargement.
|
||||
*/
|
||||
export function getBrandingLoading(): boolean {
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise l'état (à appeler au logout).
|
||||
*/
|
||||
export function resetBranding(): void {
|
||||
branding = null;
|
||||
isFetched = false;
|
||||
removeCssVariables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Injecte les CSS variables dans :root.
|
||||
*
|
||||
* On surcharge les design tokens existants (--accent-primary, etc.)
|
||||
* pour que tous les composants (header, nav, boutons actifs) adoptent
|
||||
* automatiquement les couleurs de l'établissement.
|
||||
*/
|
||||
function applyCssVariables(config: BrandingConfig): void {
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
if (config.primaryColor) {
|
||||
root.style.setProperty('--accent-primary', config.primaryColor);
|
||||
root.style.setProperty('--accent-primary-light', hexToLight(config.primaryColor));
|
||||
root.style.setProperty('--btn-primary-bg', config.primaryColor);
|
||||
root.style.setProperty('--btn-primary-hover-bg', hexToDark(config.primaryColor));
|
||||
} else {
|
||||
root.style.removeProperty('--accent-primary');
|
||||
root.style.removeProperty('--accent-primary-light');
|
||||
root.style.removeProperty('--btn-primary-bg');
|
||||
root.style.removeProperty('--btn-primary-hover-bg');
|
||||
}
|
||||
|
||||
if (config.logoUrl) {
|
||||
root.style.setProperty(`${CSS_VAR_PREFIX}-logo-url`, `url('${config.logoUrl}')`);
|
||||
} else {
|
||||
root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une version claire d'une couleur hex (pour les fonds des éléments actifs).
|
||||
* Mélange la couleur avec du blanc à 12% d'opacité.
|
||||
*/
|
||||
function hexToLight(hex: string): string {
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return '#e0f2fe';
|
||||
const r = Math.round(255 - (255 - rgb.r) * 0.12);
|
||||
const g = Math.round(255 - (255 - rgb.g) * 0.12);
|
||||
const b = Math.round(255 - (255 - rgb.b) * 0.12);
|
||||
return toHex(r, g, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une version sombre d'une couleur hex (pour les états hover des boutons).
|
||||
*/
|
||||
function hexToDark(hex: string): string {
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return '#2563eb';
|
||||
const r = Math.round(rgb.r * 0.85);
|
||||
const g = Math.round(rgb.g * 0.85);
|
||||
const b = Math.round(rgb.b * 0.85);
|
||||
return toHex(r, g, b);
|
||||
}
|
||||
|
||||
function parseHex(hex: string): { r: number; g: number; b: number } | null {
|
||||
const match = hex.match(/^#([0-9A-Fa-f]{6})$/);
|
||||
if (!match?.[1]) return null;
|
||||
const h = match[1];
|
||||
return {
|
||||
r: parseInt(h.substring(0, 2), 16),
|
||||
g: parseInt(h.substring(2, 4), 16),
|
||||
b: parseInt(h.substring(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function toHex(r: number, g: number, b: number): string {
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les CSS variables du branding.
|
||||
*/
|
||||
function removeCssVariables(): void {
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.removeProperty('--accent-primary');
|
||||
root.style.removeProperty('--accent-primary-light');
|
||||
root.style.removeProperty('--btn-primary-bg');
|
||||
root.style.removeProperty('--btn-primary-hover-bg');
|
||||
root.style.removeProperty(`${CSS_VAR_PREFIX}-logo-url`);
|
||||
}
|
||||
@@ -138,7 +138,7 @@
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
@@ -147,7 +147,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
import { logout } from '$lib/auth/auth.svelte';
|
||||
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
|
||||
import { fetchRoles, getRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let isLoggingOut = $state(false);
|
||||
let accessChecked = $state(false);
|
||||
let hasAccess = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
let logoUrl = $derived(getLogoUrl());
|
||||
|
||||
const ADMIN_ROLES = [
|
||||
'ROLE_SUPER_ADMIN',
|
||||
@@ -30,7 +32,8 @@
|
||||
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
|
||||
{ href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive },
|
||||
{ href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive },
|
||||
{ href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive }
|
||||
];
|
||||
|
||||
// Load user roles and verify admin access
|
||||
@@ -46,12 +49,14 @@
|
||||
|
||||
hasAccess = true;
|
||||
accessChecked = true;
|
||||
fetchBranding();
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
isLoggingOut = true;
|
||||
try {
|
||||
resetRoleContext();
|
||||
resetBranding();
|
||||
await logout();
|
||||
} finally {
|
||||
isLoggingOut = false;
|
||||
@@ -84,6 +89,7 @@
|
||||
const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar'));
|
||||
const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights'));
|
||||
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
|
||||
const isBrandingActive = $derived(page.url.pathname.startsWith('/admin/branding'));
|
||||
|
||||
const currentSectionLabel = $derived.by(() => {
|
||||
const path = page.url.pathname;
|
||||
@@ -138,6 +144,9 @@
|
||||
<header class="admin-header">
|
||||
<div class="header-content">
|
||||
<button class="logo-button" onclick={goHome}>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
</button>
|
||||
|
||||
@@ -186,6 +195,9 @@
|
||||
aria-label="Menu de navigation"
|
||||
>
|
||||
<div class="mobile-drawer-header">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
<button
|
||||
class="mobile-close"
|
||||
@@ -270,12 +282,22 @@
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -772,7 +772,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -782,7 +782,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -552,7 +552,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -562,7 +562,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
788
frontend/src/routes/admin/branding/+page.svelte
Normal file
788
frontend/src/routes/admin/branding/+page.svelte
Normal file
@@ -0,0 +1,788 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
import {
|
||||
updateBranding,
|
||||
getBranding,
|
||||
type BrandingConfig
|
||||
} from '$features/branding/brandingStore.svelte';
|
||||
|
||||
// State
|
||||
let branding = $state<BrandingConfig | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
// Color form state
|
||||
let primaryColor = $state<string | null>(null);
|
||||
let isSavingColors = $state(false);
|
||||
|
||||
// Logo state
|
||||
let isUploadingLogo = $state(false);
|
||||
let isDeletingLogo = $state(false);
|
||||
let fileInput = $state<HTMLInputElement>();
|
||||
|
||||
// Contrast computation
|
||||
const HEX_PATTERN = /^#[0-9A-Fa-f]{6}$/;
|
||||
let isValidHex = $derived(primaryColor ? HEX_PATTERN.test(primaryColor) : false);
|
||||
|
||||
let contrastInfo = $derived.by(() => {
|
||||
if (!primaryColor || !isValidHex) return null;
|
||||
return computeContrast(primaryColor, '#FFFFFF');
|
||||
});
|
||||
|
||||
// Track unsaved color changes — block save if contrast fails WCAG AA
|
||||
let contrastBlocked = $derived(contrastInfo !== null && !contrastInfo.passesAA);
|
||||
let hasColorChanges = $derived(
|
||||
primaryColor !== (branding?.primaryColor ?? null) &&
|
||||
(primaryColor === null || isValidHex) &&
|
||||
!contrastBlocked
|
||||
);
|
||||
|
||||
// Load branding on mount
|
||||
$effect(() => {
|
||||
loadBranding();
|
||||
});
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
// Try store first
|
||||
const cached = getBranding();
|
||||
if (cached) {
|
||||
branding = cached;
|
||||
syncFormFromBranding(cached);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/school/branding`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Impossible de charger la configuration du branding.');
|
||||
}
|
||||
|
||||
const data: BrandingConfig = await response.json();
|
||||
branding = data;
|
||||
syncFormFromBranding(data);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFormFromBranding(config: BrandingConfig) {
|
||||
primaryColor = config.primaryColor;
|
||||
}
|
||||
|
||||
async function handleSaveColors() {
|
||||
try {
|
||||
isSavingColors = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/school/branding`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
primaryColor,
|
||||
secondaryColor: null,
|
||||
accentColor: null
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData['hydra:description'] || errorData.message || 'Erreur lors de la sauvegarde.'
|
||||
);
|
||||
}
|
||||
|
||||
const data: BrandingConfig = await response.json();
|
||||
branding = data;
|
||||
syncFormFromBranding(data);
|
||||
updateBranding(data);
|
||||
successMessage = 'Couleurs mises à jour avec succès.';
|
||||
setTimeout(() => (successMessage = null), 4000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur';
|
||||
} finally {
|
||||
isSavingColors = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadLogo() {
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
isUploadingLogo = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
const formData = new window.FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/school/branding/logo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData['hydra:description'] || errorData.message || 'Erreur lors de l\'upload.'
|
||||
);
|
||||
}
|
||||
|
||||
const data: BrandingConfig = await response.json();
|
||||
branding = data;
|
||||
syncFormFromBranding(data);
|
||||
updateBranding(data);
|
||||
successMessage = 'Logo mis à jour avec succès.';
|
||||
setTimeout(() => (successMessage = null), 4000);
|
||||
|
||||
// Reset file input
|
||||
if (fileInput) fileInput.value = '';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur';
|
||||
} finally {
|
||||
isUploadingLogo = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLogo(skipConfirm = false) {
|
||||
if (!skipConfirm && !window.confirm('Êtes-vous sûr de vouloir supprimer le logo ?')) return;
|
||||
|
||||
try {
|
||||
isDeletingLogo = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/school/branding/logo`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error('Erreur lors de la suppression du logo.');
|
||||
}
|
||||
|
||||
if (branding) {
|
||||
branding = { ...branding, logoUrl: null, logoUpdatedAt: null };
|
||||
updateBranding(branding);
|
||||
}
|
||||
if (!skipConfirm) {
|
||||
successMessage = 'Logo supprimé avec succès.';
|
||||
setTimeout(() => (successMessage = null), 4000);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur';
|
||||
} finally {
|
||||
isDeletingLogo = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!window.confirm('Réinitialiser toutes les personnalisations vers le thème par défaut ?')) return;
|
||||
|
||||
if (branding?.logoUrl) {
|
||||
await handleDeleteLogo(true);
|
||||
}
|
||||
primaryColor = null;
|
||||
await handleSaveColors();
|
||||
}
|
||||
|
||||
// WCAG contrast computation
|
||||
function computeContrast(
|
||||
fg: string,
|
||||
bg: string
|
||||
): { ratio: number; passesAA: boolean; passesAALarge: boolean } {
|
||||
const fgRgb = hexToRgb(fg);
|
||||
const bgRgb = hexToRgb(bg);
|
||||
if (!fgRgb || !bgRgb) return { ratio: 0, passesAA: false, passesAALarge: false };
|
||||
|
||||
const l1 = relativeLuminance(fgRgb);
|
||||
const l2 = relativeLuminance(bgRgb);
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
const ratio = (lighter + 0.05) / (darker + 0.05);
|
||||
|
||||
return {
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
passesAA: ratio >= 4.5,
|
||||
passesAALarge: ratio >= 3.0
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const match = hex.match(/^#([0-9A-Fa-f]{6})$/);
|
||||
if (!match?.[1]) return null;
|
||||
const hex6 = match[1];
|
||||
return {
|
||||
r: parseInt(hex6.substring(0, 2), 16),
|
||||
g: parseInt(hex6.substring(2, 4), 16),
|
||||
b: parseInt(hex6.substring(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLuminance(rgb: { r: number; g: number; b: number }): number {
|
||||
const r = linearize(rgb.r / 255);
|
||||
const g = linearize(rgb.g / 255);
|
||||
const b = linearize(rgb.b / 255);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function linearize(v: number): number {
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Identité visuelle - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Identité visuelle</h1>
|
||||
<p class="subtitle">Personnalisez le logo et les couleurs de votre établissement.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="alert alert-success" role="status">
|
||||
<span>{successMessage}</span>
|
||||
<button class="alert-close" onclick={() => (successMessage = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Logo Section -->
|
||||
<section class="card">
|
||||
<h2>Logo de l'établissement</h2>
|
||||
<p class="section-description">
|
||||
Formats acceptés : PNG, JPG. Taille maximale : 2 Mo. Le logo sera redimensionné automatiquement.
|
||||
</p>
|
||||
|
||||
<div class="logo-section">
|
||||
{#if branding?.logoUrl}
|
||||
<div class="logo-preview">
|
||||
<img src={branding.logoUrl} alt="Logo de l'établissement" class="logo-image" />
|
||||
</div>
|
||||
<div class="logo-actions">
|
||||
<label class="btn-secondary btn-file">
|
||||
Changer le logo
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
bind:this={fileInput}
|
||||
onchange={handleUploadLogo}
|
||||
hidden
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-danger"
|
||||
onclick={() => handleDeleteLogo()}
|
||||
disabled={isDeletingLogo}
|
||||
>
|
||||
{isDeletingLogo ? 'Suppression...' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="logo-placeholder">
|
||||
<span class="logo-placeholder-icon">+</span>
|
||||
<p>Aucun logo configuré</p>
|
||||
</div>
|
||||
<label class="btn-primary btn-file">
|
||||
{#if isUploadingLogo}
|
||||
Upload en cours...
|
||||
{:else}
|
||||
Importer un logo
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
bind:this={fileInput}
|
||||
onchange={handleUploadLogo}
|
||||
disabled={isUploadingLogo}
|
||||
hidden
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Colors Section -->
|
||||
<section class="card">
|
||||
<h2>Couleur principale</h2>
|
||||
<p class="section-description">
|
||||
Définissez la couleur de votre établissement. Elle sera appliquée aux boutons, à la navigation
|
||||
et aux éléments actifs. Laissez vide pour utiliser le thème par défaut Classeo.
|
||||
</p>
|
||||
|
||||
<div class="color-field">
|
||||
<label for="primaryColor">Couleur</label>
|
||||
<div class="color-input-group">
|
||||
<input
|
||||
type="color"
|
||||
id="primaryColorPicker"
|
||||
value={primaryColor ?? '#3B82F6'}
|
||||
onchange={(e) => (primaryColor = e.currentTarget.value.toUpperCase())}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="primaryColor"
|
||||
placeholder="#3B82F6"
|
||||
value={primaryColor ?? ''}
|
||||
oninput={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
primaryColor = val ? val.toUpperCase() : null;
|
||||
}}
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if primaryColor}
|
||||
<button
|
||||
class="color-clear"
|
||||
onclick={() => (primaryColor = null)}
|
||||
title="Effacer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if contrastInfo}
|
||||
<div
|
||||
class="contrast-indicator"
|
||||
class:pass={contrastInfo.passesAA}
|
||||
class:warning={!contrastInfo.passesAA && contrastInfo.passesAALarge}
|
||||
class:fail={!contrastInfo.passesAALarge}
|
||||
>
|
||||
{#if contrastInfo.passesAA}
|
||||
<span class="contrast-badge">Lisible</span>
|
||||
<span>Le texte blanc sur cette couleur est facile à lire.</span>
|
||||
{:else if contrastInfo.passesAALarge}
|
||||
<span class="contrast-badge">Attention</span>
|
||||
<span>Le texte blanc est lisible en gros uniquement. Les petits textes seront difficiles à lire.</span>
|
||||
{:else}
|
||||
<span class="contrast-badge">Illisible</span>
|
||||
<span>Cette couleur est trop claire : le texte blanc sur les boutons sera difficile à lire. Choisissez une couleur plus foncée.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if primaryColor}
|
||||
<div class="preview-section">
|
||||
<h3>Aperçu</h3>
|
||||
<div class="preview-bar">
|
||||
<div class="preview-swatch" style="background-color: {primaryColor}">
|
||||
<span style="color: white">Boutons</span>
|
||||
</div>
|
||||
<div class="preview-swatch preview-swatch-nav" style="border-color: {primaryColor}; color: {primaryColor}">
|
||||
Navigation active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" onclick={handleReset} disabled={isSavingColors}>
|
||||
Réinitialiser
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={handleSaveColors}
|
||||
disabled={isSavingColors || !hasColorChanges}
|
||||
>
|
||||
{isSavingColors ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo section */
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.logo-placeholder-icon {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.logo-placeholder p {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
.color-field {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.color-field label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.color-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-input-group input[type='color'] {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 2px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-input-group input[type='text'] {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.color-input-group input[type='text']:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.color-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.color-clear:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Contrast indicator */
|
||||
.contrast-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contrast-indicator.pass {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.contrast-indicator.warning {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.contrast-indicator.fail {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.contrast-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contrast-indicator.pass .contrast-badge {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.contrast-indicator.warning .contrast-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.contrast-indicator.fail .contrast-badge {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.preview-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-section h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 60px;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-swatch-nav {
|
||||
background: #f8fafc;
|
||||
border: 2px solid;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -423,7 +423,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -433,7 +433,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -570,7 +570,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -580,7 +580,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -710,7 +710,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -720,7 +720,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -521,7 +521,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -531,7 +531,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -480,7 +480,7 @@
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -490,7 +490,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -948,7 +948,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -958,7 +958,7 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--btn-primary-hover-bg, #2563eb);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
|
||||
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
|
||||
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let isLoggingOut = $state(false);
|
||||
let logoUrl = $derived(getLogoUrl());
|
||||
|
||||
// Load user roles on mount for multi-role context switching (FR5)
|
||||
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
|
||||
@@ -17,6 +19,7 @@
|
||||
if (!refreshed) return;
|
||||
}
|
||||
fetchRoles();
|
||||
fetchBranding();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +27,7 @@
|
||||
isLoggingOut = true;
|
||||
try {
|
||||
resetRoleContext();
|
||||
resetBranding();
|
||||
await logout();
|
||||
} finally {
|
||||
isLoggingOut = false;
|
||||
@@ -43,6 +47,9 @@
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
<button class="logo-button" onclick={goHome}>
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
</button>
|
||||
<nav class="header-nav">
|
||||
@@ -98,12 +105,22 @@
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
}
|
||||
|
||||
.demo-controls button.active {
|
||||
background: #3b82f6;
|
||||
background: var(--btn-primary-bg, #3b82f6);
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user