Files
Lars Gebhardt-Kusche ac2d95fc56
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped
asdasd
2026-06-25 00:17:31 +02:00

781 lines
30 KiB
JavaScript

(function () {
function parseSections(root, options) {
const configured = Array.isArray(options.sections) ? options.sections : null;
if (configured) {
return configured;
}
try {
const parsed = JSON.parse(root.dataset.sectionsJson || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
function cx() {
return Array.from(arguments).filter(Boolean).join(' ');
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function fmtNumber(value, digits) {
if (value === null || value === undefined || value === '' || !Number.isFinite(Number(value))) {
return 'n/a';
}
return Number(value).toLocaleString('de-DE', {
minimumFractionDigits: 0,
maximumFractionDigits: digits === undefined ? 6 : digits,
});
}
function fmtDate(value) {
const raw = String(value || '').trim();
if (!raw) {
return 'n/a';
}
let normalized = raw;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
normalized = `${raw}T00:00:00Z`;
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
normalized = `${raw.replace(' ', 'T')}Z`;
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
normalized = `${raw}Z`;
}
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return raw;
}
return parsed.toLocaleString('de-DE');
}
function readMessage(error) {
return error && error.message ? error.message : 'Unbekannter Fehler';
}
function createFxRatesApp(root, options) {
const apiBase = options.apiBase || root.dataset.apiBase || '/api/fx-rates/index.php?path=v1';
const sectionDefs = parseSections(root, options);
const fallbackSections = [
{ key: 'overview', label: 'Uebersicht', summary: 'Letzte Abrufe, Umrechnung, Verlauf und manuelle Aktualisierung.' },
{ key: 'currencies', label: 'Waehrungen', summary: 'Bevorzugte Waehrungen, Anzeige-Basis und Katalog verwalten.' },
{ key: 'settings', label: 'Setup', summary: 'Provider, Token, Refresh-Regeln und Laufzeitparameter pflegen.' },
];
const sectionMap = new Map();
sectionDefs.concat(fallbackSections).forEach((section) => {
const key = String(section.key || '').trim();
if (!key || sectionMap.has(key)) {
return;
}
sectionMap.set(key, {
key,
label: String(section.label || key),
summary: String(section.summary || ''),
});
});
const sections = Array.from(sectionMap.values());
const initialTab = String(options.activeView || root.dataset.activeView || 'overview').trim() || 'overview';
const state = {
activeTab: sectionMap.has(initialTab) ? initialTab : sections[0].key,
loading: true,
saving: false,
syncingCatalog: false,
message: '',
error: '',
settings: null,
statuses: [],
recentFetches: [],
conversion: null,
history: [],
currencyProbe: null,
};
function setState(patch) {
Object.assign(state, patch);
render();
}
async function request(path, options) {
const buildApiUrl = (relativePath) => {
const url = new URL(apiBase, window.location.origin);
const [resourcePath, queryString] = String(relativePath || '').split('?');
const currentPath = url.searchParams.get('path') || '';
url.searchParams.set('path', `${currentPath.replace(/\/+$/, '')}/${String(resourcePath || '').replace(/^\/+/, '')}`.replace(/^\/+/, ''));
if (queryString) {
const extraParams = new URLSearchParams(queryString);
extraParams.forEach((value, key) => {
url.searchParams.set(key, value);
});
}
return url.toString();
};
const response = await fetch(buildApiUrl(path), {
method: options && options.method ? options.method : 'GET',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(options && options.body ? { 'Content-Type': 'application/json' } : {}),
},
body: options && options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
}
return payload.data;
}
function currentSettings() {
return state.settings || {
preferred_currencies: ['EUR', 'USD'],
default_base_currency: 'EUR',
display_base_currency: 'EUR',
refresh_max_age_hours: 6,
refresh_max_age_minutes: 360,
currency_catalog: [],
};
}
function historyPairs() {
const settings = currentSettings();
const base = String(settings.display_base_currency || settings.default_base_currency || 'EUR').toUpperCase();
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
return preferred
.map((code) => String(code || '').toUpperCase())
.filter((code) => code && code !== base)
.slice(0, 4)
.map((code) => ({ from: base, to: code }));
}
function normalizedTimestamp(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
return `${raw.replace(' ', 'T')}Z`;
}
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(raw)) {
return `${raw}Z`;
}
return raw;
}
function buildHistoryMatrix(historyEntries) {
const columns = [];
const rowsByKey = new Map();
historyEntries.forEach((entry) => {
const pair = entry && entry.pair ? entry.pair : null;
const columnKey = pair ? `${String(pair.from || '').toUpperCase()}/${String(pair.to || '').toUpperCase()}` : '';
if (!columnKey) {
return;
}
if (!columns.includes(columnKey)) {
columns.push(columnKey);
}
(Array.isArray(entry.rows) ? entry.rows : []).forEach((row) => {
const fetchId = Number(row && row.fetch_id);
const fetchedAt = normalizedTimestamp(row && row.fetched_at);
const rowKey = fetchId > 0 ? `fetch:${fetchId}` : `time:${fetchedAt}`;
if (!rowKey || rowKey === 'time:') {
return;
}
if (!rowsByKey.has(rowKey)) {
rowsByKey.set(rowKey, {
key: rowKey,
fetch_id: fetchId > 0 ? fetchId : null,
fetched_at: row && row.fetched_at ? String(row.fetched_at) : '',
provider: row && row.provider ? String(row.provider) : '',
values: {},
});
}
const bucket = rowsByKey.get(rowKey);
if (!bucket) {
return;
}
if (!bucket.fetched_at && row && row.fetched_at) {
bucket.fetched_at = String(row.fetched_at);
}
if (!bucket.provider && row && row.provider) {
bucket.provider = String(row.provider);
}
bucket.values[columnKey] = Number.isFinite(Number(row && row.rate)) ? Number(row.rate) : null;
});
});
const rows = Array.from(rowsByKey.values()).sort((left, right) => {
const leftTs = new Date(normalizedTimestamp(left.fetched_at) || 0).getTime();
const rightTs = new Date(normalizedTimestamp(right.fetched_at) || 0).getTime();
return rightTs - leftTs;
});
return {
columns,
rows,
};
}
async function loadOverviewData() {
const [statuses, recentFetches] = await Promise.all([
request('status'),
request('recent-fetches?limit=15'),
]);
const pairs = historyPairs();
const historyRows = await Promise.all(pairs.map(async (pair) => ({
pair,
rows: await request(`history?from=${encodeURIComponent(pair.from)}&to=${encodeURIComponent(pair.to)}&limit=12`),
})));
setState({
statuses: Array.isArray(statuses) ? statuses : [],
recentFetches: Array.isArray(recentFetches) ? recentFetches : [],
history: historyRows,
});
}
async function loadBootstrap() {
try {
const [settings, currencyProbe] = await Promise.all([
request('settings'),
request('currency-catalog/probe').catch(() => null),
]);
setState({
settings,
currencyProbe,
loading: false,
error: '',
});
await loadOverviewData();
} catch (error) {
setState({
loading: false,
error: readMessage(error),
});
}
}
async function runRefresh(force) {
setState({
saving: true,
error: '',
message: '',
});
try {
const result = await request('refresh', {
method: 'POST',
body: {
force: force === true,
trigger_source: 'manual',
},
});
await loadOverviewData();
setState({
saving: false,
message: result && result.reused
? `Kein neuer Abruf. Letzter Snapshot ist noch innerhalb von ${fmtNumber(currentSettings().refresh_max_age_hours, 2)} Stunden.`
: `Aktuelle Kurse gespeichert. ${fmtNumber(result?.updated_count, 0)} Werte aktualisiert.`,
});
} catch (error) {
setState({
saving: false,
error: readMessage(error),
});
}
}
async function runConversion(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const amount = Number(formData.get('amount') || 0);
const from = String(formData.get('from') || '').toUpperCase();
const to = String(formData.get('to') || '').toUpperCase();
try {
const rate = await request(`rate?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
const resolved = Number(rate?.rate || 0);
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new Error('Kein passender Kurs verfuegbar.');
}
setState({
conversion: {
amount,
from,
to,
rate: resolved,
converted: amount * resolved,
},
error: '',
message: '',
});
} catch (error) {
setState({
conversion: null,
error: readMessage(error),
});
}
}
async function saveSettings(event) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const preferred = String(formData.get('preferred_currencies') || '')
.split(',')
.map((item) => String(item || '').trim().toUpperCase())
.filter(Boolean);
setState({ saving: true, error: '', message: '' });
try {
const body = {};
['provider', 'api_version', 'api_url', 'currencies_url', 'api_key', 'default_base_currency', 'display_base_currency', 'schedule_timezone'].forEach((key) => {
if (formData.has(key)) {
body[key] = formData.get(key);
}
});
if (formData.has('timeout_sec')) {
body.timeout_sec = Number(formData.get('timeout_sec') || 10);
}
if (formData.has('refresh_max_age_hours')) {
body.refresh_max_age_hours = Number(formData.get('refresh_max_age_hours') || 6);
}
if (formData.has('preferred_currencies')) {
body.preferred_currencies = preferred;
}
const settings = await request('settings', {
method: 'PUT',
body,
});
setState({
saving: false,
settings,
message: 'Setup gespeichert.',
});
await loadOverviewData();
} catch (error) {
setState({
saving: false,
error: readMessage(error),
});
}
}
async function syncCatalog() {
setState({ syncingCatalog: true, error: '', message: '' });
try {
const result = await request('currency-catalog/sync', {
method: 'POST',
body: {},
});
setState({
syncingCatalog: false,
settings: result?.settings || state.settings,
message: `Waehrungskatalog synchronisiert. ${fmtNumber(result?.sync?.synced_count, 0)} Eintraege geladen.`,
});
} catch (error) {
setState({
syncingCatalog: false,
error: readMessage(error),
});
}
}
function openWidgetSetup() {
window.dispatchEvent(new CustomEvent('desktop:open-app', {
detail: {
appId: 'user-self-management',
},
}));
}
function renderNav() {
return `
<aside class="window-app-sidebar">
<div class="window-app-brand">
<p class="window-app-kicker">Modul</p>
<h1>Waehrungs-Checker</h1>
<p>Waehrungskurse, Historie, API und Widget-Aktualisierung in einer gemeinsamen Modulanwendung.</p>
</div>
<div class="window-app-nav-list">
${sections.map((section) => `
<button class="${cx('window-app-nav-button', state.activeTab === section.key && 'is-active')}" type="button" data-action="tab" data-tab="${escapeHtml(section.key)}">
<strong>${escapeHtml(section.label)}</strong>
<span class="window-app-meta">${escapeHtml(section.summary)}</span>
</button>
`).join('')}
</div>
</aside>
`;
}
function renderOverview() {
const settings = currentSettings();
const latest = state.statuses[0] || null;
const base = String(settings.default_base_currency || 'EUR').toUpperCase();
const preferred = Array.isArray(settings.preferred_currencies) ? settings.preferred_currencies : [];
const historyMatrix = buildHistoryMatrix(state.history);
return `
<div class="fx-stack">
<section class="window-app-card">
<p class="window-app-kicker">Aktualisierung</p>
<h3>Externer Kursabruf</h3>
<p class="window-app-copy">Das Oeffnen der App loest keinen Abruf aus. Manuell und per API wird nur aktualisiert, wenn die bestehenden Kurse aelter als ${fmtNumber(settings.refresh_max_age_hours, 2)} Stunden sind, ausser mit force.</p>
<div class="fx-inline-actions">
<button class="fx-button" type="button" data-action="refresh">${state.saving ? 'Aktualisiert ...' : 'Kurse aktualisieren'}</button>
<button class="fx-button fx-button--ghost" type="button" data-action="refresh-force">${state.saving ? 'Aktualisiert ...' : 'Erzwungen aktualisieren'}</button>
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Widget-Auswahl oeffnen</button>
</div>
<p class="${cx('fx-message', state.error && 'is-error', state.message && !state.error && 'is-success')}">${escapeHtml(state.error || state.message || '')}</p>
</section>
<section class="fx-cards">
<article class="fx-card">
<p class="fx-kicker">Letzter Abruf</p>
<div class="fx-stat-value">${escapeHtml(latest ? fmtDate(latest.fetched_at) : 'Noch keiner')}</div>
<p class="fx-copy">${escapeHtml(latest ? `${latest.base_currency} · ${latest.provider} · ${latest.trigger_source_label || latest.trigger_source}` : 'Es wurden noch keine Kurse gespeichert.')}</p>
</article>
<article class="fx-card">
<p class="fx-kicker">Standard-Basis</p>
<div class="fx-stat-value">${escapeHtml(base)}</div>
<p class="fx-copy">Anzeige-Basis ${escapeHtml(String(settings.display_base_currency || base).toUpperCase())}</p>
</article>
<article class="fx-card">
<p class="fx-kicker">Bevorzugte Waehrungen</p>
<div class="fx-stat-value">${escapeHtml(String(preferred.length || 0))}</div>
<p class="fx-copy">${escapeHtml(preferred.join(', ') || 'Keine Auswahl')}</p>
</article>
</section>
<section class="fx-table-grid">
<section class="fx-panel">
<p class="fx-kicker">Umrechnung</p>
<h3>Direkter Kursvergleich</h3>
<form class="fx-form-grid" data-form="convert">
<div class="fx-field">
<label for="fx-amount">Betrag</label>
<input id="fx-amount" class="fx-input" type="number" name="amount" min="0" step="0.00000001" value="1">
</div>
<div class="fx-field">
<label for="fx-from">Von</label>
<select id="fx-from" class="fx-select" name="from">
${preferred.map((currency) => `<option value="${escapeHtml(currency)}">${escapeHtml(currency)}</option>`).join('')}
</select>
</div>
<div class="fx-field">
<label for="fx-to">Nach</label>
<select id="fx-to" class="fx-select" name="to">
${preferred.map((currency, index) => `<option value="${escapeHtml(currency)}" ${index === 1 ? 'selected' : ''}>${escapeHtml(currency)}</option>`).join('')}
</select>
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">Umrechnen</button>
</div>
</form>
<p class="fx-copy">${state.conversion
? `${fmtNumber(state.conversion.amount, 8)} ${state.conversion.from} = ${fmtNumber(state.conversion.converted, 8)} ${state.conversion.to} bei Kurs ${fmtNumber(state.conversion.rate, 8)}`
: 'Noch keine Umrechnung berechnet.'}</p>
</section>
<section class="fx-panel">
<p class="fx-kicker">Widget</p>
<h3>Desktop-Infobereich</h3>
<div class="fx-widget-note">
<p class="fx-copy">Das Widget kann im Setup unter Infobereich aktiviert werden und verwendet dieselbe Refresh-Regel wie die App und die API.</p>
<button class="fx-button fx-button--ghost" type="button" data-action="open-widget-setup">Setup oeffnen</button>
</div>
</section>
</section>
<section class="fx-panel">
<p class="fx-kicker">Verlauf</p>
<h3>Neueste Kursverlaeufe</h3>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<th>Quelle</th>
${historyMatrix.columns.map((column) => `<th>${escapeHtml(column)}</th>`).join('')}
</tr>
</thead>
<tbody>
${historyMatrix.rows.length === 0
? `<tr><td colspan="${String(2 + historyMatrix.columns.length)}">Noch keine Verlaufsdaten vorhanden.</td></tr>`
: historyMatrix.rows.map((row) => `
<tr>
<td>${escapeHtml(fmtDate(row.fetched_at))}</td>
<td>${escapeHtml(row.provider || 'n/a')}</td>
${historyMatrix.columns.map((column) => `<td>${escapeHtml(fmtNumber(row.values[column], 8))}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
</section>
<section class="fx-panel">
<p class="fx-kicker">Abrufe</p>
<h3>Letzte gespeicherte Snapshots</h3>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<th>Basis</th>
<th>Provider</th>
<th>Ausloeser</th>
</tr>
</thead>
<tbody>
${state.recentFetches.length === 0
? '<tr><td colspan="4">Noch keine Abrufe gespeichert.</td></tr>'
: state.recentFetches.map((fetch) => `
<tr>
<td>${escapeHtml(fmtDate(fetch.fetched_at))}</td>
<td>${escapeHtml(fetch.base_currency || 'n/a')}</td>
<td>${escapeHtml(fetch.provider || 'n/a')}</td>
<td>${escapeHtml(fetch.trigger_source_label || fetch.trigger_source || 'n/a')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</section>
</div>
`;
}
function renderCurrencies() {
const settings = currentSettings();
const catalog = Array.isArray(settings.currency_catalog) ? settings.currency_catalog : [];
return `
<div class="fx-stack">
<section class="window-app-card">
<p class="window-app-kicker">Katalog</p>
<h3>Bevorzugte Waehrungen</h3>
<p class="window-app-copy">Der Katalog wird aus dem konfigurierten Provider synchronisiert. Die Anzeige-Basis muss Teil der bevorzugten Waehrungen sein.</p>
<div class="fx-inline-actions">
<button class="fx-button" type="button" data-action="sync-catalog">${state.syncingCatalog ? 'Synchronisiert ...' : 'Waehrungskatalog synchronisieren'}</button>
</div>
<p class="fx-copy">Letzte Katalog-Synchronisierung: ${escapeHtml(settings.currency_catalog_synced_at ? fmtDate(settings.currency_catalog_synced_at) : 'noch nie')}</p>
</section>
<form class="fx-panel fx-stack" data-form="settings">
<div class="fx-form-grid">
<div class="fx-field">
<label for="fx-display-base">Anzeige-Basis</label>
<input id="fx-display-base" class="fx-input" type="text" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
</div>
<div class="fx-field">
<label for="fx-default-base">Standard-Basis</label>
<input id="fx-default-base" class="fx-input" type="text" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
</div>
<div class="fx-field">
<label for="fx-preferred">Bevorzugte Waehrungen</label>
<input id="fx-preferred" class="fx-input" type="text" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
</div>
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Waehrungen speichern'}</button>
</div>
</form>
<section class="fx-panel">
<p class="fx-kicker">Verfuegbarer Katalog</p>
<div class="fx-token-row">
${catalog.length === 0
? '<span class="fx-token">Noch kein Katalog geladen</span>'
: catalog.map((item) => `<span class="fx-token">${escapeHtml(item.code)} · ${escapeHtml(item.name)}</span>`).join('')}
</div>
</section>
</div>
`;
}
function renderSettings() {
const settings = currentSettings();
return `
<form class="fx-stack" data-form="settings">
<section class="window-app-card">
<p class="window-app-kicker">Provider</p>
<h3>Externe Quelle und Token</h3>
<div class="fx-form-grid">
<div class="fx-field">
<label for="fx-provider">Provider</label>
<input id="fx-provider" class="fx-input" type="text" name="provider" value="${escapeHtml(settings.provider || 'currencyapi')}">
</div>
<div class="fx-field">
<label for="fx-api-version">API Version</label>
<select id="fx-api-version" class="fx-select" name="api_version">
<option value="v2" ${settings.api_version === 'v2' ? 'selected' : ''}>v2</option>
<option value="v3" ${settings.api_version === 'v3' ? 'selected' : ''}>v3</option>
</select>
</div>
<div class="fx-field">
<label for="fx-api-url">API URL</label>
<input id="fx-api-url" class="fx-input" type="text" name="api_url" value="${escapeHtml(settings.api_url || '')}">
</div>
<div class="fx-field">
<label for="fx-currencies-url">Currencies URL</label>
<input id="fx-currencies-url" class="fx-input" type="text" name="currencies_url" value="${escapeHtml(settings.currencies_url || settings.api_url || '')}">
</div>
<div class="fx-field">
<label for="fx-api-key">API Key</label>
<input id="fx-api-key" class="fx-input" type="password" name="api_key" value="${escapeHtml(settings.api_key || '')}">
</div>
<div class="fx-field">
<label for="fx-timeout">Timeout (Sek.)</label>
<input id="fx-timeout" class="fx-input" type="number" name="timeout_sec" min="2" step="1" value="${escapeHtml(String(settings.timeout_sec || 10))}">
</div>
<div class="fx-field">
<label for="fx-refresh-hours">Refresh-Sperre (Std.)</label>
<input id="fx-refresh-hours" class="fx-input" type="number" name="refresh_max_age_hours" min="1" step="0.5" value="${escapeHtml(String(settings.refresh_max_age_hours || 6))}">
</div>
<div class="fx-field">
<label for="fx-timezone">Zeitzone</label>
<input id="fx-timezone" class="fx-input" type="text" name="schedule_timezone" value="${escapeHtml(settings.schedule_timezone || 'Europe/Berlin')}">
</div>
<input type="hidden" name="display_base_currency" value="${escapeHtml(settings.display_base_currency || settings.default_base_currency || 'EUR')}">
<input type="hidden" name="default_base_currency" value="${escapeHtml(settings.default_base_currency || 'EUR')}">
<input type="hidden" name="preferred_currencies" value="${escapeHtml((settings.preferred_currencies || []).join(', '))}">
</div>
<div class="fx-form-actions">
<button class="fx-button" type="submit">${state.saving ? 'Speichert ...' : 'Setup speichern'}</button>
</div>
<p class="fx-copy">Der importierte Token wird als Default uebernommen, kann hier aber projektweit geaendert werden.</p>
</section>
</form>
`;
}
function renderMain() {
const current = sectionMap.get(state.activeTab) || sections[0];
let content = '';
if (state.activeTab === 'currencies') {
content = renderCurrencies();
} else if (state.activeTab === 'settings') {
content = renderSettings();
} else {
content = renderOverview();
}
return `
<main class="window-app-main">
<section class="window-app-hero">
<div>
<p class="window-app-kicker">Waehrungs-Checker</p>
<h2 class="window-app-title">${escapeHtml(current.label)}</h2>
<p class="window-app-copy">${escapeHtml(current.summary)}</p>
</div>
<div class="window-app-pill-row">
<span class="window-app-pill">API aktiv</span>
<span class="window-app-pill">Widget optional</span>
<span class="window-app-pill">Provider ${escapeHtml(String((state.settings && state.settings.provider) || 'currencyapi'))}</span>
</div>
</section>
<div class="window-app-panel fx-shell">
${content}
</div>
</main>
`;
}
function bind(rootNode) {
rootNode.querySelectorAll('[data-action="tab"]').forEach((button) => {
button.addEventListener('click', () => {
setState({ activeTab: String(button.getAttribute('data-tab') || 'overview') });
});
});
rootNode.querySelectorAll('[data-action="refresh"]').forEach((button) => {
button.addEventListener('click', () => runRefresh(false));
});
rootNode.querySelectorAll('[data-action="refresh-force"]').forEach((button) => {
button.addEventListener('click', () => {
const confirmed = window.confirm('Der Abruf wird jetzt unabhaengig vom Alter der gespeicherten Kurse erzwungen. Wirklich fortfahren?');
if (confirmed) {
runRefresh(true);
}
});
});
rootNode.querySelectorAll('[data-action="sync-catalog"]').forEach((button) => {
button.addEventListener('click', () => syncCatalog());
});
rootNode.querySelectorAll('[data-action="open-widget-setup"]').forEach((button) => {
button.addEventListener('click', () => openWidgetSetup());
});
rootNode.querySelectorAll('[data-form="convert"]').forEach((form) => {
form.addEventListener('submit', runConversion);
});
rootNode.querySelectorAll('[data-form="settings"]').forEach((form) => {
form.addEventListener('submit', saveSettings);
});
}
function render() {
if (state.loading) {
root.innerHTML = '<div class="window-app-loading"><p class="window-app-copy">Waehrungsdaten werden geladen.</p></div>';
return;
}
if (state.error && !state.settings) {
root.innerHTML = `<div class="window-app-error-state"><p class="window-app-message is-error">${escapeHtml(state.error)}</p></div>`;
return;
}
root.innerHTML = `
<div class="window-app-shell">
<div class="window-app-frame">
${renderNav()}
${renderMain()}
</div>
</div>
`;
bind(root);
}
render();
loadBootstrap();
}
window.initFxRatesApp = function initFxRatesApp(rootNode, options) {
const root = rootNode || document.getElementById('fx-rates-app');
if (!root || root.dataset.moduleInitialized === '1') {
return;
}
root.dataset.moduleInitialized = '1';
createFxRatesApp(root, options && typeof options === 'object' ? options : {});
};
if (document.getElementById('fx-rates-app')) {
window.initFxRatesApp(document.getElementById('fx-rates-app'), {});
}
})();