Wallet-Design Frontend: Editor + Live-Apple-Pass-Vorschau (pro Firma)

WalletDesignView (/app/wallet, Nav „Wallet" im Firmen-Kontext): Farben
(Hintergrund/Text/Label), Titel, Logo-Upload, Feld-Editor (Binding + Label +
Slot, hinzufügen/sortieren/entfernen). Live-Vorschau im Apple-Stil mit echten
Beispieldaten. Hinweis, dass die Anordnung durch Apple/Google fix ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-04 19:05:40 +02:00
parent 488ddc115f
commit 18894c7b52
3 changed files with 251 additions and 0 deletions

View File

@ -19,6 +19,7 @@ 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))

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: '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,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>