Kundenbereich: App-Ansicht fuers Handy (Tab-Leiste, Konto-Sheet, installierbar)

Auf dem Handy (unter 880 px und Handys quer) sieht der Kundenbereich aus wie
eine App; der Desktop bleibt pixelgleich.

- Kopfzeile als App-Leiste: Logo, beim Scrollen der Seitentitel, Avatar
- Navigation als Tab-Leiste unten mit Symbolen und Zaehlern; neuer Tab
  "Wartung" mit der Abo-Karte, Stundenkonto/Bestellung und
  Uebernahmegutschrift als Unterseiten mit Zurueck (auch Android-Geste)
- Konto als Bottom-Sheet (Ansprechpartner, Vertraege kuendigen/widerrufen,
  Rechtliches, Abmelden), am Desktop als Popover unter dem Avatar
- Rechnungen als Karten, Fortschritt als Zeitleiste, Chat fuellt den
  Bildschirm; bei offener Tastatur verschwindet die Tab-Leiste (kommt auch
  nach der Android-Zurueck-Geste wieder)
- Installierbar: Manifest, App-Icons (WEBklar-Logo), Favicon, Hinweis
  "Zum Home-Bildschirm"; Service Worker nur fuer die Offline-Seite,
  Kundendaten werden nicht zwischengespeichert
- Installierte App: Kuendigen/Widerrufen mit Link zurueck in den
  Kundenbereich (nur dort sichtbar, Texte unveraendert); Login-Links zu
  Bedingungen/Datenschutz/Impressum oeffnen wie im Dashboard ein neues Fenster
- Login mit App-Icon und Abstaenden fuer Statusleiste/Home-Balken
- Hinweise "in der Seitenleiste" haben auf dem Handy eine eigene Fassung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 14:36:29 +00:00
1 parent 06e9a0fe8d
commit 20bf5afe28
17 files changed
+999 -36

No files matched your search

+396 -14
View File
@@ -454,19 +454,20 @@ async function loadCustomerInvoices() {
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
return
}
// Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css)
body.innerHTML = `
<div class="kb-table-wrap"><table class="simple-table">
<div class="kb-table-wrap"><table class="simple-table kb-invoices">
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
<tbody>
${data.invoices.map((i) => `
<tr>
<td>${escapeHtml(i.number)}</td>
<td>${fmtDateShort(i.date)}</td>
<td>${fmtEuro(i.amount)}</td>
<td>${fmtEuro(i.balance)}</td>
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td style="white-space:nowrap">
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a> · ` : ''}
<tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
<td class="inv-no">${escapeHtml(i.number)}</td>
<td class="inv-date">${fmtDateShort(i.date)}</td>
<td class="inv-amount">${fmtEuro(i.amount)}</td>
<td class="inv-open">${fmtEuro(i.balance)}</td>
<td class="inv-status"><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td class="inv-actions" style="white-space:nowrap">
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a><span class="inv-sep" aria-hidden="true"> · </span>` : ''}
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
</td>
</tr>`).join('')}
@@ -831,7 +832,7 @@ function renderAboAccountView(view, data) {
<h1>Wartungs-Abo</h1>
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
</header>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleiste, damit Ihre Website online bleibt.</div>' : ''}
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#wartung" data-view="wartung">Wartung</a></span>, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
<div class="kb-stats">
<div class="kb-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
@@ -880,8 +881,10 @@ async function loadAboCard({ afterCheckout = false } = {}) {
card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
loadTakeoverCard({ abo: data })
document.dispatchEvent(new CustomEvent('kb:abo', { detail: data }))
} catch (err) {
console.warn('Abo konnte nicht geladen werden:', err.message)
document.dispatchEvent(new CustomEvent('kb:abo', { detail: null }))
}
}
@@ -1139,8 +1142,9 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
}
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift)
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', abo: 'abo', takeover: 'gutschrift' }
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
const loadedViews = new Set()
@@ -1166,8 +1170,8 @@ function showView(name, { updateHash = true } = {}) {
const hash = VIEW_HASHES[name]
window.history.replaceState(null, '', hash ? `#${hash}` : window.location.pathname)
}
// Mobil liegt die Seitenleiste ueber dem Inhalt -> zum Inhalt springen
if (window.innerWidth <= 880) document.querySelector('.kb-main')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
// App-Ansicht: Titel, Zurueck-Pfeil, Tab und Scrollposition nachziehen (initAppShell)
document.dispatchEvent(new CustomEvent('kb:view', { detail: { name } }))
}
function initialsOf(name) {
@@ -1183,6 +1187,8 @@ async function initDashboardPage() {
const loadError = document.getElementById('load-error')
const logoutBtn = document.getElementById('logout-btn')
initAppShell()
logoutBtn?.addEventListener('click', async () => {
await api('/api/auth/logout', { method: 'POST' })
window.location.href = '/login.html'
@@ -1277,5 +1283,381 @@ async function initDashboardPage() {
}
}
/* ------------------------------------------------------- App-Ansicht (Handy) --- */
// Auf dem Handy wird der Kundenbereich zur App: die Navigation ist die Tab-Leiste unten, die
// Kopfzeile zeigt beim Scrollen den Seitentitel, Stundenkonto/Bestellung und Gutschrift sind
// Unterseiten der Wartung (mit Zurueck), das Konto ist ein Bottom-Sheet. Styles: kundenbereich.css.
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
const coarsePointer = window.matchMedia('(pointer: coarse)')
// Unterseite -> Tab, zu dem sie gehoert
const APP_PARENT = { abo: 'wartung', takeover: 'wartung' }
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
function isAppLayout() {
return appLayout.matches
}
function onMediaChange(mq, fn) {
if (mq.addEventListener) mq.addEventListener('change', fn)
else mq.addListener(fn)
}
function isStandaloneApp() {
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true
}
function isIosDevice() {
return /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
// Karten der Seitenleiste: App-Ansicht -> Ansprechpartner ins Konto-Sheet, Abo-Karte in den Tab "Wartung"
function placeSideCards() {
for (const { el, slot, anchor } of appShell.sideCards) {
if (isAppLayout()) {
if (el.parentNode !== slot) slot.append(el)
} else if (anchor.nextSibling !== el) {
anchor.after(el)
}
}
}
function currentViewTitle() {
return document.querySelector(`#view-${appShell.view} .kb-head h1`)
}
// Kopfzeile: Hintergrund nach dem Scrollen, Seitentitel sobald die grosse Ueberschrift darunter verschwindet
function updateBarState() {
const header = document.querySelector('.kb-header')
const h1 = currentViewTitle()
const barBottom = header ? header.getBoundingClientRect().bottom : 0
document.body.classList.toggle('kb-scrolled', window.scrollY > 4)
document.body.classList.toggle('kb-titled', Boolean(h1 && h1.getBoundingClientRect().bottom < barBottom + 4))
}
function updateAppBar() {
const sub = isAppLayout() && Boolean(APP_PARENT[appShell.view])
document.getElementById('kb-back')?.classList.toggle('hidden', !sub)
document.body.classList.toggle('kb-subpage', sub)
const title = document.getElementById('kb-app-title')
if (title) title.textContent = currentViewTitle()?.textContent.trim() || ''
updateBarState()
}
function onAppViewShown(name) {
// Den Tab "Wartung" gibt es nur in der App-Ansicht; am Desktop direkt das Wartungs-Abo zeigen
if (name === 'wartung' && !isAppLayout()) {
showView('abo', { updateHash: !appShell.fromHistory })
return
}
const prev = appShell.view
const parent = APP_PARENT[name]
const nav = name === prev ? 'same' : parent === prev ? 'push' : APP_PARENT[prev] === name ? 'back' : 'tab'
appShell.view = name
document.body.dataset.kbView = name
document.body.dataset.kbNav = nav
// Unterseiten markieren ihren Tab
document.querySelectorAll('.kb-nav-item').forEach((b) => {
const on = b.dataset.view === (parent || name)
b.classList.toggle('active', on)
if (on) b.setAttribute('aria-current', 'page')
else b.removeAttribute('aria-current')
})
if (isAppLayout()) {
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zur Wartung
if (nav === 'push' && !appShell.fromHistory) {
window.history.replaceState(null, '', `#${VIEW_HASHES[parent]}`)
window.history.pushState({ kbSub: name }, '', `#${VIEW_HASHES[name]}`)
}
// Tabs merken sich ihre Scrollposition, Unterseiten beginnen oben, erneutes Tippen scrollt nach oben
if (nav === 'same') window.scrollTo({ top: 0, behavior: 'smooth' })
else window.scrollTo(0, nav === 'push' ? 0 : appShell.scroll[name] || 0)
}
updateAppBar()
}
/* Wartungs-Tab: Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
function updateWartungBadge() {
const card = document.getElementById('abo-card')
const badge = document.getElementById('kb-tab-wartung-badge')
if (!card || !badge) return
const paymentProblem = Boolean(card.querySelector('.kb-side-alert'))
const questions = Number(card.querySelector('#takeover-badge:not(.hidden)')?.textContent || 0)
badge.textContent = paymentProblem ? '!' : questions || ''
badge.title = paymentProblem ? 'Zahlung fehlgeschlagen' : questions ? 'Rückfrage offen' : ''
badge.classList.toggle('kb-badge-alert', paymentProblem)
badge.classList.toggle('hidden', !paymentProblem && !questions)
}
/* "Als App installieren": Chrome/Android per Installationsdialog, iPhone über Teilen -> Home-Bildschirm */
function renderInstallOffers() {
let dismissed = false
try {
dismissed = localStorage.getItem('kb-install-dismissed') === '1'
} catch {
/* ohne Speicher einfach wieder anzeigen */
}
const canPrompt = Boolean(appShell.installPrompt)
const ios = isIosDevice()
const offer = isAppLayout() && !isStandaloneApp() && (canPrompt || ios)
const shareIcon = '<svg class="kb-inline-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1"/></svg>'
document.querySelectorAll('[data-install]').forEach((box) => {
const dismissible = box.hasAttribute('data-install-dismissible')
const show = offer && !(dismissible && dismissed)
box.classList.toggle('hidden', !show)
if (!show) return
box.innerHTML = `
<img class="kb-install-icon" src="/img/app-icon-192.png" alt="" width="44" height="44">
<div class="kb-install-text">
<strong>WEBklar als App</strong>
<span>${canPrompt
? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
: `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}</span>
</div>
${canPrompt ? '<button type="button" class="kb-install-go" data-install-go>Installieren</button>' : ''}
${dismissible ? '<button type="button" class="kb-install-x" data-install-dismiss aria-label="Hinweis ausblenden"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button>' : ''}`
})
}
/* Konto: Bottom-Sheet (Handy) bzw. Popover (Desktop) mit Ansprechpartner, Rechtlichem und Abmelden */
function initAccountSheet() {
const sheet = document.getElementById('kb-sheet')
const avatar = document.getElementById('customer-avatar')
if (!sheet || !avatar) return
if (typeof sheet.showModal !== 'function') {
// Sehr alte Browser ohne <dialog>: Abmelden bleibt in der Kopfzeile
document.body.classList.add('kb-no-sheet')
avatar.disabled = true
return
}
const panel = sheet.querySelector('.kb-sheet-panel')
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
// Zaehler gegen verspaetetes Schliessen, wenn das Sheet gleich wieder geoeffnet wurde
let closeToken = 0
const open = () => {
if (sheet.open) return
closeToken += 1
document.getElementById('kb-sheet-name').textContent = document.getElementById('customer-name')?.textContent || 'Mein Konto'
document.getElementById('kb-sheet-meta').textContent = document.getElementById('customer-meta')?.textContent || ''
document.getElementById('kb-sheet-avatar').textContent = avatar.textContent
renderInstallOffers()
sheet.classList.remove('is-closing')
panel.style.transform = ''
sheet.showModal()
document.documentElement.classList.add('kb-sheet-open')
}
const finish = () => {
sheet.classList.remove('is-closing')
panel.style.transform = ''
if (sheet.open) sheet.close()
}
const close = () => {
if (!sheet.open || sheet.classList.contains('is-closing')) return
if (reducedMotion.matches) return finish()
const token = ++closeToken
const done = () => {
if (token === closeToken) finish()
}
sheet.classList.add('is-closing')
panel.style.transform = ''
panel.addEventListener('transitionend', done, { once: true })
setTimeout(done, 400)
}
avatar.addEventListener('click', open)
// Escape bzw. Android-Zurück: mit Animation schließen
sheet.addEventListener('cancel', (ev) => {
ev.preventDefault()
close()
})
sheet.addEventListener('close', () => {
sheet.classList.remove('is-closing')
document.documentElement.classList.remove('kb-sheet-open')
avatar.focus({ preventScroll: true })
})
// Klick auf den Hintergrund, das X oder eine Ansicht (z. B. "Nachricht schreiben") schließt
sheet.addEventListener('click', (ev) => {
if (ev.target === sheet || ev.target.closest('[data-sheet-close], [data-view]')) close()
})
document.getElementById('kb-sheet-logout')?.addEventListener('click', () => document.getElementById('logout-btn')?.click())
// Nach unten wischen schließt das Sheet (Griff und Kopf des Sheets)
let drag = null
panel.addEventListener('pointerdown', (ev) => {
if (!isAppLayout() || !ev.target.closest('.kb-sheet-grip, .kb-sheet-me') || ev.target.closest('button')) return
drag = { y: ev.clientY, t: ev.timeStamp, dy: 0 }
panel.setPointerCapture(ev.pointerId)
panel.classList.add('is-dragging')
})
panel.addEventListener('pointermove', (ev) => {
if (!drag) return
drag.dy = Math.max(0, ev.clientY - drag.y)
panel.style.transform = `translateY(${drag.dy}px)`
})
const endDrag = (ev) => {
if (!drag) return
const { dy, t } = drag
drag = null
panel.classList.remove('is-dragging')
if (dy > 90 || dy / Math.max(1, ev.timeStamp - t) > 0.6) close()
else panel.style.transform = ''
}
panel.addEventListener('pointerup', endDrag)
panel.addEventListener('pointercancel', endDrag)
}
function initAppShell() {
// Karten der Seitenleiste mit Anker merken, damit sie am Desktop an ihren Platz zurueckkehren
for (const [selector, slotId] of [['.kb-side .kb-contact', 'kb-contact-slot'], ['#abo-card', 'kb-abo-slot']]) {
const el = document.querySelector(selector)
const slot = document.getElementById(slotId)
if (!el || !slot) continue
const anchor = document.createComment(`Platz: ${selector}`)
el.before(anchor)
appShell.sideCards.push({ el, slot, anchor })
}
placeSideCards()
document.body.dataset.kbView = appShell.view
document.addEventListener('kb:view', (ev) => onAppViewShown(ev.detail.name))
document.addEventListener('kb:abo', (ev) => {
document.getElementById('kb-tab-wartung')?.classList.toggle('hidden', !ev.detail)
const slot = document.getElementById('kb-abo-slot')
slot?.querySelector(':scope > .kb-empty')?.remove()
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Die Wartung konnte gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
updateWartungBadge()
updateAppBar()
})
const aboCard = document.getElementById('abo-card')
if (aboCard) new MutationObserver(updateWartungBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zur Wartung
document.getElementById('kb-back')?.addEventListener('click', () => {
if (window.history.state?.kbSub) window.history.back()
else showView(APP_PARENT[appShell.view] || 'projects')
})
window.addEventListener('popstate', () => {
const name = viewFromHash() || 'projects'
if (name === appShell.view) return
appShell.fromHistory = true
try {
showView(name, { updateHash: false })
} finally {
appShell.fromHistory = false
}
})
// Scrollposition je Ansicht und Zustand der Kopfzeile (einmal pro Frame)
let scrollQueued = false
window.addEventListener('scroll', () => {
if (scrollQueued) return
scrollQueued = true
requestAnimationFrame(() => {
scrollQueued = false
appShell.scroll[appShell.view] = window.scrollY
updateBarState()
})
}, { passive: true })
// Tastatur offen (Touch): Tab-Leiste ausblenden, damit Eingabe und Chat Platz haben
document.addEventListener('focusin', (ev) => {
if (coarsePointer.matches && ev.target.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
})
document.addEventListener('focusout', () => {
setTimeout(() => {
if (!document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.remove('kb-typing')
}, 80)
})
// Sichtbare Hoehe (ohne Tastatur) fuer den Chat; neueste Nachricht bleibt sichtbar.
// Schliesst die Tastatur, ohne dass das Feld den Fokus verliert (Android-Zurueck-Geste),
// kommt die Tab-Leiste zurueck; tippt man erneut hinein, verschwindet sie wieder.
const vv = window.visualViewport
if (vv) {
let fullHeight = vv.height
let width = vv.width
let keyboardOpen = false
const syncHeight = () => {
document.documentElement.style.setProperty('--kb-vvh', `${Math.round(vv.height)}px`)
if (Math.abs(vv.width - width) > 1) {
width = vv.width
fullHeight = vv.height
}
fullHeight = Math.max(fullHeight, vv.height)
const open = vv.height < fullHeight - 150
if (open !== keyboardOpen) {
keyboardOpen = open
if (!open) document.body.classList.remove('kb-typing')
else if (coarsePointer.matches && document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
}
// Chat mit Tastatur: iOS schiebt die Seite hoch; App-Leiste, Verlauf und Eingabe passen genau hinein
if (keyboardOpen && appShell.view === 'chat' && isAppLayout()) window.scrollTo(0, 0)
}
vv.addEventListener('resize', syncHeight)
syncHeight()
}
const messages = document.getElementById('chat-messages')
if (messages && typeof ResizeObserver !== 'undefined') {
let atBottom = true
messages.addEventListener('scroll', () => {
atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 48
}, { passive: true })
new ResizeObserver(() => {
if (atBottom) messages.scrollTop = messages.scrollHeight
}).observe(messages)
}
// Installations-Hinweis
window.addEventListener('beforeinstallprompt', (ev) => {
ev.preventDefault()
appShell.installPrompt = ev
renderInstallOffers()
})
window.addEventListener('appinstalled', () => {
appShell.installPrompt = null
renderInstallOffers()
})
document.addEventListener('click', async (ev) => {
if (ev.target.closest('[data-install-dismiss]')) {
try {
localStorage.setItem('kb-install-dismissed', '1')
} catch {
/* ignore */
}
renderInstallOffers()
} else if (ev.target.closest('[data-install-go]') && appShell.installPrompt) {
const prompt = appShell.installPrompt
appShell.installPrompt = null
await prompt.prompt()
renderInstallOffers()
}
})
onMediaChange(appLayout, () => {
placeSideCards()
if (!isAppLayout() && appShell.view === 'wartung') showView('abo')
updateAppBar()
renderInstallOffers()
})
initAccountSheet()
renderInstallOffers()
updateAppBar()
// Nach der Bestellung bei Stripe: Bestaetigung steht auf der Abo-Karte im Tab "Wartung"
if (isAppLayout() && getQueryParam('abo') === 'success') showView('wartung')
}
// App-Installation (Homescreen) und Offline-Seite; zwischengespeichert wird nur die Offline-Seite (sw.js)
if ('serviceWorker' in navigator && window.isSecureContext) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('Service Worker:', err.message))
})
}
window.initLoginPage = initLoginPage
window.initDashboardPage = initDashboardPage