feat: zobrazení stavu objednávky Bolt Food jako progress stepper
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Successful in 1m22s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Successful in 1m22s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s
Jak to funguje: - OrderGroup má nové pole boltOrderState (raw order_state z Bolt API). Polling scheduler ho ukládá při každé změně a rozesílá přes Socket.io, takže stepper se posouvá živě všem uživatelům. - Komponenta BoltOrderProgress vykresluje čtyři kroky (Přijato → Příprava → Na cestě → Doručeno) pod časy skupiny. Známé stavy se mapují explicitně, neznámé heuristikou podle klíčových slov, zrušená objednávka se zobrazí červeně. Tooltip ukazuje raw stav, aktivní krok pulzuje, dokud sledování běží. - Po doručení (nebo zmizení objednávky z API) se token smaže, ale boltOrderState zůstává "delivered" — dokončený stepper je vidět po zbytek dne. Vynuluje se při změně/zrušení odkazu nebo návratu skupiny do stavu uzamčeno. - Nastavení odkazu nově spustí okamžitý poll, stepper se tak objeví do vteřiny místo čekání na další tik scheduleru. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
.bolt-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
max-width: 400px;
|
||||||
|
|
||||||
|
.bolt-step {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
|
||||||
|
.bolt-dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--luncher-border, #ced4da);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bolt-label {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--luncher-text-muted, #6c757d);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spojnice k předchozímu kroku
|
||||||
|
&:not(:first-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--luncher-border, #ced4da);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done {
|
||||||
|
.bolt-dot {
|
||||||
|
background: var(--bs-success, #198754);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:first-child)::before {
|
||||||
|
background: var(--bs-success, #198754);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .bolt-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulzování aktivního kroku, dokud sledování běží
|
||||||
|
&.live .bolt-step.active .bolt-dot {
|
||||||
|
animation: bolt-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bolt-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||||
|
import './BoltOrderProgress.scss';
|
||||||
|
|
||||||
|
const STEPS = ['Přijato', 'Příprava', 'Na cestě', 'Doručeno'];
|
||||||
|
|
||||||
|
/** Známé stavy objednávky z Bolt API → index kroku ve stepperu. */
|
||||||
|
const STATE_TO_STEP: Record<string, number> = {
|
||||||
|
created: 0,
|
||||||
|
pending: 0,
|
||||||
|
accepted: 0,
|
||||||
|
waiting_preparation: 0,
|
||||||
|
preparing: 1,
|
||||||
|
ready_for_pickup: 1,
|
||||||
|
waiting_courier: 1,
|
||||||
|
courier_assigned: 1,
|
||||||
|
picked_up: 2,
|
||||||
|
in_delivery: 2,
|
||||||
|
delivering: 2,
|
||||||
|
heading_to_client: 2,
|
||||||
|
delivered: 3,
|
||||||
|
finished: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||||
|
function stepForState(state: string): number | 'cancelled' {
|
||||||
|
const s = state.toLowerCase();
|
||||||
|
if (s in STATE_TO_STEP) return STATE_TO_STEP[s];
|
||||||
|
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||||
|
if (/delivered|finished/.test(s)) return 3;
|
||||||
|
if (/courier|picked|delivery|transport/.test(s)) return 2;
|
||||||
|
if (/prepar|ready|cook/.test(s)) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Raw order_state z Bolt API (např. waiting_preparation) */
|
||||||
|
state: string;
|
||||||
|
/** Zda sledování stále běží (skupina má boltTrackingToken) */
|
||||||
|
tracking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini progress stepper se stavem objednávky Bolt Food. */
|
||||||
|
export default function BoltOrderProgress({ state, tracking }: Props) {
|
||||||
|
const step = stepForState(state);
|
||||||
|
if (step === 'cancelled') {
|
||||||
|
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {state}</Tooltip>}>
|
||||||
|
<div className={`bolt-progress${tracking && step < 3 ? ' live' : ''}`}>
|
||||||
|
{STEPS.map((label, i) => (
|
||||||
|
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||||
|
<div className="bolt-dot" />
|
||||||
|
<div className="bolt-label">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import StoreAdminModal from '../components/modals/StoreAdminModal';
|
|||||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||||
import PendingPayments from '../components/PendingPayments';
|
import PendingPayments from '../components/PendingPayments';
|
||||||
|
import BoltOrderProgress from '../components/BoltOrderProgress';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
@@ -797,6 +798,11 @@ export default function OrderGroupsPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{group.boltOrderState && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<BoltOrderProgress state={group.boltOrderState} tracking={!!group.boltTrackingToken} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|||||||
@@ -102,16 +102,24 @@ export async function checkBoltTracking(): Promise<void> {
|
|||||||
|
|
||||||
for (const group of candidates) {
|
for (const group of candidates) {
|
||||||
let deliveryAt: string | undefined;
|
let deliveryAt: string | undefined;
|
||||||
|
let orderState: string | undefined;
|
||||||
let clearToken = false;
|
let clearToken = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const order = await pollBoltOrder(group.boltTrackingToken!);
|
const order = await pollBoltOrder(group.boltTrackingToken!);
|
||||||
consecutiveFailures.delete(group.id);
|
consecutiveFailures.delete(group.id);
|
||||||
if (!order || TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
|
if (!order) {
|
||||||
|
// Objednávka z API zmizela — považujeme ji za doručenou
|
||||||
|
orderState = 'delivered';
|
||||||
|
clearToken = true;
|
||||||
|
} else {
|
||||||
|
orderState = order.order_state || undefined;
|
||||||
|
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
|
||||||
clearToken = true;
|
clearToken = true;
|
||||||
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
|
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
|
||||||
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
|
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
|
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
|
||||||
consecutiveFailures.set(group.id, failures);
|
consecutiveFailures.set(group.id, failures);
|
||||||
@@ -121,17 +129,17 @@ export async function checkBoltTracking(): Promise<void> {
|
|||||||
clearToken = true;
|
clearToken = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clearToken && (deliveryAt === undefined || deliveryAt === group.deliveryAt)) continue;
|
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
|
||||||
|
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
|
||||||
|
if (!clearToken && !timeChanged && !stateChanged) continue;
|
||||||
|
|
||||||
updated = await storage.updateData<ClientData>(key, current => {
|
updated = await storage.updateData<ClientData>(key, current => {
|
||||||
const d = current ?? data!;
|
const d = current ?? data!;
|
||||||
const g = d.groups?.find(x => x.id === group.id);
|
const g = d.groups?.find(x => x.id === group.id);
|
||||||
if (g?.boltTrackingToken) {
|
if (g?.boltTrackingToken) {
|
||||||
if (clearToken) {
|
if (timeChanged) g.deliveryAt = deliveryAt;
|
||||||
g.boltTrackingToken = undefined;
|
if (stateChanged) g.boltOrderState = orderState;
|
||||||
} else {
|
if (clearToken) g.boltTrackingToken = undefined;
|
||||||
g.deliveryAt = deliveryAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
group.deliveryAt = undefined;
|
group.deliveryAt = undefined;
|
||||||
group.qrGenerated = undefined;
|
group.qrGenerated = undefined;
|
||||||
group.boltTrackingToken = undefined;
|
group.boltTrackingToken = undefined;
|
||||||
|
group.boltOrderState = undefined;
|
||||||
for (const ml of memberLogins) {
|
for (const ml of memberLogins) {
|
||||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||||
}
|
}
|
||||||
@@ -209,11 +210,16 @@ export async function setGroupBoltTracking(login: string, groupId: string, share
|
|||||||
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
|
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
|
||||||
if (!shareUrl) {
|
if (!shareUrl) {
|
||||||
group.boltTrackingToken = undefined;
|
group.boltTrackingToken = undefined;
|
||||||
|
group.boltOrderState = undefined;
|
||||||
} else {
|
} else {
|
||||||
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
|
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
|
||||||
const token = extractBoltToken(shareUrl);
|
const token = extractBoltToken(shareUrl);
|
||||||
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
|
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
|
||||||
|
if (token !== group.boltTrackingToken) {
|
||||||
group.boltTrackingToken = token;
|
group.boltTrackingToken = token;
|
||||||
|
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
|
||||||
|
group.boltOrderState = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return saveExtraData(data, date);
|
return saveExtraData(data, date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { parseToken } from "../utils";
|
|||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
|
import { checkBoltTracking } from "../boltTracking";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -171,6 +172,10 @@ router.post("/setBoltTracking", async (req: Request, res, next) => {
|
|||||||
const data = await setGroupBoltTracking(login, id, shareUrl);
|
const data = await setGroupBoltTracking(login, id, shareUrl);
|
||||||
broadcastExtra(data);
|
broadcastExtra(data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
|
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
|
||||||
|
if (shareUrl) {
|
||||||
|
checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e));
|
||||||
|
}
|
||||||
} catch (e: any) { next(e); }
|
} catch (e: any) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -97,10 +97,27 @@ describe('setGroupBoltTracking', () => {
|
|||||||
expect(d.groups![0].boltTrackingToken).toBe(TOKEN);
|
expect(d.groups![0].boltTrackingToken).toBe(TOKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prázdná hodnota sledování zruší', async () => {
|
test('prázdná hodnota sledování zruší včetně stavu', async () => {
|
||||||
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
|
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
|
||||||
expect(d.groups![0].boltTrackingToken).toBeUndefined();
|
expect(d.groups![0].boltTrackingToken).toBeUndefined();
|
||||||
|
expect(d.groups![0].boltOrderState).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nový token vynuluje stav předchozí objednávky', async () => {
|
||||||
|
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
|
||||||
|
current!.groups![0].boltOrderState = 'preparing';
|
||||||
|
return current!;
|
||||||
|
});
|
||||||
|
// Stejný token stav nemění
|
||||||
|
let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
|
||||||
|
expect(d.groups![0].boltOrderState).toBe('preparing');
|
||||||
|
// Jiný token stav vynuluje
|
||||||
|
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
|
||||||
|
d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY);
|
||||||
|
expect(d.groups![0].boltOrderState).toBeUndefined();
|
||||||
|
expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('odmítne neplatný odkaz', async () => {
|
test('odmítne neplatný odkaz', async () => {
|
||||||
@@ -143,6 +160,7 @@ describe('checkBoltTracking', () => {
|
|||||||
const after = computeDeliveryHHMM(1800);
|
const after = computeDeliveryHHMM(1800);
|
||||||
const group = await getGroup();
|
const group = await getGroup();
|
||||||
expect([before, after]).toContain(group.deliveryAt);
|
expect([before, after]).toContain(group.deliveryAt);
|
||||||
|
expect(group.boltOrderState).toBe('waiting_preparation');
|
||||||
expect(group.boltTrackingToken).toBe(TOKEN);
|
expect(group.boltTrackingToken).toBe(TOKEN);
|
||||||
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
||||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||||
@@ -167,6 +185,7 @@ describe('checkBoltTracking', () => {
|
|||||||
await checkBoltTracking();
|
await checkBoltTracking();
|
||||||
const group = await getGroup();
|
const group = await getGroup();
|
||||||
expect(group.boltTrackingToken).toBeUndefined();
|
expect(group.boltTrackingToken).toBeUndefined();
|
||||||
|
expect(group.boltOrderState).toBe('delivered');
|
||||||
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
|
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,6 +194,17 @@ describe('checkBoltTracking', () => {
|
|||||||
await checkBoltTracking();
|
await checkBoltTracking();
|
||||||
const group = await getGroup();
|
const group = await getGroup();
|
||||||
expect(group.boltTrackingToken).toBeUndefined();
|
expect(group.boltTrackingToken).toBeUndefined();
|
||||||
|
expect(group.boltOrderState).toBe('delivered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aktualizuje boltOrderState při změně stavu beze změny času', async () => {
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkBoltTracking();
|
||||||
|
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
|
||||||
|
await checkBoltTracking();
|
||||||
|
const group = await getGroup();
|
||||||
|
expect(group.boltOrderState).toBe('preparing');
|
||||||
|
expect(group.boltTrackingToken).toBe(TOKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
|
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
|
||||||
|
|||||||
@@ -810,6 +810,9 @@ OrderGroup:
|
|||||||
boltTrackingToken:
|
boltTrackingToken:
|
||||||
description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
|
description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
|
||||||
type: string
|
type: string
|
||||||
|
boltOrderState:
|
||||||
|
description: Poslední známý stav objednávky z Bolt API (raw order_state, např. waiting_preparation, preparing, delivered). Zůstává vyplněn i po ukončení sledování.
|
||||||
|
type: string
|
||||||
|
|
||||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
|
|||||||
Reference in New Issue
Block a user