feat: automatické sledování doručení objednávky přes Bolt Food
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 43s
CI / Notify (push) Successful in 2s

Zakladatel skupiny může na stránce objednání vložit sdílecí odkaz
Bolt Food. Server pak každou minutu dotazuje veřejné Bolt API
a automaticky aktualizuje čas doručení skupiny (deliveryAt).
Sledování se samo ukončí po doručení, zrušení objednávky nebo
opakovaných chybách. Leader lease vytažena do znovupoužitelného
modulu leaderLease.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:22:07 +02:00
parent 1df21edc1a
commit 491ec25b52
12 changed files with 559 additions and 52 deletions
+48 -5
View File
@@ -8,7 +8,7 @@ import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
} from '../../../types';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
@@ -26,6 +26,22 @@ import PendingPayments from '../components/PendingPayments';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
try {
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
} catch {
return null;
}
}
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
registerLocale('cs', cs);
@@ -72,7 +88,7 @@ export default function OrderGroupsPage() {
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
@@ -236,7 +252,7 @@ export default function OrderGroupsPage() {
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
const { orderedAt, deliveryAt } = times;
const { orderedAt, deliveryAt, boltUrl } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM');
return;
@@ -245,7 +261,17 @@ export default function OrderGroupsPage() {
setPageError('Čas doručení musí být ve formátu HH:MM');
return;
}
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
if (boltUrl.trim() && !boltToken) {
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
return;
}
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok && boltChanged) {
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
}
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
@@ -667,6 +693,18 @@ export default function OrderGroupsPage() {
style={{ width: 75 }}
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Bolt odkaz:</small>
<Form.Control
type="text"
size="sm"
placeholder={`${BOLT_SHARE_URL_PREFIX}`}
value={editTimes[group.id]?.boltUrl ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 260 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
@@ -674,7 +712,7 @@ export default function OrderGroupsPage() {
<div
className="d-flex align-items-center gap-3 flex-wrap"
style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }))}
title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
>
<small className="text-muted">
@@ -682,6 +720,11 @@ export default function OrderGroupsPage() {
</small>
<small className="text-muted">
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
{group.boltTrackingToken && (
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
<Badge bg="success" className="ms-1">Bolt</Badge>
</OverlayTrigger>
)}
</small>
</div>
)}