vcard4reseller/backend/templates/public/profile.html.twig
Thomas Peterson 3dfb0b2831 Wallet: QR auf Profilseite → Apple/Google Wallet-Pass
- 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>
2026-06-03 22:28:14 +02:00

165 lines
7.3 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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&nbsp;/&nbsp;Google&nbsp;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 %}