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 EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
import PendingPayments from '../components/PendingPayments';
|
||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
@@ -797,6 +798,11 @@ export default function OrderGroupsPage() {
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{group.boltOrderState && (
|
||||
<div className="mt-2">
|
||||
<BoltOrderProgress state={group.boltOrderState} tracking={!!group.boltTrackingToken} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
|
||||
@@ -102,16 +102,24 @@ export async function checkBoltTracking(): Promise<void> {
|
||||
|
||||
for (const group of candidates) {
|
||||
let deliveryAt: string | undefined;
|
||||
let orderState: string | undefined;
|
||||
let clearToken = false;
|
||||
|
||||
try {
|
||||
const order = await pollBoltOrder(group.boltTrackingToken!);
|
||||
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;
|
||||
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
|
||||
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
|
||||
consecutiveFailures.set(group.id, failures);
|
||||
@@ -121,17 +129,17 @@ export async function checkBoltTracking(): Promise<void> {
|
||||
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 => {
|
||||
const d = current ?? data!;
|
||||
const g = d.groups?.find(x => x.id === group.id);
|
||||
if (g?.boltTrackingToken) {
|
||||
if (clearToken) {
|
||||
g.boltTrackingToken = undefined;
|
||||
} else {
|
||||
g.deliveryAt = deliveryAt;
|
||||
}
|
||||
if (timeChanged) g.deliveryAt = deliveryAt;
|
||||
if (stateChanged) g.boltOrderState = orderState;
|
||||
if (clearToken) g.boltTrackingToken = undefined;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
||||
group.deliveryAt = undefined;
|
||||
group.qrGenerated = undefined;
|
||||
group.boltTrackingToken = undefined;
|
||||
group.boltOrderState = undefined;
|
||||
for (const ml of memberLogins) {
|
||||
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 (!shareUrl) {
|
||||
group.boltTrackingToken = undefined;
|
||||
group.boltOrderState = undefined;
|
||||
} else {
|
||||
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
|
||||
const token = extractBoltToken(shareUrl);
|
||||
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
|
||||
if (token !== group.boltTrackingToken) {
|
||||
group.boltTrackingToken = token;
|
||||
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
|
||||
group.boltOrderState = undefined;
|
||||
}
|
||||
}
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parseToken } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
|
||||
import { GroupState } from "../../../types/gen/types.gen";
|
||||
import { checkBoltTracking } from "../boltTracking";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -171,6 +172,10 @@ router.post("/setBoltTracking", async (req: Request, res, next) => {
|
||||
const data = await setGroupBoltTracking(login, id, shareUrl);
|
||||
broadcastExtra(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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -97,10 +97,27 @@ describe('setGroupBoltTracking', () => {
|
||||
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);
|
||||
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
|
||||
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 () => {
|
||||
@@ -143,6 +160,7 @@ describe('checkBoltTracking', () => {
|
||||
const after = computeDeliveryHHMM(1800);
|
||||
const group = await getGroup();
|
||||
expect([before, after]).toContain(group.deliveryAt);
|
||||
expect(group.boltOrderState).toBe('waiting_preparation');
|
||||
expect(group.boltTrackingToken).toBe(TOKEN);
|
||||
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
@@ -167,6 +185,7 @@ describe('checkBoltTracking', () => {
|
||||
await checkBoltTracking();
|
||||
const group = await getGroup();
|
||||
expect(group.boltTrackingToken).toBeUndefined();
|
||||
expect(group.boltOrderState).toBe('delivered');
|
||||
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
@@ -175,6 +194,17 @@ describe('checkBoltTracking', () => {
|
||||
await checkBoltTracking();
|
||||
const group = await getGroup();
|
||||
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 () => {
|
||||
|
||||
@@ -810,6 +810,9 @@ OrderGroup:
|
||||
boltTrackingToken:
|
||||
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
|
||||
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 ---
|
||||
PendingQr:
|
||||
|
||||
Reference in New Issue
Block a user