- WalletService (dependency-frei): Google signierter RS256-„Save"-JWT-Link;
Apple .pkpass (pass.json + GD-Icons + manifest + PKCS#7 via openssl + zip).
Konfigurationsgesteuert (env), ohne Zugangsdaten deaktiviert.
- WalletController: /w/{code} Landing (Geräteerkennung + Buttons),
/w/{code}/qr.png, /apple.pkpass, /google (302). Adressierung via shortCode.
- Öffentliche Profilseite: QR-Bereich „Zur Wallet hinzufügen" (nur wenn
Provider konfiguriert + shortCode vorhanden).
- .env Wallet-Block (leer=aus), KONZEPT §12 + deploy/README dokumentiert.
Verifiziert: not-configured → ausgeblendet/404; mit Test-Zertifikaten valides
signiertes .pkpass + Google-Save-JWT. Produktiv: echte Apple-/Google-Creds nötig.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
7.3 KiB
Twig
165 lines
7.3 KiB
Twig
{% extends 'base.html.twig' %}
|
||
|
||
{% set fullName = (e.firstName ~ ' ' ~ e.lastName)|trim %}
|
||
{% set reseller = e.company.reseller %}
|
||
|
||
{# Firmenspezifisches Branding – defensiv validiert #}
|
||
{% set b = e.company.brandingConfig %}
|
||
{% set primary = (b.primaryColor is defined and b.primaryColor matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryColor : null %}
|
||
{% set primaryDark = (b.primaryDark is defined and b.primaryDark matches '/^#[0-9a-fA-F]{6}$/') ? b.primaryDark : primary %}
|
||
{% set logo = (b.logoUrl is defined and (b.logoUrl starts with 'https://' or b.logoUrl starts with '/')) ? b.logoUrl : null %}
|
||
|
||
{% block title %}{{ fullName }} – {{ e.company.name }}{% endblock %}
|
||
|
||
{% block meta %}
|
||
<meta name="description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
|
||
<meta property="og:title" content="{{ fullName }}">
|
||
<meta property="og:description" content="{{ (e.position ? e.position ~ ' · ' : '') ~ e.company.name }}">
|
||
<meta property="og:type" content="profile">
|
||
{% endblock %}
|
||
|
||
{% block stylesheets %}
|
||
{% if primary %}
|
||
<style>
|
||
:root {
|
||
--psc-orange: {{ primary }};
|
||
--psc-orange-dark: {{ primaryDark }};
|
||
--psc-orange-soft: color-mix(in srgb, {{ primary }} 14%, white);
|
||
--psc-orange-soft-2: color-mix(in srgb, {{ primary }} 7%, white);
|
||
--psc-border: color-mix(in srgb, {{ primary }} 32%, white);
|
||
}
|
||
</style>
|
||
{% endif %}
|
||
<style>
|
||
.wrap { max-width: 480px; margin: 0 auto; padding: 1.5rem 1rem 3rem; }
|
||
.vc__logo { display: block; max-height: 38px; margin: 0 auto .4rem; }
|
||
.vc {
|
||
background: var(--white);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
border: 1px solid #f0f0f0;
|
||
}
|
||
.vc__cover {
|
||
height: 120px;
|
||
background: linear-gradient(135deg, var(--psc-orange) 0%, var(--psc-orange-dark) 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.vc__cover-logo { max-height: 46px; max-width: 70%; filter: brightness(0) invert(1); opacity: .95; }
|
||
.vc__head { padding: 0 1.6rem 1.4rem; margin-top: -56px; text-align: center; }
|
||
.vc__avatar {
|
||
width: 112px; height: 112px; border-radius: 50%;
|
||
border: 5px solid var(--white);
|
||
background: var(--psc-orange-soft);
|
||
color: var(--psc-orange-dark);
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: 2.4rem; font-weight: 700; object-fit: cover;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.vc__name { font-size: 1.6rem; margin: .7rem 0 .15rem; }
|
||
.vc__role { color: var(--muted); font-size: 1rem; }
|
||
.vc__org {
|
||
display: inline-block; margin-top: .6rem;
|
||
background: var(--psc-orange-soft); color: var(--psc-orange-dark);
|
||
padding: .25rem .9rem; border-radius: 999px; font-weight: 600; font-size: .85rem;
|
||
}
|
||
.vc__actions { display: grid; gap: .6rem; padding: 0 1.6rem 1.4rem; }
|
||
.vc__row { display: flex; gap: .6rem; }
|
||
.vc__row .btn { flex: 1; justify-content: center; }
|
||
.btn-block { width: 100%; justify-content: center; }
|
||
|
||
.vc__section { padding: 1.2rem 1.6rem; border-top: 1px solid #f2f2f2; }
|
||
.vc__label { font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 700; margin-bottom: .7rem; }
|
||
.links { display: grid; gap: .5rem; }
|
||
.link {
|
||
display: flex; align-items: center; gap: .7rem;
|
||
padding: .7rem .9rem; border: 1px solid #eee; border-radius: var(--radius-sm);
|
||
color: var(--text); font-weight: 600; font-size: .92rem;
|
||
}
|
||
.link:hover { border-color: var(--psc-border); background: var(--psc-orange-soft-2); text-decoration: none; }
|
||
.link .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--psc-orange); }
|
||
.bio { color: var(--text); font-size: .95rem; }
|
||
|
||
.qr { text-align: center; }
|
||
.qr img { width: 190px; height: 190px; border-radius: var(--radius-sm); border: 1px solid #eee; }
|
||
.qr p { color: var(--muted); font-size: .82rem; margin: .6rem 0 0; }
|
||
|
||
.foot { text-align: center; margin-top: 1.6rem; color: var(--muted); font-size: .8rem; }
|
||
.foot .brand-logo { font-size: .95rem; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block body %}
|
||
<div class="wrap">
|
||
<div class="vc">
|
||
<div class="vc__cover">
|
||
{% if logo %}<img class="vc__cover-logo" src="{{ logo }}" alt="{{ e.company.name }}">{% endif %}
|
||
</div>
|
||
<div class="vc__head">
|
||
{% if e.photoPath %}
|
||
<img class="vc__avatar" src="{{ e.photoPath }}" alt="{{ fullName }}">
|
||
{% else %}
|
||
<div class="vc__avatar">{{ (e.firstName|first ~ e.lastName|first)|upper }}</div>
|
||
{% endif %}
|
||
<h1 class="vc__name">{{ fullName }}</h1>
|
||
{% if e.position or e.department %}
|
||
<div class="vc__role">{{ e.position }}{% if e.position and e.department %} · {% endif %}{{ e.department }}</div>
|
||
{% endif %}
|
||
<div class="vc__org">{{ e.company.name }}</div>
|
||
</div>
|
||
|
||
<div class="vc__actions">
|
||
<a class="btn btn-primary btn-block" href="{{ path('public_profile_vcard', {companySlug: e.company.slug, slug: e.slug}) }}">
|
||
⬇ Kontakt speichern (vCard)
|
||
</a>
|
||
<div class="vc__row">
|
||
{% if e.phone %}<a class="btn btn-soft" href="tel:{{ e.phone }}">Anrufen</a>{% endif %}
|
||
{% if e.mobile %}<a class="btn btn-soft" href="tel:{{ e.mobile }}">Mobil</a>{% endif %}
|
||
{% if e.email %}<a class="btn btn-soft" href="mailto:{{ e.email }}">E-Mail</a>{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% if e.bio %}
|
||
<div class="vc__section">
|
||
<div class="vc__label">Über mich</div>
|
||
<p class="bio">{{ e.bio }}</p>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if e.contactLinks|length %}
|
||
<div class="vc__section">
|
||
<div class="vc__label">Links</div>
|
||
<div class="links">
|
||
{% for link in e.contactLinks %}
|
||
<a class="link" href="{{ link.url }}" target="_blank" rel="noopener">
|
||
<span class="dot"></span>{{ link.label ?: link.type|capitalize }}
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="vc__section qr">
|
||
<div class="vc__label">Profil teilen</div>
|
||
<img src="{{ path('public_profile_qr', {companySlug: e.company.slug, slug: e.slug}) }}" alt="QR-Code zum Profil">
|
||
<p>QR-Code scannen, um dieses Profil zu öffnen</p>
|
||
</div>
|
||
|
||
{% if walletEnabled %}
|
||
<div class="vc__section qr">
|
||
<div class="vc__label">Zur Wallet hinzufügen</div>
|
||
<a href="{{ path('wallet_landing', {code: e.shortCode}) }}">
|
||
<img src="{{ path('wallet_qr', {code: e.shortCode}) }}" alt="QR-Code: Karte zu Apple/Google Wallet hinzufügen">
|
||
</a>
|
||
<p>Scannen, um die Karte in Apple / Google Wallet zu speichern</p>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="foot">
|
||
bereitgestellt über
|
||
<span class="brand-logo">{{ reseller ? reseller.name : 'vcard4' }}<span class="tag">reseller</span></span>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|