/* Transactions, Events, Machine settings, Bottles, Users */
/* Henter ekte transaksjoner fra backend; støtter dato-range + filter. */
const Transactions = ({ machine, lang }) => {
const t = I18N[lang];
const [period, setPeriod] = React.useState('10'); // '10' | '7' | '30' | '90' | 'custom' — default siste 10
const [from, setFrom] = React.useState(() => isoDate(daysAgo(30)));
const [to, setTo] = React.useState(() => isoDate(new Date()));
const [filter, setFilter] = React.useState('all');
const [rows, setRows] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [openTx, setOpenTx] = React.useState(null); // valgt transaksjon for detalj-drawer
const load = React.useCallback(async () => {
setLoading(true); setError(null);
try {
const params = { automat_id: machine.id, limit: 500 };
if (period === '10') { params.limit = 10; params.days = 365; }
else if (period === '7') params.days = 7;
else if (period === '30') params.days = 30;
else if (period === '90') params.days = 90;
else if (period === 'custom') { params.date_from = from; params.date_to = to; }
const r = await window.api.listOrders(params);
setRows(r || []);
} catch (e) { setError(e.message); }
setLoading(false);
}, [machine.id, period, from, to]);
React.useEffect(() => { load(); }, [load]);
const filtered = rows.filter(x => filter === 'all' || x.kind === filter);
// Sum: KUN fullførte ordrer med positiv beløp (ekskluderer feilet)
const successful = filtered.filter(x => x.session_state === 'complete' && (x.price_ore || 0) > 0);
const sum = successful.reduce((s, x) => s + (x.price_ore || 0), 0);
const failedCount = filtered.filter(x => orderStatusKey(x) === 'feil').length;
const swapCount = rows.filter(x => x.kind === 'exchange' && x.session_state === 'complete').length;
const swapShare = rows.length ? Math.round(swapCount / rows.filter(x => x.session_state === 'complete').length * 100) || 0 : 0;
const exportCsv = () => {
const lines = [
['Tid','Type','Flaske inn','Flaske ut','Beløp (kr)','Status','Mobil','Feil'].join(';'),
...filtered.map(o => [
o.created_at?.slice(0, 19) || '',
o.kind, o.bottle_in || '', o.bottle_out || '',
((o.price_ore || 0) / 100).toFixed(2),
orderStatusKey(o), o.mobile || '', o.error || '',
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(';')),
].join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `transaksjoner-${machine.id}-${period}.csv`;
a.click();
};
return (
{t.tab_transactions}
{filtered.length}/{rows.length}
{lang === 'no' ? 'Sum (fullført)' : 'Sum (success)'}
{fmtKr(sum / 100, lang)}
{lang === 'no' ? 'Vellykket' : 'OK'}
{successful.length}/{filtered.length}
{failedCount > 0 && (
{lang === 'no' ? 'Feilet' : 'Failed'}
{failedCount}
)}
{lang === 'no' ? 'Bytte-andel' : 'Swap %'}
{swapShare}%
{/* periode + filter */}
setPeriod('10')}>Siste 10
setPeriod('7')}>7 d
setPeriod('30')}>30 d
setPeriod('90')}>90 d
setPeriod('custom')}>Egendefinert
{period === 'custom' && (
<>
setFrom(e.target.value)} />
til
setTo(e.target.value)} />
>
)}
setFilter('all')}>{lang === 'no' ? 'Alle' : 'All'}
setFilter('exchange')}>{t.swap}
setFilter('buy')}>{t.buy}
{loading ? 'Laster…' : 'Oppdater'}
CSV
{error && (
❌ {error}
)}
{loading && rows.length === 0 ? (
⏳ Laster transaksjoner…
) : filtered.length === 0 ? (
Ingen transaksjoner i valgt periode
) : (
{t.tx_time}
{t.tx_type}
{lang === 'no' ? 'Inn (tom)' : 'In (empty)'}
{lang === 'no' ? 'Ut (full)' : 'Out (full)'}
Status
{lang === 'no' ? 'Mobil' : 'Mobile'}
{t.tx_amount}
{filtered.map(o => (
setOpenTx(o)} style={{ cursor: 'pointer' }}>
{formatTs(o.created_at)}
{o.kind === 'exchange'
? {t.swap}
: {t.buy} }
{o.bottle_in || — }
{o.bottle_out}
{stateBadge(o)}
{o.mobile || '—'}
{(o.price_ore || 0) > 0
? fmtKr((o.price_ore || 0) / 100, lang)
: — }
))}
)}
{openTx &&
setOpenTx(null)} />}
);
};
/* Liten thumbnail i tabellrad — lazy-loadet via IntersectionObserver,
* bruker backend thumbnail-endepunkt (~5-15 KB i stedet for 500 KB).
* v2-ordrer: bilde via /api/admin/orders/{id}/image/*; legacy: Firestore-logg. */
const TxThumb = ({ machineId, order }) => {
const legacyFname = order.out_image || order.in_image;
const v2Url = order.out_image_url || order.in_image_url;
const thumbUrl = v2Url
? `${v2Url}?size=96`
: (legacyFname ? window.api.legacyLogImageThumbUrl(machineId, legacyFname, 96) : null);
const [src, setSrc] = React.useState(null);
const [visible, setVisible] = React.useState(false);
const ref = React.useRef(null);
// Vis først når raden er innenfor viewport
React.useEffect(() => {
if (!ref.current || !thumbUrl) return;
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
setVisible(true);
obs.disconnect();
}
});
}, { rootMargin: '200px' });
obs.observe(ref.current);
return () => obs.disconnect();
}, [thumbUrl]);
React.useEffect(() => {
if (!visible || !thumbUrl) return;
let cancelled = false;
window.authedImageUrl(thumbUrl)
.then(url => { if (!cancelled) setSrc(url); })
.catch(() => { if (!cancelled) setSrc(null); });
return () => { cancelled = true; };
}, [thumbUrl, visible]);
if (!thumbUrl) {
return
;
}
return (
{src &&
}
);
};
/* Full transaksjons-detalj med begge bilder + alle felter + tidslinje */
const TransactionDetail = ({ order, machine, lang, onClose, onRefunded }) => {
const t = I18N[lang];
const [refunds, setRefunds] = React.useState([]);
const [showRefund, setShowRefund] = React.useState(false);
const [refundBusy, setRefundBusy] = React.useState(false);
const [refundError, setRefundError] = React.useState(null);
const [timeline, setTimeline] = React.useState(null);
const isLegacy = machine.kind === 'legacy';
// Sjåfør/operatør (tenant_worker/operator) skal ikke se «Refunder» — backend
// krever tenant_admin uansett (2026-09-05). Tenant utledes fra ordren,
// automaten eller DATA.machines (tenant-transaksjonslista har bare id/navn).
const tenantIdForOrder = order.tenant_id || machine.tenantId
|| (((window.DATA && DATA.machines) || []).find(m => m.id === (order.automat_id || machine.id)) || {}).tenantId;
const roleHere = window.roleForTenant ? window.roleForTenant(tenantIdForOrder) : null;
const isWorker = roleHere === 'worker';
React.useEffect(() => {
if (!order.order_id) return;
window.api.listRefunds(order.order_id).then(setRefunds).catch(() => setRefunds([]));
// Full tidslinje (kun v2-ordrer i vår DB) — alle steg kunden gikk gjennom
if (!isLegacy) {
window.api.orderTimeline(order.order_id)
.then(setTimeline).catch(() => setTimeline(null));
}
}, [order.order_id]);
const totalPriceOre = order.price_ore || 0;
const alreadyRefundedOre = order.refunded_amount_ore
|| refunds.reduce((s, r) => s + (r.amount_ore || 0), 0);
const remainingOre = totalPriceOre - alreadyRefundedOre;
const canRefund = order.payment_method === 'stripe'
&& order.stripe_payment_intent_id
&& order.captured_at
&& remainingOre > 0
&& !isWorker;
return (
<>
{lang === 'no' ? 'Transaksjon' : 'Transaction'}
{order.order_id || '—'}
{/* Status-banner — skiller fullført / kreditert / avbrutt / feil */}
{(() => {
const key = orderStatusKey(order);
const tone = key === 'fullført' ? 'var(--success)'
: (key === 'kreditert' || key === 'delvis-kreditert') ? 'var(--warn)'
: key === 'avbrutt' ? 'var(--text-dim)'
: 'var(--danger)';
const bg = key === 'fullført' ? 'var(--success-soft)'
: (key === 'kreditert' || key === 'delvis-kreditert') ? 'var(--warn-soft)'
: key === 'avbrutt' ? 'var(--surface-2)'
: 'var(--danger-soft)';
const kindTxt = order.kind === 'exchange'
? (lang === 'no' ? 'bytte' : 'swap') : (lang === 'no' ? 'kjøp' : 'buy');
return (
{key === 'fullført' && <>✓ Fullført {kindTxt}>}
{key === 'kreditert' && <>↩ Kreditert — {kindTxt}, beløpet er refundert kunden>}
{key === 'delvis-kreditert' && <>↩ Delvis kreditert — {fmtKr((order.refunded_amount_ore || 0) / 100, lang)} refundert>}
{key === 'avbrutt' && <>⊘ Avbrutt — {order.error || (lang === 'no' ? 'handelen ble avbrutt' : 'trade cancelled')}>}
{key !== 'fullført' && key !== 'kreditert' && key !== 'delvis-kreditert' && key !== 'avbrutt' && (
<>❌ Feilet: {order.error || order.session_state}>
)}
);
})()}
{/* Bilder side-om-side: flaske levert INN og flaske levert UT
(auto-fit: stables til 1 kolonne på smal skjerm) */}
{/* Beskrivelse */}
{order.description && (
{lang === 'no' ? 'Beskrivelse fra v1' : 'Description from v1'}
{order.description}
)}
{/* Kvittering-handlinger (kun v2-ordrer fra DB). PDF-en krever
auth-header — vanlig
ga 401/blank side, derfor hentes
den som blob og åpnes i ny fane (fikset 2026-09-04). */}
{order.session_state === 'complete' && order.payment_method && (
{
try { await window.api.openReceiptPdf(order.order_id); }
catch (e) { alert((lang === 'no' ? 'Kvittering: ' : 'Receipt: ') + e.message); }
}}>
{lang === 'no' ? 'Vis kvittering (PDF)' : 'Open receipt (PDF)'}
{
try { await window.api.regenerateReceipt(order.order_id);
alert(lang === 'no' ? 'Regenerert' : 'Regenerated'); }
catch (e) { alert(e.message); }
}}>
{lang === 'no' ? 'Regenerer' : 'Regenerate'}
{order.receipt_number && (
{lang === 'no' ? 'Kvittering-nr' : 'Receipt no'}: {order.receipt_number}
)}
)}
{/* Metadata-grid (mobil: .kvp-grid -> 1 kolonne via styles.css) */}
0 ? fmtKr((order.price_ore || 0) / 100, lang) : '—'} />
{order.stripe_payment_intent_id && (
)}
{order.authorized_at && (
)}
{order.captured_at && (
)}
{(order.door_in ?? order.in_index) != null && }
{(order.door_out ?? order.out_index) != null && }
{(order.deposit_ore || 0) > 0 && }
{order.canceled_at && }
{/* Refund-seksjon — kun for Stripe-betalte ordrer */}
{(order.payment_method === 'stripe' || refunds.length > 0 || alreadyRefundedOre > 0) && (
{lang === 'no' ? 'Refusjon' : 'Refund'}
{alreadyRefundedOre > 0
? `${fmtKr(alreadyRefundedOre / 100, lang)} ${lang === 'no' ? 'refundert' : 'refunded'}`
: (lang === 'no' ? 'Ingen refusjon' : 'No refund')}
{(order.credit_note_numbers || []).length > 0 && (
{lang === 'no' ? 'Kreditnota' : 'Credit note'}: {order.credit_note_numbers.join(', ')}
)}
{remainingOre > 0 && totalPriceOre > 0 && (
({fmtKr(remainingOre / 100, lang)} {lang === 'no' ? 'gjenstår' : 'remaining'})
)}
{canRefund && (
setShowRefund(true)}>
{lang === 'no' ? 'Refunder' : 'Refund'}
)}
{refunds.length > 0 && (
)}
{refundError && (
❌ {refundError}
)}
)}
{/* Tidslinje: alle steg i handelen (system + kundens app-hendelser
+ penge-milepæler), tidsstemplet og sortert */}
{timeline && timeline.items && timeline.items.length > 0 && (
{lang === 'no' ? 'Tidslinje' : 'Timeline'}
{timeline.items.map((ev, i) => (
0 ? '1px solid var(--border)' : 'none',
}}>
{formatTs(ev.ts)}
{ev.source}
{ev.label}
{ev.detail && (
{ev.detail}
)}
))}
)}
{showRefund && (
setShowRefund(false)}
busy={refundBusy} setBusy={setRefundBusy}
onDone={async (result) => {
setShowRefund(false);
setRefundError(null);
// Refresh refunds-listen
const fresh = await window.api.listRefunds(order.order_id).catch(() => []);
setRefunds(fresh);
if (onRefunded) onRefunded(result);
}}
onError={(e) => setRefundError(e)} />
)}
>
);
};
/* Refund-modal: full eller partiell */
const RefundModal = ({ order, remainingOre, lang, onClose, busy, setBusy, onDone, onError }) => {
const [amountKr, setAmountKr] = React.useState((remainingOre / 100).toFixed(2));
const [reason, setReason] = React.useState('requested_by_customer');
const [customReason, setCustomReason] = React.useState('');
const submit = async () => {
setBusy(true);
try {
const amount_ore = Math.round(parseFloat(amountKr) * 100);
if (!(amount_ore > 0) || amount_ore > remainingOre) {
onError(`Beløp må være mellom 1 øre og ${remainingOre} øre`);
setBusy(false);
return;
}
const finalReason = reason === 'custom' ? customReason : reason;
const result = await window.api.refundOrder(order.order_id, {
amount_ore, reason: finalReason || null,
});
onDone(result);
} catch (e) {
onError(e.message);
}
setBusy(false);
};
return (
<>
{lang === 'no' ? 'Refunder betaling' : 'Refund payment'}
{lang === 'no' ? 'Avbryt' : 'Cancel'}
{busy ? '⏳…' : (lang === 'no' ? 'Bekreft refusjon' : 'Confirm refund')}
>
);
};
/* Bilde med label + ML-score. `url` (v2-ordrebilde) vinner over legacy
`filename` (Firestore-logg). */
const TxImage = ({ label, machineId, filename, url, bottleName, score }) => {
const [src, setSrc] = React.useState(null);
const [err, setErr] = React.useState(null);
const fetchUrl = url || (filename ? window.api.legacyLogImageUrl(machineId, filename) : null);
React.useEffect(() => {
if (!fetchUrl) { setSrc(null); return; }
let cancelled = false;
window.authedImageUrl(fetchUrl)
.then(u => { if (!cancelled) setSrc(u); })
.catch(e => { if (!cancelled) setErr(e.message); });
return () => { cancelled = true; };
}, [fetchUrl]);
return (
{label}
{src ? (
) : err ? (
❌ {err}
) : fetchUrl ? (
⏳ Laster…
) : (
Ingen bilde registrert
)}
{bottleName || '—'}
{/* ML-konfidens er plattforminfo — kun superadmin-visning */}
{score != null && score > 0 && window.isSuperView && window.isSuperView() && (
ML: {Math.round(score * 100)}%
)}
);
};
function isoDate(d) {
return d.toISOString().slice(0, 10);
}
function daysAgo(n) {
const d = new Date();
d.setDate(d.getDate() - n);
return d;
}
function formatTs(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('no-NO', { dateStyle: 'short', timeStyle: 'short' });
}
/* Status for en ordre — tar hensyn til kreditering (refusjon), avbrudd og
feil, ikke bare session_state. «kreditert» vinner over «fullført». */
function orderStatusKey(o) {
const total = (o.captured_amount_ore != null ? o.captured_amount_ore : o.price_ore) || 0;
const refunded = o.refunded_amount_ore || 0;
if (refunded > 0) return refunded >= total ? 'kreditert' : 'delvis-kreditert';
const err = (o.error || '').toLowerCase();
const cancelled = err.includes('cancel') || err.includes('abort')
|| err.includes('avbrutt') || err.includes('avbrot');
if (o.session_state === 'complete') return 'fullført';
if (cancelled) return 'avbrutt';
if (o.session_state === 'error' || o.error) return 'feil';
if (o.session_state === 'refunded') return 'kreditert';
return o.session_state || '—';
}
function stateBadge(o) {
const key = orderStatusKey(o);
if (key === 'kreditert') return kreditert ;
if (key === 'delvis-kreditert') return delvis kreditert ;
if (key === 'fullført') return fullført ;
if (key === 'avbrutt') return avbrutt ;
if (key === 'feil') return feil ;
return {key} ;
}
/* Hendelser for ÉN automat — EKTE varsler/hendelser fra backend (2026-09-04;
før: buildEvents() → tom liste, så fanen var alltid tom og eier så aldri
avbrutte handler, betalingsfeil osv.). */
const EVENT_KIND_LABELS = {
handel_avbrutt: 'Handel avbrutt', betaling_ikke_aktivert: 'Betaling ikke aktivert',
automat_opptatt: 'Automat opptatt', bytte_avvist: 'Bytte avvist',
valg_avvist: 'Flaskevalg avvist', open_door: 'Dør åpen', gas: 'Gassalarm',
low_stock: 'Lavt lager', no_stock: 'Tomt for type', cam_capture_feil: 'Kamerafeil',
retur_flaske_igjen: 'Returflaske gjenglemt', service_due: 'Service forfaller',
};
const Events = ({ machine, lang }) => {
const t = I18N[lang];
const [rows, setRows] = React.useState(null);
const [feil, setFeil] = React.useState(null);
const last = React.useCallback(() => {
window.api.recentAlarms(machine.id, 200)
.then(r => setRows(r || [])).catch(e => setFeil(e.message));
}, [machine.id]);
React.useEffect(() => { last(); }, [last]);
const evs = (rows || []).map(r => ({
id: 'EV-' + r.id,
ts: { text: new Date(r.sent_at).toLocaleTimeString('no-NO', { timeStyle: 'short' }),
date: new Date(r.sent_at).toLocaleDateString('no-NO') },
sev: (r.severity === 'critical' || r.severity === 'high') ? 'danger'
: r.severity === 'medium' ? 'warn' : 'info',
kind: EVENT_KIND_LABELS[r.kind] || r.kind,
msg_no: r.message, msg_en: r.message,
shelf: (r.message || '').match(/d[øo]r (\d+)/i)?.[1] || null,
camera: (r.message || '').match(/Kamera (\d+)/i)?.[1] || null,
recipient: r.recipient,
}));
const [sev, setSev] = React.useState('all');
const filtered = evs.filter(e => sev === 'all' || e.sev === sev);
return (
{t.tab_events}
{filtered.length}/{evs.length}
setSev('all')}>{lang === 'no' ? 'Alle' : 'All'}
setSev('danger')}>{t.sev_danger}
setSev('warn')}>{t.sev_warn}
setSev('info')}>{t.sev_info}
{lang === 'no' ? 'Oppdater' : 'Refresh'}
{feil &&
❌ {feil}
}
{rows === null && !feil &&
⏳ …
}
{rows !== null && filtered.length === 0 && (
{lang === 'no' ? 'Ingen hendelser registrert på denne automaten' : 'No events for this automat'}
)}
{filtered.length > 0 && (
{t.ev_time}
{t.ev_sev}
{lang === 'no' ? 'Type' : 'Kind'}
{t.ev_msg}
{t.ev_shelf}
{t.ev_camera}
{filtered.map(e => (
{e.sev === 'danger' && }
{e.sev === 'warn' && }
{e.sev === 'info' && }
{e.ts.text}{e.ts.date}
{e.sev === 'danger' && {t.sev_danger} }
{e.sev === 'warn' && {t.sev_warn} }
{e.sev === 'info' && {t.sev_info} }
{e.kind}
{lang === 'no' ? e.msg_no : e.msg_en}
{e.shelf || — }
{e.camera ? `CAM-${e.camera}` : — }
))}
)}
);
};
const MachineSettings = ({ machine, lang }) => {
// EKTE innstillinger (mock-revisjon 2026-09-04): togglene leser/skriver
// automatens config via settings-API-et; vedlikehold viser reell service-
// historikk. 2026-09-04 (runde 2): flasketype-sortiment, betalings-
// løsninger, tekniker-register + «Planlegg besøk» — alt ekte data.
const t = I18N[lang];
const no = lang === 'no';
const isSuper = !!(window.isSuperView && window.isSuperView());
const [cfg, setCfg] = React.useState(null);
const [service, setService] = React.useState(null);
const [feil, setFeil] = React.useState(null);
// Redigerbart navn/plassering (ID kan IKKE endres — kameraer, MQTT-emner og
// firmware er bundet til den; se forklaring i UI-et)
const [navn, setNavn] = React.useState(machine.location || '');
const [navnBusy, setNavnBusy] = React.useState(false);
const lagreNavn = async () => {
const ny = navn.trim();
if (!ny || ny === machine.location) return;
setNavnBusy(true);
try {
await window.api.updateAutomat(machine.id, { location: ny });
if (machine._onReload) await machine._onReload();
} catch (e) { alert((no ? 'Lagring feilet: ' : 'Save failed: ') + e.message); }
setNavnBusy(false);
};
const lastAll = React.useCallback(() => {
window.api.getSettings(machine.id).then(setCfg)
.catch(e => setFeil(e.message));
window.api.serviceHistory(machine.id).then(setService).catch(() => {});
}, [machine.id]);
React.useEffect(() => { lastAll(); }, [lastAll]);
const lagre = async (key, value) => {
try {
const ny = await window.api.saveSettings(machine.id, { [key]: value });
setCfg(ny);
} catch (e) { alert('Lagring feilet: ' + e.message); lastAll(); }
};
const sisteService = service?.entries?.[0];
return (
{no ? 'Generelt' : 'General'}
{no ? 'Identifikatoren kan ikke endres: kameraene, MQTT-emnene og firmwaren på automaten er bundet til den. Navnet kan du endre fritt.'
: 'The identifier is fixed (cameras, MQTT topics and firmware are bound to it); the name can be changed freely.'}
{no ? 'Drift' : 'Operations'}
{feil &&
{feil}
}
{!cfg && !feil &&
{no ? 'Laster…' : 'Loading…'}
}
{cfg && (<>
lagre('gas_alarm_enabled', v)} />
lagre('check_open_doors', v)} />
lagre('low_stock_sms_enabled', v)} />
{/* 2026-09-05: bryterne over konsumeres nå av backend (gass-SMS,
dør-åpen-alarm, lav-lager-SMS). Feltene under er redigerbare —
før var terskel og admin-mobiler kun visning. */}
lagre('cameras_power_off_after_sale', v)} />
lagre('predict_accept_limit', v)} />
lagre('door_open_alarm_min', v)} />
lagre('admin_mobiles', v)} />
>)}
{no ? 'Farlig sone' : 'Danger zone'}
{
// Leser EKTE tilstand (maintenanceMode fra backend) — før leste
// knappen machine._maint som aldri ble initialisert, så den kunne
// bare slå PÅ (2026-09-05).
const paa = !machine.maintenanceMode;
if (!confirm(no
? `${paa ? 'Sette' : 'Ta'} automaten ${paa ? 'i' : 'ut av'} vedlikeholdsmodus? Kunder ${paa ? 'blokkeres' : 'slippes til igjen'}.`
: 'Toggle maintenance mode?')) return;
try {
await window.api.updateAutomat(machine.id, { maintenance_mode: paa });
if (machine._onReload) await machine._onReload();
else alert(no ? (paa ? 'Vedlikeholdsmodus PÅ' : 'Vedlikeholdsmodus AV') : 'OK');
} catch (e) { alert(e.message); }
}}>
{machine.maintenanceMode
? (no ? 'Avslutt vedlikeholdsmodus' : 'End maintenance mode')
: (no ? 'Sett i vedlikeholdsmodus' : 'Enter maintenance mode')}
);
};
/* Salgslenke + QR-kode for automaten: tenanten kan kopiere lenka eller
skrive ut en A4-plakat med QR som tar kunden rett til salgssiden
(/app/?a=). QR genereres lokalt (qrcode-generator, lastet i
index.html) — faller pent tilbake til bare lenke om biblioteket mangler. */
const SalesLinkPanel = ({ machine, lang }) => {
const no = lang === 'no';
const url = `${location.origin}/app/?a=${encodeURIComponent(machine.id)}`;
const qrRef = React.useRef(null);
const [qrSvg, setQrSvg] = React.useState(null);
const [kopiert, setKopiert] = React.useState(false);
React.useEffect(() => {
if (typeof window.qrcode !== 'function') { setQrSvg(null); return; }
try {
const qr = window.qrcode(0, 'M'); // 0 = auto-versjon, M = feilkorreksjon
qr.addData(url);
qr.make();
setQrSvg(qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true }));
} catch (e) { setQrSvg(null); }
}, [url]);
const kopier = async () => {
try {
await navigator.clipboard.writeText(url);
setKopiert(true);
setTimeout(() => setKopiert(false), 2000);
} catch (e) {
// Clipboard-API krever https/fokus — fall tilbake til prompt
window.prompt(no ? 'Kopier lenka:' : 'Copy the link:', url);
}
};
// HTML-escape (H17, 2026-09-05): automatnavnet er tenant-styrt tekst og gikk
// rett inn i document.write → lagret XSS med admin-origin (token-tyveri).
const escHtmlForPrint = (s) => String(s ?? '').replace(/[&<>"']/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const skrivUt = () => {
const tittel = escHtmlForPrint(machine.location || machine.name || machine.id);
const html = `
QR · ${tittel}
${tittel}
${no ? 'Kjøp og bytt gassflasker her — hele døgnet' : 'Buy and swap gas bottles here — 24/7'}
${qrSvg || ''}
${escHtmlForPrint(url)}
${no ? 'Skann QR-koden med mobilkameraet for å starte' : 'Scan the QR code with your phone camera'}