mirror of
https://github.com/anna-sara/lan_kiosk
synced 2026-09-03 10:35:01 +02:00
291 lines
13 KiB
TypeScript
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, så 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? Gå tillbaka
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{step === 'waiting' && (
|
|
<div className="box has-text-centered">
|
|
<p className="is-size-4 mb-4">Väntar på 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>
|
|
</>
|
|
);
|
|
}
|