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:
parent
488ddc115f
commit
18894c7b52
@ -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: '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: '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 },
|
||||
].filter((i) => i.show))
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ const router = createRouter({
|
||||
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
|
||||
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.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' } },
|
||||
],
|
||||
},
|
||||
|
||||
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