White-Label Phase 3: Branding-Verwaltung

- BrandingAdminController: GET/PUT /api/my-branding (Farben, Tagline),
  Logo-Upload/-Löschen, öffentliche Logo-Auslieferung /api/branding/logo/...
- BrandingService liefert logoUrl aus S3-logoKey (Firma → Reseller → Default)
- BrandingView (Reseller-Topnav + Firmen-Sidebar): Farbwähler, Slogan,
  Logo-Upload mit Live-Vorschau, Anzeige der eigenen Adresse

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 15:15:27 +02:00
parent bcd8ba969a
commit a233c34599
5 changed files with 432 additions and 16 deletions

View File

@ -0,0 +1,194 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Security\TenantContext;
use App\Service\BrandingService;
use App\Service\TenantResolver;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* White-Label-Branding pflegen: Reseller-Admin brandet seinen Reseller,
* Firmen-Admin seine Firma (Farben, Tagline, Logo). Plus öffentliche Logo-Auslieferung.
*/
final class BrandingAdminController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
private readonly TenantResolver $resolver,
private readonly BrandingService $branding,
#[Autowire(service: 'card_assets.storage')]
private readonly FilesystemOperator $cardAssets,
) {
}
#[Route('/api/my-branding', name: 'my_branding_get', methods: ['GET'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function get(): JsonResponse
{
return new JsonResponse($this->serialize());
}
#[Route('/api/my-branding', name: 'my_branding_put', methods: ['PUT'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function put(Request $request): JsonResponse
{
[, $entity] = $this->target();
$data = json_decode($request->getContent(), true) ?? [];
$cfg = $entity->getBrandingConfig();
foreach (['primaryColor', 'primaryColorDark', 'primaryColorSoft'] as $key) {
if (\array_key_exists($key, $data)) {
$hex = $this->hex($data[$key] ?? null);
if (null !== $hex) {
$cfg[$key] = $hex;
} else {
unset($cfg[$key]);
}
}
}
if (\array_key_exists('tagline', $data)) {
$tag = trim((string) ($data['tagline'] ?? ''));
if ('' !== $tag) {
$cfg['tagline'] = mb_substr($tag, 0, 120);
} else {
unset($cfg['tagline']);
}
}
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize());
}
#[Route('/api/my-branding/logo', name: 'my_branding_logo_post', methods: ['POST'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function uploadLogo(Request $request): JsonResponse
{
[$scope, $entity] = $this->target();
$file = $request->files->get('file');
if (!$file instanceof UploadedFile) {
throw new BadRequestHttpException('Keine Datei (Feld "file").');
}
$ext = strtolower((string) $file->getClientOriginalExtension());
if (!\in_array($ext, ['png', 'jpg', 'jpeg', 'svg'], true)) {
throw new BadRequestHttpException('Nur PNG, JPG oder SVG erlaubt.');
}
$cfg = $entity->getBrandingConfig();
$old = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
$key = sprintf('branding/%s-%s/logo-%s.%s', $scope, $entity->getId()->toRfc4122(), bin2hex(random_bytes(4)), $ext);
$this->cardAssets->write($key, (string) file_get_contents($file->getPathname()));
if (null !== $old && $old !== $key && $this->cardAssets->fileExists($old)) {
$this->cardAssets->delete($old);
}
$cfg['logoKey'] = $key;
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize(), 201);
}
#[Route('/api/my-branding/logo', name: 'my_branding_logo_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_COMPANY_ADMIN')]
public function deleteLogo(): JsonResponse
{
[, $entity] = $this->target();
$cfg = $entity->getBrandingConfig();
$key = \is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
if (null !== $key && $this->cardAssets->fileExists($key)) {
$this->cardAssets->delete($key);
}
unset($cfg['logoKey']);
$entity->setBrandingConfig($cfg);
$this->em->flush();
return new JsonResponse($this->serialize());
}
/** Öffentliche Logo-Auslieferung (vom Branding-Endpoint verlinkt). */
#[Route('/api/branding/logo/{scope}/{id}.png', name: 'branding_logo', methods: ['GET'])]
public function logo(string $scope, string $id): Response
{
$entity = $this->load($scope, $id);
$key = \is_string($entity->getBrandingConfig()['logoKey'] ?? null) ? $entity->getBrandingConfig()['logoKey'] : null;
if (null === $key || !$this->cardAssets->fileExists($key)) {
throw new NotFoundHttpException('Kein Logo.');
}
$bytes = $this->cardAssets->read($key);
$mime = str_ends_with($key, '.svg') ? 'image/svg+xml' : (str_ends_with($key, '.png') ? 'image/png' : 'image/jpeg');
return new Response($bytes, 200, ['Content-Type' => $mime, 'Cache-Control' => 'public, max-age=86400']);
}
/** @return array{0:string,1:Company|Reseller} */
private function target(): array
{
$company = $this->tenant->getCompany();
if ($company instanceof Company) {
return [BrandingService::SCOPE_COMPANY, $company];
}
$reseller = $this->tenant->getReseller();
if ($reseller instanceof Reseller) {
return [BrandingService::SCOPE_RESELLER, $reseller];
}
throw new AccessDeniedHttpException('Kein Branding-Kontext.');
}
private function load(string $scope, string $id): Company|Reseller
{
$uuid = Uuid::fromString($id);
$entity = BrandingService::SCOPE_COMPANY === $scope
? $this->em->getRepository(Company::class)->find($uuid)
: $this->em->getRepository(Reseller::class)->find($uuid);
if (!$entity instanceof Company && !$entity instanceof Reseller) {
throw new NotFoundHttpException('Nicht gefunden.');
}
return $entity;
}
/** @return array<string, mixed> */
private function serialize(): array
{
[$scope, $entity] = $this->target();
$cfg = $entity->getBrandingConfig();
$tenant = $entity instanceof Company
? new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_COMPANY, $entity->getReseller(), $entity)
: new \App\Service\ResolvedTenant(\App\Service\ResolvedTenant::KIND_RESELLER, $entity);
$host = $entity instanceof Company ? $this->resolver->companyHost($entity) : $this->resolver->resellerHost($entity);
return [
'scope' => $scope,
'name' => $entity->getName(),
'address' => $host,
'primaryColor' => $cfg['primaryColor'] ?? '#f58220',
'primaryColorDark' => $cfg['primaryColorDark'] ?? '#d96500',
'primaryColorSoft' => $cfg['primaryColorSoft'] ?? '#fff2e7',
'tagline' => $cfg['tagline'] ?? '',
'hasLogo' => '' !== (string) ($cfg['logoKey'] ?? ''),
'logoUrl' => $this->branding->logoUrl($tenant),
];
}
private function hex(mixed $value): ?string
{
return (\is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null;
}
}

View File

@ -2,6 +2,10 @@
namespace App\Service; namespace App\Service;
use App\Entity\Company;
use App\Entity\Reseller;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/** /**
* Baut aus einem aufgelösten Tenant das Branding (Name, Farben, Logo) für die SPA. * Baut aus einem aufgelösten Tenant das Branding (Name, Farben, Logo) für die SPA.
* Fallback-Kette: Firma Reseller Plattform-Standard. So erbt eine Firma ohne * Fallback-Kette: Firma Reseller Plattform-Standard. So erbt eine Firma ohne
@ -9,37 +13,35 @@ namespace App\Service;
*/ */
final class BrandingService final class BrandingService
{ {
public const SCOPE_RESELLER = 'reseller';
public const SCOPE_COMPANY = 'company';
/** Plattform-Standard (vcard4reseller-Orange). */ /** Plattform-Standard (vcard4reseller-Orange). */
private const DEFAULTS = [ private const COLOR_DEFAULTS = [
'primaryColor' => '#f58220', 'primaryColor' => '#f58220',
'primaryColorDark' => '#d96500', 'primaryColorDark' => '#d96500',
'primaryColorSoft' => '#fff2e7', 'primaryColorSoft' => '#fff2e7',
'logoUrl' => null,
'tagline' => null, 'tagline' => null,
]; ];
public function __construct(private readonly UrlGeneratorInterface $urls)
{
}
/** /**
* @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array<string,mixed>} * @return array{level:string,name:string,reseller:?string,customDomain:bool,branding:array<string,mixed>}
*/ */
public function forTenant(ResolvedTenant $tenant): array public function forTenant(ResolvedTenant $tenant): array
{ {
// Branding-Ebenen von allgemein nach speziell zusammenführen $branding = self::COLOR_DEFAULTS;
$configs = []; foreach ($this->configChain($tenant) as $config) {
if (null !== $tenant->reseller) { foreach (self::COLOR_DEFAULTS as $key => $_) {
$configs[] = $tenant->reseller->getBrandingConfig();
}
if (null !== $tenant->company) {
$configs[] = $tenant->company->getBrandingConfig();
}
$branding = self::DEFAULTS;
foreach ($configs as $config) {
foreach (self::DEFAULTS as $key => $_) {
if (isset($config[$key]) && '' !== $config[$key]) { if (isset($config[$key]) && '' !== $config[$key]) {
$branding[$key] = $config[$key]; $branding[$key] = $config[$key];
} }
} }
} }
$branding['logoUrl'] = $this->logoUrl($tenant);
return [ return [
'level' => $tenant->kind, 'level' => $tenant->kind,
@ -50,12 +52,45 @@ final class BrandingService
]; ];
} }
/** Öffentliche Logo-URL eines Tenants (am spezifischsten zuerst), oder null. */
public function logoUrl(ResolvedTenant $tenant): ?string
{
if (null !== $tenant->company && '' !== (string) ($tenant->company->getBrandingConfig()['logoKey'] ?? '')) {
return $this->logoRoute(self::SCOPE_COMPANY, (string) $tenant->company->getId(), $tenant->company->getBrandingConfig()['logoKey']);
}
if (null !== $tenant->reseller && '' !== (string) ($tenant->reseller->getBrandingConfig()['logoKey'] ?? '')) {
return $this->logoRoute(self::SCOPE_RESELLER, (string) $tenant->reseller->getId(), $tenant->reseller->getBrandingConfig()['logoKey']);
}
return null;
}
/** @return array<int, array<string,mixed>> */
private function configChain(ResolvedTenant $tenant): array
{
$chain = [];
if (null !== $tenant->reseller) {
$chain[] = $tenant->reseller->getBrandingConfig();
}
if (null !== $tenant->company) {
$chain[] = $tenant->company->getBrandingConfig();
}
return $chain;
}
private function logoRoute(string $scope, string $id, mixed $logoKey): string
{
return $this->urls->generate('branding_logo', ['scope' => $scope, 'id' => $id])
.'?v='.substr(sha1((string) $logoKey), 0, 8);
}
private function name(ResolvedTenant $tenant): string private function name(ResolvedTenant $tenant): string
{ {
if ($tenant->isCompany() && null !== $tenant->company) { if ($tenant->isCompany() && $tenant->company instanceof Company) {
return $tenant->company->getName(); return $tenant->company->getName();
} }
if ($tenant->isReseller() && null !== $tenant->reseller) { if ($tenant->isReseller() && $tenant->reseller instanceof Reseller) {
return $tenant->reseller->getName(); return $tenant->reseller->getName();
} }

View File

@ -20,6 +20,7 @@ const topNav = computed<NavItem[]>(() => {
{ label: 'Einloggen als', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin }, { label: 'Einloggen als', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin },
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin }, { label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin },
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin }, { label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isResellerAdmin },
{ label: 'Branding', to: '/app/branding', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isResellerAdmin },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above },
].filter((i) => i.show) ].filter((i) => i.show)
}) })
@ -35,6 +36,7 @@ const leftNav = computed<NavItem[]>(() => {
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: inCompany }, { label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: inCompany },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: inCompany }, { label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: inCompany },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: inCompany }, { label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: inCompany },
{ label: 'Branding', to: '/app/branding', icon: 'M2 12a10 10 0 1 0 20 0 10 10 0 0 0-20 0zM12 2a15 15 0 0 1 0 20M2 12h20', show: inCompany },
{ label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: inCompany }, { label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: inCompany },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: inCompany }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: inCompany },
].filter((i) => i.show) ].filter((i) => i.show)

View File

@ -25,6 +25,7 @@ const router = createRouter({
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') }, { path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') }, { path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') }, { path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },
{ path: 'branding', name: 'branding', component: () => import('@/views/BrandingView.vue') },
{ path: 'wallet', name: 'wallet', component: () => import('@/views/WalletDesignView.vue') }, { path: 'wallet', name: 'wallet', component: () => import('@/views/WalletDesignView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Einstellungen' } }, { path: 'settings', name: 'settings', component: () => import('@/views/PlaceholderView.vue'), props: { title: 'Einstellungen' } },
], ],

View File

@ -0,0 +1,184 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import client from '@/api/client'
import { useBrandingStore } from '@/stores/branding'
interface BrandingDto {
scope: string
name: string
address: string
primaryColor: string
primaryColorDark: string
primaryColorSoft: string
tagline: string
hasLogo: boolean
logoUrl: string | null
}
const brandingStore = useBrandingStore()
const data = ref<BrandingDto | null>(null)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
async function load() {
loading.value = true
try {
data.value = (await client.get<BrandingDto>('/my-branding')).data
} catch {
error.value = 'Branding konnte nicht geladen werden.'
} finally {
loading.value = false
}
}
async function save() {
if (!data.value) return
saving.value = true
saved.value = false
error.value = ''
try {
const { data: res } = await client.put<BrandingDto>('/my-branding', {
primaryColor: data.value.primaryColor,
primaryColorDark: data.value.primaryColorDark,
primaryColorSoft: data.value.primaryColorSoft,
tagline: data.value.tagline,
})
data.value = res
saved.value = true
await brandingStore.load()
} catch {
error.value = 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function onLogoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
error.value = ''
const form = new FormData()
form.append('file', file)
try {
data.value = (await client.post<BrandingDto>('/my-branding/logo', form)).data
await brandingStore.load()
} catch {
error.value = 'Logo-Upload fehlgeschlagen (PNG, JPG oder SVG).'
} finally {
if (fileInput.value) fileInput.value.value = ''
}
}
async function removeLogo() {
try {
data.value = (await client.delete<BrandingDto>('/my-branding/logo')).data
await brandingStore.load()
} catch {
error.value = 'Logo konnte nicht entfernt werden.'
}
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Branding</h1>
<p class="muted">So erscheint Ihr Auftritt für Kunden auf Ihrer Adresse.</p>
</div>
<button class="btn btn-primary" :disabled="saving || loading" @click="save">
{{ saving ? 'Speichern…' : 'Speichern' }}
</button>
</div>
<p v-if="loading" class="muted">Lädt</p>
<div v-else-if="data" class="grid">
<div class="card">
<h3>Logo</h3>
<div class="logo-row">
<div class="logo-preview">
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" />
<span v-else class="muted sm">Kein Logo</span>
</div>
<div class="logo-actions">
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/svg+xml" hidden @change="onLogoChange" />
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">{{ data.hasLogo ? 'Logo ersetzen' : 'Logo hochladen' }}</button>
<button v-if="data.hasLogo" class="btn btn-ghost btn-sm" @click="removeLogo">Entfernen</button>
<p class="muted sm">PNG, JPG oder SVG. Wird in Login & Kopfzeile angezeigt.</p>
</div>
</div>
<h3 style="margin-top:1.6rem">Farben</h3>
<div class="colors">
<label class="color">
<span>Primärfarbe</span>
<span class="swatch"><input type="color" v-model="data.primaryColor" /><code>{{ data.primaryColor }}</code></span>
</label>
<label class="color">
<span>Primär dunkel (Hover)</span>
<span class="swatch"><input type="color" v-model="data.primaryColorDark" /><code>{{ data.primaryColorDark }}</code></span>
</label>
<label class="color">
<span>Primär hell (Flächen)</span>
<span class="swatch"><input type="color" v-model="data.primaryColorSoft" /><code>{{ data.primaryColorSoft }}</code></span>
</label>
</div>
<h3 style="margin-top:1.6rem">Slogan</h3>
<input class="input" v-model="data.tagline" maxlength="120" placeholder="z. B. Ihre Druckerei vor Ort" />
<p v-if="error" class="error" style="margin-top:1rem">{{ error }}</p>
<p v-if="saved" class="ok" style="margin-top:1rem">Gespeichert.</p>
</div>
<div class="card preview" :style="{ '--p': data.primaryColor, '--pd': data.primaryColorDark, '--ps': data.primaryColorSoft }">
<div class="muted sm" style="margin-bottom:.6rem">Vorschau</div>
<div class="prev-bar">
<img v-if="data.logoUrl" :src="data.logoUrl" alt="Logo" class="prev-logo" />
<strong v-else>{{ data.name }}</strong>
</div>
<p class="prev-tag">{{ data.tagline || '—' }}</p>
<button class="prev-btn">Primär-Button</button>
<span class="prev-chip">Soft-Fläche</span>
<p class="muted sm" style="margin-top:1.2rem">Adresse: <code>{{ data.address }}</code></p>
</div>
</div>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.2rem; align-items: start; }
.card h3 { font-size: .95rem; margin-bottom: .7rem; }
.sm { font-size: .8rem; }
.error { color: var(--danger); font-size: .88rem; }
.ok { color: var(--success); font-size: .88rem; }
.logo-row { display: flex; gap: 1.2rem; align-items: center; }
.logo-preview { width: 96px; height: 96px; border: 1px dashed var(--line); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; background: #fafafa; flex-shrink: 0; }
.logo-preview img { max-width: 84px; max-height: 84px; }
.logo-actions { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.logo-actions .muted { margin: .2rem 0 0; }
.colors { display: flex; flex-direction: column; gap: .7rem; }
.color { display: flex; align-items: center; justify-content: space-between; font-size: .9rem; font-weight: 600; }
.swatch { display: inline-flex; align-items: center; gap: .6rem; }
.swatch input[type=color] { width: 38px; height: 30px; border: 1px solid var(--line); border-radius: 8px; background: none; cursor: pointer; padding: 0; }
.swatch code { color: var(--muted); font-size: .82rem; }
.preview { position: sticky; top: 80px; }
.prev-bar { display: flex; align-items: center; min-height: 34px; }
.prev-logo { max-height: 30px; max-width: 160px; }
.prev-tag { color: var(--muted); font-size: .85rem; margin: .3rem 0 1rem; }
.prev-btn { background: var(--p); color: #fff; border: none; border-radius: var(--radius-sm); padding: .55rem 1rem; font-weight: 700; font-family: var(--font); cursor: default; }
.prev-btn:hover { background: var(--pd); }
.prev-chip { display: inline-block; margin-top: .7rem; background: var(--ps); color: var(--pd); padding: .35rem .8rem; border-radius: 999px; font-size: .82rem; font-weight: 700; }
@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } }
</style>