Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.

- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
    exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
  - Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
    custom override) that auto-load selected fonts.
  - Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
  - New tests cover font sync command and font manifest API.

  Tests run: php artisan test --filter=Fonts --testsuite=Feature.
  Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
  untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
This commit is contained in:
Codex Agent
2025-11-25 19:31:52 +01:00
parent 4d31eb4d42
commit 9bde8f3f32
38 changed files with 2420 additions and 104 deletions

View File

@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\Api\Event;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventType;
use App\Models\Package;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventBrandingResponseTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_extended_branding_shape_with_logo_and_buttons(): void
{
$package = Package::factory()->create([
'branding_allowed' => true,
]);
$eventType = EventType::factory()->create([
'icon' => 'party',
]);
$event = Event::factory()->create([
'status' => 'published',
'event_type_id' => $eventType->id,
'settings' => [
'branding' => [
'palette' => [
'primary' => '#123456',
'secondary' => '#654321',
'background' => '#f0f0f0',
'surface' => '#ffffff',
],
'typography' => [
'heading' => 'Playfair Display',
'body' => 'Inter, sans-serif',
'size' => 'l',
],
'logo' => [
'mode' => 'upload',
'value' => 'branding/test.png',
'position' => 'center',
'size' => 'l',
],
'buttons' => [
'style' => 'outline',
'radius' => 18,
'primary' => '#ff0000',
'secondary' => '#00ff00',
'link_color' => '#111111',
],
'mode' => 'dark',
],
],
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => 0,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(7),
]);
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'branding-check']);
$response = $this->getJson('/api/v1/events/'.$token->plain_token);
$response->assertOk();
$response->assertJsonPath('branding.palette.primary', '#123456');
$response->assertJsonPath('branding.palette.surface', '#ffffff');
$response->assertJsonPath('branding.typography.heading', 'Playfair Display');
$response->assertJsonPath('branding.typography.size', 'l');
$response->assertJsonPath('branding.logo.mode', 'upload');
$this->assertStringContainsString('/storage/', (string) $response->json('branding.logo.value'));
$response->assertJsonPath('branding.logo.position', 'center');
$response->assertJsonPath('branding.buttons.style', 'outline');
$response->assertJsonPath('branding.buttons.radius', 18);
$response->assertJsonPath('branding.mode', 'dark');
}
public function test_it_uses_tenant_branding_when_use_default_flag_is_enabled(): void
{
$package = Package::factory()->create([
'branding_allowed' => true,
]);
$event = Event::factory()->create([
'status' => 'published',
'settings' => [
'branding' => [
'use_default_branding' => true,
'primary_color' => '#000000',
'secondary_color' => '#111111',
],
],
]);
$event->tenant->update([
'settings' => [
'branding' => [
'primary_color' => '#abcdef',
'secondary_color' => '#fedcba',
'background_color' => '#ffffff',
'buttons' => [
'style' => 'filled',
'radius' => 8,
],
],
],
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => 0,
'purchased_at' => now(),
'gallery_expires_at' => now()->addDays(14),
]);
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'branding-default']);
$response = $this->getJson('/api/v1/events/'.$token->plain_token);
$response->assertOk();
$response->assertJsonPath('branding.use_default_branding', true);
$response->assertJsonPath('branding.primary_color', '#abcdef');
$response->assertJsonPath('branding.secondary_color', '#fedcba');
$response->assertJsonPath('branding.buttons.radius', 8);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Tests\Feature\Api;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class TenantFontsTest extends TestCase
{
use RefreshDatabase;
private ?string $manifestBackup = null;
protected function setUp(): void
{
parent::setUp();
$manifestPath = public_path('fonts/google/manifest.json');
if (File::exists($manifestPath)) {
$this->manifestBackup = File::get($manifestPath);
}
}
protected function tearDown(): void
{
$manifestPath = public_path('fonts/google/manifest.json');
if ($this->manifestBackup !== null) {
File::put($manifestPath, $this->manifestBackup);
} else {
File::delete($manifestPath);
}
parent::tearDown();
}
public function test_tenant_can_fetch_font_manifest(): void
{
$manifestPath = public_path('fonts/google/manifest.json');
File::ensureDirectoryExists(dirname($manifestPath));
File::put($manifestPath, json_encode([
'fonts' => [
[
'family' => 'Manifest Font',
'category' => 'sans-serif',
'variants' => [
['variant' => 'regular', 'weight' => 400, 'style' => 'normal', 'url' => '/fonts/google/manifest-font/regular.woff2'],
],
],
],
]));
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]);
$token = $user->createToken('test')->plainTextToken;
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/tenant/fonts');
$response->assertOk();
$response->assertJsonStructure(['data']);
$this->assertTrue(collect($response->json('data'))->pluck('family')->contains('Manifest Font'));
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class SyncGoogleFontsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('services.google_fonts.key', 'test-key');
}
public function test_it_downloads_fonts_and_writes_manifest(): void
{
$targetPath = storage_path('app/test-fonts');
File::deleteDirectory($targetPath);
Http::fake([
'https://www.googleapis.com/webfonts/v1/webfonts*' => Http::response([
'items' => [
[
'family' => 'Alpha Sans',
'category' => 'sans-serif',
'variants' => ['regular', '700'],
'files' => [
'regular' => 'https://fonts.gstatic.com/s/alpha-regular.woff2',
'700' => 'https://fonts.gstatic.com/s/alpha-700.woff2',
],
],
[
'family' => 'Beta Serif',
'category' => 'serif',
'variants' => ['regular'],
'files' => [
'regular' => 'https://fonts.gstatic.com/s/beta-regular.ttf',
],
],
],
]),
'https://fonts.gstatic.com/*' => Http::response('font-binary', 200),
]);
Artisan::call('fonts:sync-google', [
'--count' => 2,
'--weights' => '400,700',
'--path' => 'storage/app/test-fonts',
'--force' => true,
]);
$manifestPath = $targetPath.'/manifest.json';
$cssPath = $targetPath.'/fonts.css';
$this->assertFileExists($manifestPath);
$this->assertFileExists($cssPath);
$manifest = json_decode(File::get($manifestPath), true);
$this->assertSame(2, $manifest['count']);
$this->assertCount(2, $manifest['fonts']);
$family = collect($manifest['fonts']);
$this->assertTrue($family->pluck('family')->contains('Alpha Sans'));
$this->assertTrue($family->pluck('family')->contains('Beta Serif'));
$this->assertTrue(str_contains(File::get($cssPath), "font-family: 'Alpha Sans';"));
$this->assertTrue(str_contains(File::get($cssPath), "font-family: 'Beta Serif';"));
File::deleteDirectory($targetPath);
}
}