diff --git a/client/src/components/BoltOrderProgress.scss b/client/src/components/BoltOrderProgress.scss new file mode 100644 index 0000000..14c38b6 --- /dev/null +++ b/client/src/components/BoltOrderProgress.scss @@ -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); + } +} diff --git a/client/src/components/BoltOrderProgress.tsx b/client/src/components/BoltOrderProgress.tsx new file mode 100644 index 0000000..add5144 --- /dev/null +++ b/client/src/components/BoltOrderProgress.tsx @@ -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 = { + 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 Objednávka Bolt byla zrušena; + } + return ( + Stav z Bolt Food: {state}}> +
+ {STEPS.map((label, i) => ( +
+
+
{label}
+
+ ))} +
+ + ); +} diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 7c4e41a..5692237 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -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() { })()}
)} + {group.boltOrderState && ( +
+ +
+ )} )} diff --git a/server/src/boltTracking.ts b/server/src/boltTracking.ts index 6b309f5..700e42c 100644 --- a/server/src/boltTracking.ts +++ b/server/src/boltTracking.ts @@ -102,15 +102,23 @@ export async function checkBoltTracking(): Promise { 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 if (typeof order.expected_time_to_client_in_seconds === 'number') { - deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds); + } 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; @@ -121,17 +129,17 @@ export async function checkBoltTracking(): Promise { 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(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; }); diff --git a/server/src/groups.ts b/server/src/groups.ts index 467b841..b189650 100644 --- a/server/src/groups.ts +++ b/server/src/groups.ts @@ -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'); - group.boltTrackingToken = token; + 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); } diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts index 2a1a961..c173660 100644 --- a/server/src/routes/groupRoutes.ts +++ b/server/src/routes/groupRoutes.ts @@ -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); } }); diff --git a/server/src/tests/boltTracking.test.ts b/server/src/tests/boltTracking.test.ts index dbd1346..d274b44 100644 --- a/server/src/tests/boltTracking.test.ts +++ b/server/src/tests/boltTracking.test.ts @@ -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(`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 () => { diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 13f8a38..8b627a9 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -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: