Compare commits
5 Commits
4d0146d6c2
...
1f45e35ab5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f45e35ab5 | |||
| 18894c7b52 | |||
| 488ddc115f | |||
| 03355f89f3 | |||
| eaa5c506de |
34
backend/migrations/Version20260604165342.php
Normal file
34
backend/migrations/Version20260604165342.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260604165342 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Company.walletConfig (Wallet-Pass-Design)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// MariaDB: NOT-NULL-JSON auf bestehende Zeilen bricht (json_valid-CHECK auf '').
|
||||||
|
// Daher nullable hinzufügen → mit '[]' backfillen → NOT NULL setzen.
|
||||||
|
$this->addSql('ALTER TABLE company ADD wallet_config JSON DEFAULT NULL');
|
||||||
|
$this->addSql("UPDATE company SET wallet_config = '[]' WHERE wallet_config IS NULL");
|
||||||
|
$this->addSql('ALTER TABLE company MODIFY wallet_config JSON NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE company DROP wallet_config');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Company;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\CompanyRepository;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Service\WalletService;
|
use App\Service\WalletService;
|
||||||
use Endroid\QrCode\Builder\Builder;
|
use Endroid\QrCode\Builder\Builder;
|
||||||
@ -24,10 +26,23 @@ final class WalletController extends AbstractController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EmployeeRepository $employees,
|
private readonly EmployeeRepository $employees,
|
||||||
|
private readonly CompanyRepository $companies,
|
||||||
private readonly WalletService $wallet,
|
private readonly WalletService $wallet,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/w/logo/{companyId}.png', name: 'wallet_logo', methods: ['GET'])]
|
||||||
|
public function logo(string $companyId): Response
|
||||||
|
{
|
||||||
|
$company = $this->companies->find(\Symfony\Component\Uid\Uuid::fromString($companyId));
|
||||||
|
$bytes = $company instanceof Company ? $this->wallet->logoBytes($company) : null;
|
||||||
|
if (null === $bytes) {
|
||||||
|
throw $this->createNotFoundException('Kein Wallet-Logo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response($bytes, 200, ['Content-Type' => 'image/png', 'Cache-Control' => 'public, max-age=86400']);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])]
|
#[Route('/w/{code}', name: 'wallet_landing', methods: ['GET'])]
|
||||||
public function landing(string $code, Request $request): Response
|
public function landing(string $code, Request $request): Response
|
||||||
{
|
{
|
||||||
|
|||||||
193
backend/src/Controller/WalletDesignController.php
Normal file
193
backend/src/Controller/WalletDesignController.php
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Company;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Security\TenantContext;
|
||||||
|
use App\Service\WalletService;
|
||||||
|
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\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet-Pass-Design pro Firma: Farben, Titel, Logo, Datenfelder/Slots.
|
||||||
|
* Das Layout selbst ist durch Apple/Google fest vorgegeben (feste Slots).
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||||
|
final class WalletDesignController
|
||||||
|
{
|
||||||
|
private const BINDING_LABELS = [
|
||||||
|
'fullName' => 'Name', 'firstName' => 'Vorname', 'lastName' => 'Nachname',
|
||||||
|
'position' => 'Position', 'department' => 'Abteilung', 'email' => 'E-Mail',
|
||||||
|
'phone' => 'Telefon', 'mobile' => 'Mobil', 'company' => 'Firma', 'profileUrl' => 'Profil-Link',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly EmployeeRepository $employees,
|
||||||
|
private readonly TenantContext $tenant,
|
||||||
|
private readonly WalletService $wallet,
|
||||||
|
private readonly UrlGeneratorInterface $urls,
|
||||||
|
#[Autowire(service: 'card_assets.storage')]
|
||||||
|
private readonly FilesystemOperator $cardAssets,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/companies/{id}/wallet-design', name: 'wallet_design_get', methods: ['GET'])]
|
||||||
|
public function get(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$company = $this->company($id);
|
||||||
|
|
||||||
|
return new JsonResponse($this->serialize($company));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/companies/{id}/wallet-design', name: 'wallet_design_put', methods: ['PUT'])]
|
||||||
|
public function put(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$company = $this->company($id);
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
|
$cfg = $company->getWalletConfig();
|
||||||
|
$cfg['backgroundColor'] = $this->hex($data['backgroundColor'] ?? null);
|
||||||
|
$cfg['foregroundColor'] = $this->hex($data['foregroundColor'] ?? null);
|
||||||
|
$cfg['labelColor'] = $this->hex($data['labelColor'] ?? null);
|
||||||
|
$cfg['title'] = trim((string) ($data['title'] ?? ''));
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
foreach (is_array($data['fields'] ?? null) ? $data['fields'] : [] as $f) {
|
||||||
|
$binding = (string) ($f['binding'] ?? '');
|
||||||
|
$slot = (string) ($f['slot'] ?? '');
|
||||||
|
if (in_array($binding, WalletService::BINDINGS, true) && in_array($slot, WalletService::SLOTS, true)) {
|
||||||
|
$fields[] = ['binding' => $binding, 'label' => substr((string) ($f['label'] ?? ''), 0, 40), 'slot' => $slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cfg['fields'] = $fields;
|
||||||
|
// logoKey bleibt erhalten (separater Upload-Endpunkt)
|
||||||
|
|
||||||
|
$company->setWalletConfig(array_filter($cfg, fn ($v) => null !== $v));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serialize($company));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/companies/{id}/wallet-design/logo', name: 'wallet_design_logo_post', methods: ['POST'])]
|
||||||
|
public function uploadLogo(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$company = $this->company($id);
|
||||||
|
$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'], true)) {
|
||||||
|
throw new BadRequestHttpException('Nur PNG oder JPG erlaubt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = $company->getWalletConfig();
|
||||||
|
$old = is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
||||||
|
$key = sprintf('%s/wallet-logo-%s.%s', $company->getId()->toRfc4122(), bin2hex(random_bytes(4)), 'png' === $ext ? 'png' : 'jpg');
|
||||||
|
$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;
|
||||||
|
$company->setWalletConfig($cfg);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serialize($company), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/companies/{id}/wallet-design/logo', name: 'wallet_design_logo_delete', methods: ['DELETE'])]
|
||||||
|
public function deleteLogo(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$company = $this->company($id);
|
||||||
|
$cfg = $company->getWalletConfig();
|
||||||
|
$key = is_string($cfg['logoKey'] ?? null) ? $cfg['logoKey'] : null;
|
||||||
|
if (null !== $key && $this->cardAssets->fileExists($key)) {
|
||||||
|
$this->cardAssets->delete($key);
|
||||||
|
}
|
||||||
|
unset($cfg['logoKey']);
|
||||||
|
$company->setWalletConfig($cfg);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serialize($company));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serialize(Company $company): array
|
||||||
|
{
|
||||||
|
$d = $this->wallet->design($company);
|
||||||
|
$sample = $this->sample($company);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'backgroundColor' => $d['backgroundColor'],
|
||||||
|
'foregroundColor' => $d['foregroundColor'],
|
||||||
|
'labelColor' => $d['labelColor'],
|
||||||
|
'title' => $d['title'],
|
||||||
|
'fields' => $d['fields'],
|
||||||
|
'hasLogo' => null !== $d['logoKey'],
|
||||||
|
'logoUrl' => null !== $d['logoKey']
|
||||||
|
? $this->urls->generate('wallet_logo', ['companyId' => (string) $company->getId()]).'?v='.substr(sha1($d['logoKey']), 0, 8)
|
||||||
|
: null,
|
||||||
|
'appleEnabled' => $this->wallet->isAppleConfigured(),
|
||||||
|
'googleEnabled' => $this->wallet->isGoogleConfigured(),
|
||||||
|
'bindings' => array_map(fn ($b) => ['value' => $b, 'label' => self::BINDING_LABELS[$b] ?? $b], WalletService::BINDINGS),
|
||||||
|
'slots' => WalletService::SLOTS,
|
||||||
|
'sample' => $sample,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beispieldaten (erster Mitarbeiter der Firma) je Binding – für die Live-Vorschau.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function sample(Company $company): array
|
||||||
|
{
|
||||||
|
$employee = $this->employees->findOneBy(['company' => $company]);
|
||||||
|
$out = [];
|
||||||
|
foreach (WalletService::BINDINGS as $b) {
|
||||||
|
$out[$b] = $employee instanceof Employee ? $this->wallet->bindingValue($employee, $b) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hex(mixed $value): ?string
|
||||||
|
{
|
||||||
|
return (is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function company(string $id): Company
|
||||||
|
{
|
||||||
|
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($id));
|
||||||
|
if (!$company instanceof Company) {
|
||||||
|
throw new NotFoundHttpException('Firma nicht gefunden.');
|
||||||
|
}
|
||||||
|
if ($this->tenant->isPlatformAdmin()) {
|
||||||
|
return $company;
|
||||||
|
}
|
||||||
|
$reseller = $this->tenant->getReseller();
|
||||||
|
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
|
||||||
|
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
|
||||||
|
}
|
||||||
|
$own = $this->tenant->getCompany();
|
||||||
|
if (null !== $own && !$company->getId()->equals($own->getId())) {
|
||||||
|
throw new AccessDeniedHttpException('Nur die eigene Firma.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $company;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,10 @@ class Company implements ResellerOwnedInterface
|
|||||||
#[ORM\Column(type: 'json')]
|
#[ORM\Column(type: 'json')]
|
||||||
private array $brandingConfig = [];
|
private array $brandingConfig = [];
|
||||||
|
|
||||||
|
/** Wallet-Pass-Design (Farben, Titel, Logo-Key, Felder/Slots) – siehe WalletService. */
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $walletConfig = [];
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')]
|
#[ORM\ManyToOne(targetEntity: Reseller::class, inversedBy: 'companies')]
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
private Reseller $reseller;
|
private Reseller $reseller;
|
||||||
@ -161,6 +165,18 @@ class Company implements ResellerOwnedInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getWalletConfig(): array
|
||||||
|
{
|
||||||
|
return $this->walletConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWalletConfig(array $walletConfig): self
|
||||||
|
{
|
||||||
|
$this->walletConfig = $walletConfig;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getReseller(): ?Reseller
|
public function getReseller(): ?Reseller
|
||||||
{
|
{
|
||||||
return $this->reseller;
|
return $this->reseller;
|
||||||
|
|||||||
@ -2,20 +2,41 @@
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Company;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt Wallet-Pässe für die digitale Visitenkarte eines Mitarbeiters:
|
* Erzeugt Wallet-Pässe (Apple .pkpass + Google Save-JWT) für die digitale
|
||||||
* - Google Wallet: signierter „Save to Google Wallet"-JWT-Link (RS256).
|
* Visitenkarte eines Mitarbeiters. Aussehen (Farben, Titel, Logo, Felder/Slots)
|
||||||
* - Apple Wallet: signierte .pkpass (pass.json + Bilder + manifest + PKCS#7).
|
* ist pro Firma über Company.walletConfig konfigurierbar – innerhalb der von
|
||||||
* Beides ist konfigurationsgesteuert (env); ohne Zugangsdaten deaktiviert.
|
* Apple/Google fest vorgegebenen Pass-Struktur.
|
||||||
*/
|
*/
|
||||||
final class WalletService
|
final class WalletService
|
||||||
{
|
{
|
||||||
|
/** Verfügbare Datenfelder (Mitarbeiterprofil). */
|
||||||
|
public const BINDINGS = [
|
||||||
|
'fullName', 'firstName', 'lastName', 'position', 'department',
|
||||||
|
'email', 'phone', 'mobile', 'company', 'profileUrl',
|
||||||
|
];
|
||||||
|
/** Apple-Slots; Google bildet sie auf Header/Textmodule ab. */
|
||||||
|
public const SLOTS = ['primary', 'secondary', 'auxiliary', 'back'];
|
||||||
|
|
||||||
|
/** @var list<array{binding: string, label: string, slot: string}> */
|
||||||
|
public const DEFAULT_FIELDS = [
|
||||||
|
['binding' => 'fullName', 'label' => '', 'slot' => 'primary'],
|
||||||
|
['binding' => 'position', 'label' => 'Position', 'slot' => 'secondary'],
|
||||||
|
['binding' => 'company', 'label' => 'Firma', 'slot' => 'secondary'],
|
||||||
|
['binding' => 'phone', 'label' => 'Telefon', 'slot' => 'auxiliary'],
|
||||||
|
['binding' => 'email', 'label' => 'E-Mail', 'slot' => 'auxiliary'],
|
||||||
|
['binding' => 'profileUrl', 'label' => 'Profil', 'slot' => 'back'],
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UrlGeneratorInterface $urls,
|
private readonly UrlGeneratorInterface $urls,
|
||||||
|
#[Autowire(service: 'card_assets.storage')] private readonly FilesystemOperator $cardAssets,
|
||||||
#[Autowire('%env(APPLE_WALLET_PASS_TYPE_ID)%')] private readonly string $applePassTypeId,
|
#[Autowire('%env(APPLE_WALLET_PASS_TYPE_ID)%')] private readonly string $applePassTypeId,
|
||||||
#[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId,
|
#[Autowire('%env(APPLE_WALLET_TEAM_ID)%')] private readonly string $appleTeamId,
|
||||||
#[Autowire('%env(APPLE_WALLET_ORG_NAME)%')] private readonly string $appleOrgName,
|
#[Autowire('%env(APPLE_WALLET_ORG_NAME)%')] private readonly string $appleOrgName,
|
||||||
@ -40,6 +61,67 @@ final class WalletService
|
|||||||
return '' !== $this->googleIssuerId && '' !== $this->googleServiceAccount && is_file($this->googleServiceAccount);
|
return '' !== $this->googleIssuerId && '' !== $this->googleServiceAccount && is_file($this->googleServiceAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Konfiguration / Design ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgelöstes Wallet-Design einer Firma inkl. Defaults.
|
||||||
|
*
|
||||||
|
* @return array{backgroundColor: string, foregroundColor: string, labelColor: string, title: string, logoKey: ?string, fields: list<array{binding: string, label: string, slot: string}>}
|
||||||
|
*/
|
||||||
|
public function design(Company $company): array
|
||||||
|
{
|
||||||
|
$w = $company->getWalletConfig();
|
||||||
|
$b = $company->getBrandingConfig();
|
||||||
|
$brand = $this->hex($b['primaryColor'] ?? null, '#f58220');
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$raw = (isset($w['fields']) && is_array($w['fields'])) ? $w['fields'] : self::DEFAULT_FIELDS;
|
||||||
|
foreach ($raw as $f) {
|
||||||
|
$binding = (string) ($f['binding'] ?? '');
|
||||||
|
$slot = (string) ($f['slot'] ?? '');
|
||||||
|
if (in_array($binding, self::BINDINGS, true) && in_array($slot, self::SLOTS, true)) {
|
||||||
|
$fields[] = ['binding' => $binding, 'label' => (string) ($f['label'] ?? ''), 'slot' => $slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'backgroundColor' => $this->hex($w['backgroundColor'] ?? null, $brand),
|
||||||
|
'foregroundColor' => $this->hex($w['foregroundColor'] ?? null, '#ffffff'),
|
||||||
|
'labelColor' => $this->hex($w['labelColor'] ?? null, '#ffffff'),
|
||||||
|
'title' => trim((string) ($w['title'] ?? '')) ?: $company->getName(),
|
||||||
|
'logoKey' => (isset($w['logoKey']) && is_string($w['logoKey']) && '' !== $w['logoKey']) ? $w['logoKey'] : null,
|
||||||
|
'fields' => $fields ?: self::DEFAULT_FIELDS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bindingValue(Employee $e, string $binding): string
|
||||||
|
{
|
||||||
|
return match ($binding) {
|
||||||
|
'fullName' => trim($e->getFirstName().' '.$e->getLastName()),
|
||||||
|
'firstName' => $e->getFirstName(),
|
||||||
|
'lastName' => $e->getLastName(),
|
||||||
|
'position' => (string) ($e->getPosition() ?? ''),
|
||||||
|
'department' => (string) ($e->getDepartment() ?? ''),
|
||||||
|
'email' => (string) ($e->getEmail() ?? ''),
|
||||||
|
'phone' => (string) ($e->getPhone() ?? ''),
|
||||||
|
'mobile' => (string) ($e->getMobile() ?? ''),
|
||||||
|
'company' => $e->getCompany()->getName(),
|
||||||
|
'profileUrl' => $this->shareUrl($e),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rohes Logo-PNG/JPG aus dem Object-Storage (für die öffentliche Logo-URL). */
|
||||||
|
public function logoBytes(Company $company): ?string
|
||||||
|
{
|
||||||
|
$key = $this->design($company)['logoKey'];
|
||||||
|
if (null === $key || !$this->cardAssets->fileExists($key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cardAssets->read($key);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Google Wallet ---
|
// --- Google Wallet ---
|
||||||
|
|
||||||
public function googleSaveUrl(Employee $e): string
|
public function googleSaveUrl(Employee $e): string
|
||||||
@ -48,27 +130,41 @@ final class WalletService
|
|||||||
if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) {
|
if (!is_array($sa) || !isset($sa['client_email'], $sa['private_key'])) {
|
||||||
throw new \RuntimeException('Google-Service-Account ungültig.');
|
throw new \RuntimeException('Google-Service-Account ungültig.');
|
||||||
}
|
}
|
||||||
$c = $this->card($e);
|
$company = $e->getCompany();
|
||||||
|
$d = $this->design($company);
|
||||||
|
$url = $this->shareUrl($e);
|
||||||
$classId = $this->googleIssuerId.'.'.$this->googleClassSuffix;
|
$classId = $this->googleIssuerId.'.'.$this->googleClassSuffix;
|
||||||
$objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58());
|
$objectId = $this->googleIssuerId.'.vcard_'.($e->getShortCode() ?? $e->getId()->toBase58());
|
||||||
|
|
||||||
|
// Header = erstes Primärfeld, sonst Titel
|
||||||
|
$header = $d['title'];
|
||||||
|
$modules = [];
|
||||||
|
foreach ($d['fields'] as $i => $f) {
|
||||||
|
$val = $this->bindingValue($e, $f['binding']);
|
||||||
|
if ('' === $val) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('primary' === $f['slot'] && $d['title'] === $header) {
|
||||||
|
$header = $val;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$modules[] = ['id' => 'f'.$i, 'header' => $f['label'], 'body' => $val];
|
||||||
|
}
|
||||||
|
|
||||||
$object = [
|
$object = [
|
||||||
'id' => $objectId,
|
'id' => $objectId,
|
||||||
'classId' => $classId,
|
'classId' => $classId,
|
||||||
'state' => 'ACTIVE',
|
'state' => 'ACTIVE',
|
||||||
'hexBackgroundColor' => $c['primaryColor'],
|
'hexBackgroundColor' => $d['backgroundColor'],
|
||||||
'cardTitle' => $this->locValue($c['company']),
|
'cardTitle' => $this->locValue($d['title']),
|
||||||
'header' => $this->locValue($c['name']),
|
'header' => $this->locValue($header),
|
||||||
'barcode' => ['type' => 'QR_CODE', 'value' => $c['url']],
|
'barcode' => ['type' => 'QR_CODE', 'value' => $url],
|
||||||
'textModulesData' => array_values(array_filter([
|
'textModulesData' => $modules,
|
||||||
'' !== $c['role'] ? ['id' => 'role', 'header' => 'Position', 'body' => $c['role']] : null,
|
'linksModuleData' => ['uris' => [['uri' => $url, 'description' => 'Profil öffnen', 'id' => 'profile']]],
|
||||||
'' !== $c['phone'] ? ['id' => 'phone', 'header' => 'Telefon', 'body' => $c['phone']] : null,
|
|
||||||
'' !== $c['email'] ? ['id' => 'email', 'header' => 'E-Mail', 'body' => $c['email']] : null,
|
|
||||||
])),
|
|
||||||
'linksModuleData' => ['uris' => [['uri' => $c['url'], 'description' => 'Profil öffnen', 'id' => 'profile']]],
|
|
||||||
];
|
];
|
||||||
if (null !== $c['logoUrl']) {
|
if (null !== $d['logoKey']) {
|
||||||
$object['logo'] = ['sourceUri' => ['uri' => $c['logoUrl']], 'contentDescription' => $this->locValue($c['company'])];
|
$object['logo'] = ['sourceUri' => ['uri' => $this->logoUrl($company)], 'contentDescription' => $this->locValue($d['title'])];
|
||||||
}
|
}
|
||||||
|
|
||||||
$claims = [
|
$claims = [
|
||||||
@ -76,64 +172,51 @@ final class WalletService
|
|||||||
'aud' => 'google',
|
'aud' => 'google',
|
||||||
'typ' => 'savetowallet',
|
'typ' => 'savetowallet',
|
||||||
'iat' => time(),
|
'iat' => time(),
|
||||||
'payload' => [
|
'payload' => ['genericClasses' => [['id' => $classId]], 'genericObjects' => [$object]],
|
||||||
'genericClasses' => [['id' => $classId]],
|
|
||||||
'genericObjects' => [$object],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']);
|
return 'https://pay.google.com/gp/v/save/'.$this->signRs256($claims, (string) $sa['private_key']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $claims */
|
|
||||||
private function signRs256(array $claims, string $privateKey): string
|
|
||||||
{
|
|
||||||
$segments = [
|
|
||||||
$this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
|
|
||||||
$this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)),
|
|
||||||
];
|
|
||||||
$signature = '';
|
|
||||||
if (!openssl_sign(implode('.', $segments), $signature, $privateKey, \OPENSSL_ALGO_SHA256)) {
|
|
||||||
throw new \RuntimeException('JWT-Signatur fehlgeschlagen.');
|
|
||||||
}
|
|
||||||
$segments[] = $this->b64url($signature);
|
|
||||||
|
|
||||||
return implode('.', $segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Apple Wallet (.pkpass) ---
|
// --- Apple Wallet (.pkpass) ---
|
||||||
|
|
||||||
public function applePkpass(Employee $e): string
|
public function applePkpass(Employee $e): string
|
||||||
{
|
{
|
||||||
$c = $this->card($e);
|
$company = $e->getCompany();
|
||||||
|
$d = $this->design($company);
|
||||||
|
$url = $this->shareUrl($e);
|
||||||
|
|
||||||
|
$slots = ['primary' => [], 'secondary' => [], 'auxiliary' => [], 'back' => []];
|
||||||
|
foreach ($d['fields'] as $i => $f) {
|
||||||
|
$val = $this->bindingValue($e, $f['binding']);
|
||||||
|
if ('' === $val) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slots[$f['slot']][] = ['key' => 'f'.$i, 'label' => $f['label'], 'value' => $val];
|
||||||
|
}
|
||||||
|
|
||||||
$pass = [
|
$pass = [
|
||||||
'formatVersion' => 1,
|
'formatVersion' => 1,
|
||||||
'passTypeIdentifier' => $this->applePassTypeId,
|
'passTypeIdentifier' => $this->applePassTypeId,
|
||||||
'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(),
|
'serialNumber' => $e->getShortCode() ?? $e->getId()->toBase58(),
|
||||||
'teamIdentifier' => $this->appleTeamId,
|
'teamIdentifier' => $this->appleTeamId,
|
||||||
'organizationName' => $this->appleOrgName,
|
'organizationName' => $this->appleOrgName,
|
||||||
'description' => 'Visitenkarte '.$c['name'],
|
'description' => 'Visitenkarte '.$this->bindingValue($e, 'fullName'),
|
||||||
'logoText' => $c['company'],
|
'logoText' => $d['title'],
|
||||||
'foregroundColor' => 'rgb(255, 255, 255)',
|
'foregroundColor' => $this->rgb($d['foregroundColor']),
|
||||||
'labelColor' => 'rgb(255, 255, 255)',
|
'labelColor' => $this->rgb($d['labelColor']),
|
||||||
'backgroundColor' => $this->rgb($c['primaryColor']),
|
'backgroundColor' => $this->rgb($d['backgroundColor']),
|
||||||
'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $c['url'], 'messageEncoding' => 'iso-8859-1']],
|
'barcodes' => [['format' => 'PKBARCODE_FORMAT_QR', 'message' => $url, 'messageEncoding' => 'iso-8859-1']],
|
||||||
'generic' => [
|
'generic' => [
|
||||||
'primaryFields' => [['key' => 'name', 'label' => '', 'value' => $c['name']]],
|
'primaryFields' => $slots['primary'],
|
||||||
'secondaryFields' => array_values(array_filter([
|
'secondaryFields' => $slots['secondary'],
|
||||||
'' !== $c['role'] ? ['key' => 'role', 'label' => 'POSITION', 'value' => $c['role']] : null,
|
'auxiliaryFields' => $slots['auxiliary'],
|
||||||
['key' => 'company', 'label' => 'FIRMA', 'value' => $c['company']],
|
'backFields' => $slots['back'],
|
||||||
])),
|
|
||||||
'auxiliaryFields' => array_values(array_filter([
|
|
||||||
'' !== $c['phone'] ? ['key' => 'phone', 'label' => 'TELEFON', 'value' => $c['phone']] : null,
|
|
||||||
'' !== $c['email'] ? ['key' => 'email', 'label' => 'E-MAIL', 'value' => $c['email']] : null,
|
|
||||||
])),
|
|
||||||
'backFields' => [['key' => 'link', 'label' => 'Profil', 'value' => $c['url']]],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)];
|
$files = ['pass.json' => (string) json_encode($pass, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)];
|
||||||
$files += $this->passImages($c);
|
$files += $this->passImages($d);
|
||||||
|
|
||||||
$manifest = [];
|
$manifest = [];
|
||||||
foreach ($files as $name => $content) {
|
foreach ($files as $name => $content) {
|
||||||
@ -171,7 +254,6 @@ final class WalletService
|
|||||||
return $this->smimeToDer($smime);
|
return $this->smimeToDer($smime);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extrahiert den DER-kodierten p7s-Block aus der S/MIME-Ausgabe von openssl. */
|
|
||||||
private function smimeToDer(string $smime): string
|
private function smimeToDer(string $smime): string
|
||||||
{
|
{
|
||||||
if (preg_match('/smime\.p7s.*?\r?\n\r?\n(.*?)\r?\n-{2,}/s', $smime, $m)) {
|
if (preg_match('/smime\.p7s.*?\r?\n\r?\n(.*?)\r?\n-{2,}/s', $smime, $m)) {
|
||||||
@ -183,9 +265,7 @@ final class WalletService
|
|||||||
return (string) base64_decode((string) preg_replace('/\s+/', '', $b64));
|
return (string) base64_decode((string) preg_replace('/\s+/', '', $b64));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @param array<string, string> $files */
|
||||||
* @param array<string, string> $files
|
|
||||||
*/
|
|
||||||
private function zip(array $files): string
|
private function zip(array $files): string
|
||||||
{
|
{
|
||||||
$tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass');
|
$tmp = (string) tempnam(sys_get_temp_dir(), 'pkpass');
|
||||||
@ -202,22 +282,37 @@ final class WalletService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{name: string, primaryColor: string} $c
|
* Pass-Bilder: echtes Firmenlogo (aus Object-Storage) skaliert, sonst farbiges
|
||||||
|
* Quadrat mit Initiale.
|
||||||
|
*
|
||||||
|
* @param array{backgroundColor: string, logoKey: ?string, title: string} $d
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function passImages(array $c): array
|
private function passImages(array $d): array
|
||||||
{
|
{
|
||||||
[$r, $g, $b] = $this->rgbParts($c['primaryColor']);
|
$logoSrc = null;
|
||||||
$initial = strtoupper(mb_substr($c['name'], 0, 1)) ?: 'V';
|
if (null !== $d['logoKey'] && $this->cardAssets->fileExists($d['logoKey'])) {
|
||||||
$make = function (int $size) use ($r, $g, $b, $initial): string {
|
$logoSrc = @imagecreatefromstring($this->cardAssets->read($d['logoKey'])) ?: null;
|
||||||
|
}
|
||||||
|
[$r, $g, $b] = $this->rgbParts($d['backgroundColor']);
|
||||||
|
$initial = strtoupper(mb_substr($d['title'], 0, 1)) ?: 'V';
|
||||||
|
|
||||||
|
$make = function (int $size) use ($logoSrc, $r, $g, $b, $initial): string {
|
||||||
$img = imagecreatetruecolor($size, $size);
|
$img = imagecreatetruecolor($size, $size);
|
||||||
imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b));
|
imagefill($img, 0, 0, imagecolorallocate($img, $r, $g, $b));
|
||||||
$white = imagecolorallocate($img, 255, 255, 255);
|
if (null !== $logoSrc) {
|
||||||
$f = 5;
|
$sw = imagesx($logoSrc);
|
||||||
$tw = imagefontwidth($f) * strlen($initial);
|
$sh = imagesy($logoSrc);
|
||||||
$th = imagefontheight($f);
|
$scale = min($size / $sw, $size / $sh);
|
||||||
imagestring($img, $f, (int) (($size - $tw) / 2), (int) (($size - $th) / 2), $initial, $white);
|
$dw = (int) ($sw * $scale);
|
||||||
|
$dh = (int) ($sh * $scale);
|
||||||
|
imagecopyresampled($img, $logoSrc, (int) (($size - $dw) / 2), (int) (($size - $dh) / 2), 0, 0, $dw, $dh, $sw, $sh);
|
||||||
|
} else {
|
||||||
|
$white = imagecolorallocate($img, 255, 255, 255);
|
||||||
|
$f = 5;
|
||||||
|
imagestring($img, $f, (int) (($size - imagefontwidth($f) * strlen($initial)) / 2), (int) (($size - imagefontheight($f)) / 2), $initial, $white);
|
||||||
|
}
|
||||||
ob_start();
|
ob_start();
|
||||||
imagepng($img);
|
imagepng($img);
|
||||||
$png = (string) ob_get_clean();
|
$png = (string) ob_get_clean();
|
||||||
@ -226,37 +321,40 @@ final class WalletService
|
|||||||
return $png;
|
return $png;
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
$out = [
|
||||||
'icon.png' => $make(29),
|
'icon.png' => $make(29),
|
||||||
'icon@2x.png' => $make(58),
|
'icon@2x.png' => $make(58),
|
||||||
'logo.png' => $make(50),
|
'logo.png' => $make(50),
|
||||||
'logo@2x.png' => $make(100),
|
'logo@2x.png' => $make(100),
|
||||||
];
|
];
|
||||||
|
if (null !== $logoSrc) {
|
||||||
|
imagedestroy($logoSrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- gemeinsame Kartendaten ---
|
// --- Helper ---
|
||||||
|
|
||||||
/**
|
/** @param array<string, mixed> $claims */
|
||||||
* @return array{name: string, role: string, company: string, phone: string, email: string, url: string, primaryColor: string, logoUrl: ?string}
|
private function signRs256(array $claims, string $privateKey): string
|
||||||
*/
|
|
||||||
private function card(Employee $e): array
|
|
||||||
{
|
{
|
||||||
$b = $e->getCompany()->getBrandingConfig();
|
$segments = [
|
||||||
$primary = (isset($b['primaryColor']) && is_string($b['primaryColor']) && preg_match('/^#[0-9a-fA-F]{6}$/', $b['primaryColor']))
|
$this->b64url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
|
||||||
? $b['primaryColor'] : '#f58220';
|
$this->b64url((string) json_encode($claims, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)),
|
||||||
$logo = (isset($b['logoUrl']) && is_string($b['logoUrl']) && str_starts_with($b['logoUrl'], 'https://'))
|
|
||||||
? $b['logoUrl'] : null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => trim($e->getFirstName().' '.$e->getLastName()),
|
|
||||||
'role' => trim((string) ($e->getPosition() ?? '')),
|
|
||||||
'company' => $e->getCompany()->getName(),
|
|
||||||
'phone' => (string) ($e->getPhone() ?? $e->getMobile() ?? ''),
|
|
||||||
'email' => (string) ($e->getEmail() ?? ''),
|
|
||||||
'url' => $this->shareUrl($e),
|
|
||||||
'primaryColor' => $primary,
|
|
||||||
'logoUrl' => $logo,
|
|
||||||
];
|
];
|
||||||
|
$signature = '';
|
||||||
|
if (!openssl_sign(implode('.', $segments), $signature, $privateKey, \OPENSSL_ALGO_SHA256)) {
|
||||||
|
throw new \RuntimeException('JWT-Signatur fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
$segments[] = $this->b64url($signature);
|
||||||
|
|
||||||
|
return implode('.', $segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logoUrl(Company $company): string
|
||||||
|
{
|
||||||
|
return $this->urls->generate('wallet_logo', ['companyId' => (string) $company->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shareUrl(Employee $e): string
|
private function shareUrl(Employee $e): string
|
||||||
@ -271,6 +369,11 @@ final class WalletService
|
|||||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hex(mixed $value, string $fallback): string
|
||||||
|
{
|
||||||
|
return (is_string($value) && preg_match('/^#[0-9a-fA-F]{6}$/', $value)) ? strtolower($value) : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array{0: int, 1: int, 2: int} */
|
/** @return array{0: int, 1: int, 2: int} */
|
||||||
private function rgbParts(string $hex): array
|
private function rgbParts(string $hex): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -54,9 +54,21 @@ write_files:
|
|||||||
# SPA bauen (Profil-/QR-Links zeigen auf die öffentliche Domain)
|
# SPA bauen (Profil-/QR-Links zeigen auf die öffentliche Domain)
|
||||||
docker run --rm -e VITE_PUBLIC_BASE="https://$DOMAIN" -v "$PWD/frontend":/app -w /app node:25-alpine sh -c "npm ci && npm run build"
|
docker run --rm -e VITE_PUBLIC_BASE="https://$DOMAIN" -v "$PWD/frontend":/app -w /app node:25-alpine sh -c "npm ci && npm run build"
|
||||||
chown -R 1000:1000 /opt/vcard4
|
chown -R 1000:1000 /opt/vcard4
|
||||||
|
# Hetzner-Privatnetz-NIC (nicht eth0) sicher per DHCP hochziehen (für DB-Zugriff).
|
||||||
|
# Manchmal kommt das private Interface beim ersten Boot nicht hoch → DB unerreichbar.
|
||||||
|
PRIV=$(ls /sys/class/net | grep -E '^(enp|ens)' | grep -v '^eth0$' | head -1 || true)
|
||||||
|
if [ -n "${PRIV:-}" ] && ! ip -4 addr show "$PRIV" | grep -q 'inet 10\.'; then
|
||||||
|
printf '[Match]\nName=%s\n[Network]\nDHCP=ipv4\n' "$PRIV" > "/etc/systemd/network/10-$PRIV.network"
|
||||||
|
ip link set "$PRIV" up || true
|
||||||
|
systemctl restart systemd-networkd || true
|
||||||
|
for i in $(seq 1 30); do ip -4 addr | grep -q 'inet 10\.' && break; sleep 2; done
|
||||||
|
fi
|
||||||
COMPOSE="docker compose --project-directory /opt/vcard4 -f deploy/compose/docker-compose.prod.yml"
|
COMPOSE="docker compose --project-directory /opt/vcard4 -f deploy/compose/docker-compose.prod.yml"
|
||||||
$COMPOSE up -d --build
|
$COMPOSE up -d --build
|
||||||
sleep 20
|
sleep 20
|
||||||
|
# PHP-Abhängigkeiten installieren: vendor/ ist gitignored und /app ist als Volume
|
||||||
|
# gemountet (überdeckt ein im Image gebautes vendor) → hier in den Container hinein.
|
||||||
|
$COMPOSE exec -T -e COMPOSER_HOME=/tmp/composer php composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||||
if [ "$RUN_MIGRATIONS" = "true" ]; then
|
if [ "$RUN_MIGRATIONS" = "true" ]; then
|
||||||
$COMPOSE exec -T php php bin/console doctrine:migrations:migrate --no-interaction || true
|
$COMPOSE exec -T php php bin/console doctrine:migrations:migrate --no-interaction || true
|
||||||
# Erst-Befüllung (idempotent: überspringt, wenn admin@vcard4reseller.de existiert)
|
# Erst-Befüllung (idempotent: überspringt, wenn admin@vcard4reseller.de existiert)
|
||||||
|
|||||||
@ -8,8 +8,8 @@ location = "nbg1"
|
|||||||
network_zone = "eu-central"
|
network_zone = "eu-central"
|
||||||
|
|
||||||
app_count = 2
|
app_count = 2
|
||||||
app_server_type = "cx22"
|
app_server_type = "cx23"
|
||||||
db_server_type = "cx22"
|
db_server_type = "cx23"
|
||||||
|
|
||||||
# Anwendung
|
# Anwendung
|
||||||
repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL
|
repo_url = "https://github.com/DEIN-USER/vcard4reseller.git" # privat: Token in URL
|
||||||
|
|||||||
@ -35,13 +35,13 @@ variable "app_count" {
|
|||||||
variable "app_server_type" {
|
variable "app_server_type" {
|
||||||
description = "Servertyp App-Nodes"
|
description = "Servertyp App-Nodes"
|
||||||
type = string
|
type = string
|
||||||
default = "cx22"
|
default = "cx23"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "db_server_type" {
|
variable "db_server_type" {
|
||||||
description = "Servertyp DB-Node"
|
description = "Servertyp DB-Node"
|
||||||
type = string
|
type = string
|
||||||
default = "cx22"
|
default = "cx23"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Anwendung / Deploy ---
|
# --- Anwendung / Deploy ---
|
||||||
|
|||||||
@ -19,12 +19,23 @@ const nav = 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: auth.isCompanyAdmin },
|
{ 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: auth.isCompanyAdmin },
|
||||||
{ 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: auth.isCompanyAdmin },
|
{ 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: auth.isCompanyAdmin },
|
||||||
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isCompanyAdmin },
|
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isCompanyAdmin },
|
||||||
|
{ label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: auth.isCompanyAdmin },
|
||||||
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
|
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
|
||||||
].filter((i) => i.show))
|
].filter((i) => i.show))
|
||||||
|
|
||||||
const contextLabel = computed(() =>
|
const contextLabel = computed(() =>
|
||||||
auth.user?.company?.name ?? auth.user?.reseller?.name ?? 'Plattform',
|
auth.user?.company?.name ?? auth.user?.reseller?.name ?? 'Plattform',
|
||||||
)
|
)
|
||||||
|
const userName = computed(() => auth.user?.name || auth.user?.email || '')
|
||||||
|
const initials = computed(() =>
|
||||||
|
userName.value.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((s) => s[0]).join('').toUpperCase() || 'U',
|
||||||
|
)
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
if (auth.isPlatformAdmin) return 'Plattform-Admin'
|
||||||
|
if (auth.isResellerAdmin) return 'Reseller-Admin'
|
||||||
|
if (auth.isCompanyAdmin) return 'Firmen-Admin'
|
||||||
|
return 'Mitarbeiter'
|
||||||
|
})
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
@ -41,14 +52,18 @@ async function stopImpersonation() {
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar__brand">
|
<div class="sidebar__brand">
|
||||||
<span class="brand-logo" style="color:#fff">vcard4<span class="tag">reseller</span></span>
|
<span class="brand-logo">vcard4<span class="tag">reseller</span></span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar__nav">
|
<nav class="sidebar__nav">
|
||||||
<RouterLink v-for="item in nav" :key="item.to" :to="item.to" class="navlink"
|
<RouterLink v-for="item in nav" :key="item.to" :to="item.to" class="navlink"
|
||||||
:class="{ active: $route.path === item.to }">
|
:class="{ active: $route.path === item.to }">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"
|
<span class="navlink__icon">
|
||||||
stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
<span>{{ item.label }}</span>
|
stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="navlink__label">{{ item.label }}</span>
|
||||||
|
<svg class="navlink__chev" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@ -64,7 +79,13 @@ async function stopImpersonation() {
|
|||||||
<strong>{{ contextLabel }}</strong>
|
<strong>{{ contextLabel }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__user">
|
<div class="topbar__user">
|
||||||
<span class="muted">{{ auth.user?.email }}</span>
|
<div class="user">
|
||||||
|
<div class="user__avatar">{{ initials }}</div>
|
||||||
|
<div class="user__meta">
|
||||||
|
<strong>{{ userName }}</strong>
|
||||||
|
<span class="muted">{{ roleLabel }}<template v-if="auth.user?.company"> · {{ auth.user.company.name }}</template></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" @click="logout">Abmelden</button>
|
<button class="btn btn-ghost btn-sm" @click="logout">Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -76,30 +97,46 @@ async function stopImpersonation() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell { display: flex; min-height: 100vh; }
|
.shell { display: flex; min-height: 100vh; background: var(--bg); }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 240px; flex-shrink: 0; background: var(--sidebar); color: #cfcfd2;
|
width: 248px; flex-shrink: 0; background: #fff; color: var(--text);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh;
|
display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh;
|
||||||
}
|
}
|
||||||
.sidebar__brand { padding: 1.4rem 1.3rem; font-size: 1.15rem; }
|
.sidebar__brand { padding: 1.5rem 1.4rem 1.2rem; font-size: 1.2rem; }
|
||||||
.sidebar__nav { display: flex; flex-direction: column; gap: 2px; padding: .5rem .7rem; }
|
.sidebar__nav { display: flex; flex-direction: column; gap: 3px; padding: .4rem .7rem; overflow-y: auto; }
|
||||||
.navlink {
|
.navlink {
|
||||||
display: flex; align-items: center; gap: .8rem; padding: .7rem .8rem;
|
display: flex; align-items: center; gap: .75rem; padding: .62rem .7rem;
|
||||||
border-radius: var(--radius-sm); color: #c2c2c6; font-size: .92rem; font-weight: 600;
|
border-radius: var(--radius-sm); color: #5b5b5b; font-size: .92rem; font-weight: 600;
|
||||||
}
|
}
|
||||||
.navlink:hover { background: var(--sidebar-hover); color: #fff; text-decoration: none; }
|
.navlink__icon { display: flex; color: #9a9a9a; }
|
||||||
.navlink.active { background: var(--psc-orange); color: #fff; }
|
.navlink__label { flex: 1; }
|
||||||
|
.navlink__chev { color: #cfcfcf; }
|
||||||
|
.navlink:hover { background: #f6f6f6; color: var(--text); text-decoration: none; }
|
||||||
|
.navlink:hover .navlink__icon { color: #6f6f6f; }
|
||||||
|
.navlink.active { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
|
||||||
|
.navlink.active .navlink__icon,
|
||||||
|
.navlink.active .navlink__chev { color: var(--psc-orange); }
|
||||||
|
|
||||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.6rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
|
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.6rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
|
||||||
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
|
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
|
padding: .85rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.topbar__ctx { display: flex; flex-direction: column; line-height: 1.2; }
|
.topbar__ctx { display: flex; flex-direction: column; line-height: 1.2; }
|
||||||
.topbar__ctx .muted { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; }
|
.topbar__ctx .muted { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.topbar__user { display: flex; align-items: center; gap: 1rem; }
|
.topbar__user { display: flex; align-items: center; gap: 1.1rem; }
|
||||||
|
.user { display: flex; align-items: center; gap: .65rem; }
|
||||||
|
.user__avatar {
|
||||||
|
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: var(--psc-orange-soft); color: var(--psc-orange-dark);
|
||||||
|
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem;
|
||||||
|
}
|
||||||
|
.user__meta { display: flex; flex-direction: column; line-height: 1.25; }
|
||||||
|
.user__meta strong { font-size: .92rem; }
|
||||||
|
.user__meta .muted { font-size: .76rem; }
|
||||||
.content { padding: 1.8rem; }
|
.content { padding: 1.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -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: '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' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface TenantRef {
|
|||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
name?: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
reseller: TenantRef | null
|
reseller: TenantRef | null
|
||||||
company: TenantRef | null
|
company: TenantRef | null
|
||||||
|
|||||||
249
frontend/src/views/WalletDesignView.vue
Normal file
249
frontend/src/views/WalletDesignView.vue
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { list } from '@/api/resources'
|
||||||
|
import client from '@/api/client'
|
||||||
|
|
||||||
|
interface Company { '@id': string; id: string; name: string; slug: string }
|
||||||
|
interface Field { binding: string; label: string; slot: string }
|
||||||
|
interface Design {
|
||||||
|
backgroundColor: string; foregroundColor: string; labelColor: string; title: string
|
||||||
|
fields: Field[]; hasLogo: boolean; logoUrl: string | null
|
||||||
|
appleEnabled: boolean; googleEnabled: boolean
|
||||||
|
bindings: { value: string; label: string }[]; slots: string[]
|
||||||
|
sample: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_LABEL: Record<string, string> = {
|
||||||
|
primary: 'Hauptfeld', secondary: 'Sekundär', auxiliary: 'Zusatz', back: 'Rückseite',
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = ref<Company[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const design = ref<Design | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const selected = computed(() => companies.value.find((c) => c['@id'] === selectedId.value))
|
||||||
|
const apiBase = (client.defaults.baseURL || '/api').replace(/\/api$/, '')
|
||||||
|
|
||||||
|
function fieldsBySlot(slot: string) {
|
||||||
|
return design.value ? design.value.fields.filter((f) => f.slot === slot) : []
|
||||||
|
}
|
||||||
|
function sampleVal(binding: string) {
|
||||||
|
return design.value?.sample[binding] || `{${binding}}`
|
||||||
|
}
|
||||||
|
function bindingLabel(binding: string) {
|
||||||
|
return design.value?.bindings.find((b) => b.value === binding)?.label ?? binding
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCompanies() {
|
||||||
|
companies.value = (await list<Company>('companies')).member
|
||||||
|
if (companies.value[0]) selectedId.value = companies.value[0]['@id']
|
||||||
|
}
|
||||||
|
async function loadDesign() {
|
||||||
|
if (!selected.value) return
|
||||||
|
saved.value = false; error.value = ''
|
||||||
|
const { data } = await client.get<Design>(`/companies/${selected.value.id}/wallet-design`)
|
||||||
|
design.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedId, loadDesign)
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
design.value?.fields.push({ binding: 'phone', label: '', slot: 'auxiliary' })
|
||||||
|
}
|
||||||
|
function removeField(i: number) {
|
||||||
|
design.value?.fields.splice(i, 1)
|
||||||
|
}
|
||||||
|
function move(i: number, dir: -1 | 1) {
|
||||||
|
const f = design.value?.fields
|
||||||
|
if (!f) return
|
||||||
|
const j = i + dir
|
||||||
|
if (j < 0 || j >= f.length) return
|
||||||
|
;[f[i], f[j]] = [f[j], f[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!selected.value || !design.value) return
|
||||||
|
saving.value = true; error.value = ''; saved.value = false
|
||||||
|
try {
|
||||||
|
const d = design.value
|
||||||
|
const { data } = await client.put<Design>(`/companies/${selected.value.id}/wallet-design`, {
|
||||||
|
backgroundColor: d.backgroundColor, foregroundColor: d.foregroundColor, labelColor: d.labelColor,
|
||||||
|
title: d.title, fields: d.fields,
|
||||||
|
})
|
||||||
|
design.value = data
|
||||||
|
saved.value = true
|
||||||
|
} catch {
|
||||||
|
error.value = 'Speichern fehlgeschlagen.'
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLogo(e: Event) {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file || !selected.value) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
const { data } = await client.post<Design>(`/companies/${selected.value.id}/wallet-design/logo`, fd)
|
||||||
|
design.value = data
|
||||||
|
} catch {
|
||||||
|
alert('Upload fehlgeschlagen (nur PNG/JPG).')
|
||||||
|
} finally { uploading.value = false; (e.target as HTMLInputElement).value = '' }
|
||||||
|
}
|
||||||
|
async function removeLogo() {
|
||||||
|
if (!selected.value || !design.value) return
|
||||||
|
const { data } = await client.delete<Design>(`/companies/${selected.value.id}/wallet-design/logo`)
|
||||||
|
design.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await loadCompanies(); await loadDesign(); loading.value = false })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Wallet-Design</h1>
|
||||||
|
<p class="muted">Aussehen der Apple-/Google-Wallet-Karte pro Firma</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="muted">Lädt…</div>
|
||||||
|
<div v-else-if="!design" class="card pad muted">Keine Firma vorhanden.</div>
|
||||||
|
|
||||||
|
<div v-else class="layout">
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="card pad editor">
|
||||||
|
<div class="field" v-if="companies.length > 1">
|
||||||
|
<label>Firma</label>
|
||||||
|
<select class="input" v-model="selectedId">
|
||||||
|
<option v-for="c in companies" :key="c['@id']" :value="c['@id']">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!design.appleEnabled && !design.googleEnabled" class="hint warn">
|
||||||
|
Wallet ist serverseitig noch nicht konfiguriert – das Design lässt sich trotzdem vorbereiten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="field"><label>Titel (Logotext)</label><input class="input" v-model="design.title" :placeholder="selected?.name" /></div>
|
||||||
|
|
||||||
|
<div class="grid3">
|
||||||
|
<div class="field"><label>Hintergrund</label><div class="cr"><input type="color" v-model="design.backgroundColor" /><input class="input" v-model="design.backgroundColor" /></div></div>
|
||||||
|
<div class="field"><label>Textfarbe</label><div class="cr"><input type="color" v-model="design.foregroundColor" /><input class="input" v-model="design.foregroundColor" /></div></div>
|
||||||
|
<div class="field"><label>Label-Farbe</label><div class="cr"><input type="color" v-model="design.labelColor" /><input class="input" v-model="design.labelColor" /></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Logo</label>
|
||||||
|
<div class="logo-row">
|
||||||
|
<img v-if="design.logoUrl" :src="apiBase + design.logoUrl" class="logo-thumb" :style="{ background: design.backgroundColor }" />
|
||||||
|
<label class="btn btn-soft btn-sm" :class="{ disabled: uploading }">
|
||||||
|
{{ design.hasLogo ? 'Logo ersetzen…' : 'Logo hochladen…' }}
|
||||||
|
<input type="file" accept=".png,.jpg,.jpeg" hidden @change="uploadLogo" />
|
||||||
|
</label>
|
||||||
|
<button v-if="design.hasLogo" class="btn btn-ghost btn-sm" @click="removeLogo">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fields-block">
|
||||||
|
<div class="fields-head"><span>Felder</span><button class="btn btn-soft btn-sm" @click="addField">+ Feld</button></div>
|
||||||
|
<div v-for="(f, i) in design.fields" :key="i" class="frow">
|
||||||
|
<select class="input" v-model="f.binding">
|
||||||
|
<option v-for="b in design.bindings" :key="b.value" :value="b.value">{{ b.label }}</option>
|
||||||
|
</select>
|
||||||
|
<input class="input" v-model="f.label" placeholder="Label" />
|
||||||
|
<select class="input" v-model="f.slot">
|
||||||
|
<option v-for="s in design.slots" :key="s" :value="s">{{ SLOT_LABEL[s] }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-ghost btn-xs" title="hoch" @click="move(i, -1)">↑</button>
|
||||||
|
<button class="btn btn-ghost btn-xs" title="runter" @click="move(i, 1)">↓</button>
|
||||||
|
<button class="btn btn-ghost btn-xs del" title="entfernen" @click="removeField(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<span v-if="saved" class="ok">✓ Gespeichert</span>
|
||||||
|
<span v-if="error" class="error">{{ error }}</span>
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? 'Speichern…' : 'Speichern' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Apple-Pass-Vorschau -->
|
||||||
|
<div class="preview">
|
||||||
|
<div class="muted small" style="margin-bottom:.6rem">Vorschau (Apple-Stil)</div>
|
||||||
|
<div class="pass" :style="{ background: design.backgroundColor, color: design.foregroundColor }">
|
||||||
|
<div class="pass__top">
|
||||||
|
<img v-if="design.logoUrl" :src="apiBase + design.logoUrl" class="pass__logo" />
|
||||||
|
<span class="pass__logotext">{{ design.title || selected?.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="f in fieldsBySlot('primary')" :key="'p'+f.binding" class="pass__primary">{{ sampleVal(f.binding) }}</div>
|
||||||
|
<div class="pass__row" v-if="fieldsBySlot('secondary').length">
|
||||||
|
<div v-for="f in fieldsBySlot('secondary')" :key="'s'+f.binding" class="pass__cell">
|
||||||
|
<div class="pass__label" :style="{ color: design.labelColor }">{{ f.label || bindingLabel(f.binding) }}</div>
|
||||||
|
<div>{{ sampleVal(f.binding) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pass__row" v-if="fieldsBySlot('auxiliary').length">
|
||||||
|
<div v-for="f in fieldsBySlot('auxiliary')" :key="'a'+f.binding" class="pass__cell">
|
||||||
|
<div class="pass__label" :style="{ color: design.labelColor }">{{ f.label || bindingLabel(f.binding) }}</div>
|
||||||
|
<div>{{ sampleVal(f.binding) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pass__qr"><div class="qrbox">QR</div></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" v-if="fieldsBySlot('back').length">
|
||||||
|
Rückseite: {{ fieldsBySlot('back').map(f => f.label || bindingLabel(f.binding)).join(', ') }}
|
||||||
|
</p>
|
||||||
|
<p class="hint muted">Anordnung ist durch Apple/Google fest vorgegeben – Farben, Logo, Titel und Felder sind frei.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head { margin-bottom: 1.4rem; }
|
||||||
|
.page-head .muted { margin: .2rem 0 0; }
|
||||||
|
.pad { padding: 1.4rem; }
|
||||||
|
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 1.4rem; align-items: start; }
|
||||||
|
.layout > * { min-width: 0; }
|
||||||
|
@media (max-width: 980px) { .layout { grid-template-columns: 1fr; } .preview { position: static; max-width: 320px; } }
|
||||||
|
.field { margin-bottom: .9rem; }
|
||||||
|
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: .6rem; }
|
||||||
|
.cr { display: flex; gap: .4rem; align-items: center; }
|
||||||
|
.cr input[type=color] { width: 40px; height: 36px; border: 1px solid #e0e0e0; border-radius: 8px; background: #fff; padding: 2px; cursor: pointer; flex-shrink: 0; }
|
||||||
|
.cr .input { min-width: 0; }
|
||||||
|
.logo-row { display: flex; align-items: center; gap: .6rem; }
|
||||||
|
.logo-thumb { width: 46px; height: 46px; border-radius: 8px; object-fit: contain; border: 1px solid var(--line); }
|
||||||
|
.btn.disabled { opacity: .6; pointer-events: none; }
|
||||||
|
|
||||||
|
.fields-block { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .7rem; margin: .3rem 0 1rem; }
|
||||||
|
.fields-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; font-weight: 600; font-size: .85rem; }
|
||||||
|
.frow { display: grid; grid-template-columns: 1.1fr 1fr 0.9fr auto auto auto; gap: .35rem; align-items: center; margin-bottom: .35rem; }
|
||||||
|
.frow > * { min-width: 0; }
|
||||||
|
.frow :deep(.input) { padding: .4rem .5rem; }
|
||||||
|
.btn-xs { padding: .35rem .5rem; font-size: .8rem; }
|
||||||
|
.frow .del { color: var(--danger); }
|
||||||
|
.actions { display: flex; align-items: center; gap: .8rem; justify-content: flex-end; }
|
||||||
|
.ok { color: var(--success); font-size: .85rem; font-weight: 600; }
|
||||||
|
.error { color: var(--danger); font-size: .85rem; }
|
||||||
|
.small { font-size: .8rem; }
|
||||||
|
.hint { font-size: .78rem; margin: .4rem 0 0; }
|
||||||
|
.hint.warn { color: var(--psc-orange-dark); background: var(--psc-orange-soft); padding: .5rem .7rem; border-radius: 8px; }
|
||||||
|
|
||||||
|
/* Apple-Pass-Vorschau */
|
||||||
|
.preview { position: sticky; top: 1rem; }
|
||||||
|
.pass { border-radius: 14px; padding: 1rem; box-shadow: var(--shadow-sm); min-height: 200px; }
|
||||||
|
.pass__top { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; }
|
||||||
|
.pass__logo { height: 26px; max-width: 50%; object-fit: contain; }
|
||||||
|
.pass__logotext { font-weight: 700; font-size: .9rem; opacity: .95; }
|
||||||
|
.pass__primary { font-size: 1.5rem; font-weight: 700; line-height: 1.1; margin-bottom: .9rem; }
|
||||||
|
.pass__row { display: flex; gap: 1rem; margin-bottom: .8rem; flex-wrap: wrap; }
|
||||||
|
.pass__cell { font-size: .9rem; font-weight: 600; }
|
||||||
|
.pass__label { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 700; margin-bottom: .1rem; }
|
||||||
|
.pass__qr { display: flex; justify-content: center; margin-top: .6rem; }
|
||||||
|
.qrbox { width: 64px; height: 64px; background: #fff; color: #222; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: .7rem; font-weight: 700; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user