lan_kiosk/resources/js/Pages/Swish.tsx
Anna-Sara Sélea df9f1cf17e Swish feature
2026-08-22 16:13:42 +02:00

291 lines
13 KiB
TypeScript

import { Head } from '@inertiajs/react';
import axios from 'axios';
import { FormEvent, useEffect, useRef, useState } from 'react';
type Step = 'lookup' | 'pay' | 'waiting' | 'done';
// Swish only reports the outcome to our server, so the page asks us — not Swish —
// how the payment went. Give up after a few minutes so it does not poll forever.
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
export default function Swish() {
const [step, setStep] = useState<Step>('lookup');
const [lanId, setLanId] = useState('');
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerName, setCustomerName] = useState('');
const [memberName, setMemberName] = useState<string | null>(null);
const [amount, setAmount] = useState('');
const [giveLeftover, setGiveLeftover] = useState(false);
const [reference, setReference] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const timers = useRef<number[]>([]);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Stop polling when the page goes away
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const reset = () => {
timers.current.forEach(clearTimeout);
timers.current = [];
setStep('lookup');
setLanId('');
setAmount('');
setGiveLeftover(false);
setMemberName(null);
setReference(null);
setStatus(null);
setError('');
};
// Server errors carry a message worth showing — a network failure does not
const messageFrom = (e: unknown, fallback: string) => {
if (axios.isAxiosError(e) && e.response?.status === 429) {
return 'För många försök. Vänta en minut och prova igen.';
}
return (axios.isAxiosError(e) && e.response?.data?.message) || fallback;
};
const handleLookup = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await axios.get('/api/swish/lookup', { params: { lan_id: lanId } });
setCustomerId(res.data.customer_id);
setCustomerName(res.data.name);
setMemberName(res.data.member_name ?? null);
setStep('pay');
} catch (e) {
setError(messageFrom(e, 'Hittade ingen deltagare med det LAN-id:t.'));
} finally {
setLoading(false);
}
};
const pollStatus = (ref: string, startedAt: number) => {
const timer = window.setTimeout(async () => {
try {
const res = await axios.get('/api/swish/status', { params: { reference: ref } });
const current = res.data.status;
if (current === 'PAID' || current === 'amount_mismatch') {
setStatus(current);
setStep('done');
return;
}
if (['DECLINED', 'ERROR', 'CANCELLED', 'failed'].includes(current)) {
setStatus(current);
setStep('done');
return;
}
} catch {
// A failed poll is not a failed payment — keep trying until the timeout
}
if (Date.now() - startedAt < POLL_TIMEOUT_MS) {
pollStatus(ref, startedAt);
} else {
setStatus('timeout');
setStep('done');
}
}, POLL_INTERVAL_MS);
timers.current.push(timer);
};
const handlePay = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await axios.post('/api/swish/initiate', {
customer_id: customerId,
amount: parseInt(amount, 10),
give_leftover: giveLeftover,
});
setReference(res.data.payment_reference);
setStep('waiting');
pollStatus(res.data.payment_reference, Date.now());
window.location.href = res.data.swish_url;
} catch (e) {
setError(messageFrom(e, 'Något gick fel. Försök igen.'));
} finally {
setLoading(false);
}
};
const outcome = () => {
if (status === 'PAID') {
return {
title: '✓ Betalningen är klar!',
text: `${amount} kr är inlagt på ${memberName ? `gruppen ${customerName}` : 'ditt'} saldo.`,
tone: 'has-text-success',
};
}
if (status === 'amount_mismatch') {
return {
title: 'Beloppet stämmer inte',
text: 'Betalningen kom fram men beloppet matchade inte. Prata med en funktionär.',
tone: 'has-text-danger',
};
}
if (status === 'timeout') {
return {
title: 'Vi väntar fortfarande',
text: 'Betalningen har inte bekräftats än. Kolla ditt saldo om en stund, eller prata med en funktionär.',
tone: 'has-text-grey',
};
}
return {
title: 'Betalningen gick inte igenom',
text: 'Inget har dragits. Du kan försöka igen.',
tone: 'has-text-danger',
};
};
return (
<>
<Head title="Betala in med Swish | vBytes LAN Kiosk" />
<section className="section">
<div className="container" style={{ maxWidth: 480 }}>
<h1 className="title is-3 has-text-centered mb-6">Betala in med Swish</h1>
{step === 'lookup' && (
<div className="box">
<p>Ange ditt LAN-id för att hitta ditt konto.</p>
<p className="mb-4"> Om du är med i en grupp kommer saldot skickas till din grupp.</p>
{!isMobile && (
<div className="notification is-warning is-light">
Betalningen öppnas i Swish-appen, den här sidan behöver köras i mobilen.
</div>
)}
<form onSubmit={handleLookup}>
<div className="field">
<label className="label">LAN-id</label>
<div className="control">
<input
className="input"
type="number"
min={1}
value={lanId}
onChange={e => setLanId(e.target.value)}
required
autoFocus
/>
</div>
</div>
{error && <p className="help is-danger mb-3">{error}</p>}
<button
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
type="submit"
>
Hitta mig
</button>
</form>
</div>
)}
{step === 'pay' && (
<div className="box">
<div className="notification is-success is-light mb-6">
<strong>{memberName ?? customerName}</strong> stämmer det?
{memberName && (
<p className="mt-1">Ingår i gruppen <strong>{customerName}</strong></p>
)}
</div>
<form onSubmit={handlePay}>
<div className="field">
<label className="label">Belopp (kr)</label>
<div className="control">
<input
className="input"
type="number"
min={1}
value={amount}
onChange={e => setAmount(e.target.value)}
required
autoFocus
/>
</div>
</div>
<div className="field mt-6 mb-6">
<label className="label">Ge överblivet saldo till vBytes?</label>
<div className="control">
<label className="radio mr-4">
<input
type="radio"
className="accent-blue-600 mr-1"
checked={giveLeftover === true}
onChange={() => setGiveLeftover(true)}
/>
Ja
</label>
<label className="radio">
<input
type="radio"
className="accent-blue-600 mr-1"
checked={giveLeftover === false}
onChange={() => setGiveLeftover(false)}
/>
Nej
</label>
</div>
</div>
{error && <p className="help is-danger mb-3">{error}</p>}
<div className="buttons mt-5">
<button
className={`button is-link is-fullwidth ${loading ? 'is-loading' : ''}`}
type="submit"
>
Betala med Swish
</button>
<button
className="button is-info is-outlined is-fullwidth"
type="button"
onClick={() => { setStep('lookup'); setMemberName(null); setError(''); }}
>
Fel person? tillbaka
</button>
</div>
</form>
</div>
)}
{step === 'waiting' && (
<div className="box has-text-centered">
<p className="is-size-4 mb-4">Väntar Swish</p>
<progress className="progress is-small is-link mb-5" max="100" />
<p className="has-text-grey mb-2">
Godkänn betalningen i Swish-appen. Den här sidan uppdaterar sig själv.
</p>
{reference && (
<p className="is-size-7 has-text-grey-light">Referens: {reference}</p>
)}
</div>
)}
{step === 'done' && (
<div className="box has-text-centered">
<p className={`is-size-4 mb-4 ${outcome().tone}`}>{outcome().title}</p>
<p className="has-text-grey mb-5">{outcome().text}</p>
{reference && (
<p className="is-size-7 has-text-grey-light mb-4">Referens: {reference}</p>
)}
<button className="button is-light" onClick={reset}>
Gör en ny betalning
</button>
</div>
)}
</div>
</section>
</>
);
}