feat: sledování objednávek přes Wolt
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s
Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné. - pole skupiny bolt* přejmenována na tracking* + nové trackingProvider - endpoint /groups/setBoltTracking → /groups/setTracking - Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order - DEV simulace zůstává jen pro Bolt Food
This commit is contained in:
1 parent
eeb1630391
commit
5c048fe1f1
25 files changed
+1326
-869
No files matched your search
@@ -1,104 +0,0 @@
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import './BoltOrderProgress.scss';
|
||||
|
||||
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||
|
||||
/**
|
||||
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||
*/
|
||||
const ORDER_STATE_TO_STEP: Record<string, number> = {
|
||||
created: 0,
|
||||
pending: 0,
|
||||
waiting_acceptance: 0,
|
||||
accepted: 0,
|
||||
waiting_preparation: 0,
|
||||
preparing: 1,
|
||||
waiting_delivery: 1,
|
||||
ready_for_pickup: 1,
|
||||
waiting_courier: 1,
|
||||
waiting_pickup: 1,
|
||||
picked_up: 3,
|
||||
in_delivery: 3,
|
||||
delivering: 3,
|
||||
heading_to_client: 3,
|
||||
delivered: 4,
|
||||
finished: 4,
|
||||
};
|
||||
|
||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||
const COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||
matched: 0,
|
||||
accepted: 0,
|
||||
heading_to_provider: 1,
|
||||
arrived_to_provider: 1,
|
||||
picked_up: 3,
|
||||
heading_to_client: 3,
|
||||
delivering: 3,
|
||||
arrived_to_client: 3,
|
||||
delivered: 4,
|
||||
};
|
||||
|
||||
/** Order states ve kterých už jídlo čeká připravené na kurýra. */
|
||||
const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']);
|
||||
|
||||
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||
function stepForOrderState(state: string): number | 'cancelled' {
|
||||
const s = state.toLowerCase();
|
||||
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
|
||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||
if (/delivered|finished/.test(s)) return 4;
|
||||
if (/accept|pending|created/.test(s)) return 0;
|
||||
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
|
||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stepForCourierState(state?: string): number {
|
||||
if (!state) return 0;
|
||||
const s = state.toLowerCase();
|
||||
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
|
||||
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Raw order_state z Bolt API (např. waiting_preparation) */
|
||||
state: string;
|
||||
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
|
||||
courierState?: 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, courierState, tracking }: Props) {
|
||||
const orderStep = stepForOrderState(state);
|
||||
if (orderStep === 'cancelled') {
|
||||
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
|
||||
}
|
||||
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||
let step = Math.max(orderStep, stepForCourierState(courierState));
|
||||
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP.
|
||||
const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider';
|
||||
const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase());
|
||||
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
|
||||
return (
|
||||
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
|
||||
<div className={`bolt-progress${tracking && step < 4 ? ' 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>
|
||||
);
|
||||
}
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
.bolt-progress {
|
||||
.tracking-progress {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
max-width: 400px;
|
||||
|
||||
.bolt-step {
|
||||
.tracking-step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -11,7 +11,7 @@
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
|
||||
.bolt-dot {
|
||||
.tracking-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 12px;
|
||||
@@ -20,7 +20,7 @@
|
||||
background: var(--luncher-border, #ced4da);
|
||||
}
|
||||
|
||||
.bolt-label {
|
||||
.tracking-label {
|
||||
margin-top: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--luncher-text-muted, #6c757d);
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
|
||||
&.done {
|
||||
.bolt-dot {
|
||||
.tracking-dot {
|
||||
background: var(--bs-success, #198754);
|
||||
}
|
||||
|
||||
@@ -49,19 +49,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active .bolt-label {
|
||||
&.active .tracking-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;
|
||||
&.live .tracking-step.active .tracking-dot {
|
||||
animation: tracking-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bolt-pulse {
|
||||
@keyframes tracking-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import { TrackingProvider } from '../../../types';
|
||||
import './OrderProgress.scss';
|
||||
|
||||
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
|
||||
|
||||
/** Lidský název rozvozové služby do tooltipu. */
|
||||
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
|
||||
bolt: 'Bolt Food',
|
||||
wolt: 'Wolt',
|
||||
};
|
||||
|
||||
/**
|
||||
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
|
||||
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
|
||||
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
|
||||
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
|
||||
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
|
||||
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
|
||||
*/
|
||||
const BOLT_STATE_TO_STEP: Record<string, number> = {
|
||||
created: 0,
|
||||
pending: 0,
|
||||
waiting_acceptance: 0,
|
||||
accepted: 0,
|
||||
waiting_preparation: 0,
|
||||
preparing: 1,
|
||||
waiting_delivery: 1,
|
||||
ready_for_pickup: 1,
|
||||
waiting_courier: 1,
|
||||
waiting_pickup: 1,
|
||||
picked_up: 3,
|
||||
in_delivery: 3,
|
||||
delivering: 3,
|
||||
heading_to_client: 3,
|
||||
delivered: 4,
|
||||
finished: 4,
|
||||
};
|
||||
|
||||
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
|
||||
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
|
||||
matched: 0,
|
||||
accepted: 0,
|
||||
heading_to_provider: 1,
|
||||
arrived_to_provider: 1,
|
||||
picked_up: 3,
|
||||
heading_to_client: 3,
|
||||
delivering: 3,
|
||||
arrived_to_client: 3,
|
||||
delivered: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
|
||||
* Stav "ready" znamená hotové jídlo čekající na kurýra, proto zůstává v „Přípravě"
|
||||
* a na „Vyzvedávání" se posune, až když je kurýr přiřazený. Že objednávku kurýr
|
||||
* skutečně veze, se pozná až ze stavu kurýra 'delivering' (viz trackingProviders.ts).
|
||||
*/
|
||||
const WOLT_STATE_TO_STEP: Record<string, number> = {
|
||||
received: 0,
|
||||
acknowledged: 0,
|
||||
confirmed: 0,
|
||||
waiting: 0,
|
||||
production: 1,
|
||||
preparing: 1,
|
||||
ready: 1,
|
||||
picked_up: 3,
|
||||
transport: 3,
|
||||
delivering: 3,
|
||||
delivered: 4,
|
||||
};
|
||||
|
||||
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
|
||||
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
|
||||
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
|
||||
wolt: new Set(['ready']),
|
||||
};
|
||||
|
||||
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
|
||||
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
|
||||
const s = state.toLowerCase();
|
||||
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
|
||||
if (s in known) return known[s];
|
||||
if (/cancel|reject|fail/.test(s)) return 'cancelled';
|
||||
if (/delivered|finished/.test(s)) return 4;
|
||||
if (/accept|pending|created|received/.test(s)) return 0;
|
||||
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
|
||||
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function stepForCourierState(provider: TrackingProvider, state?: string): number {
|
||||
if (!state) return 0;
|
||||
const s = state.toLowerCase();
|
||||
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering' = veze naši objednávku
|
||||
if (provider === 'wolt') return s === 'delivering' ? 3 : 0;
|
||||
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
|
||||
if (/picked|client|delivering|transport/.test(s)) return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Rozvozová služba, ze které stav pochází */
|
||||
provider: TrackingProvider;
|
||||
/** Raw stav objednávky (Bolt order_state, Wolt status) */
|
||||
state: string;
|
||||
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
|
||||
courierState?: string;
|
||||
/** Zda sledování stále běží (skupina má trackingCode) */
|
||||
tracking: boolean;
|
||||
}
|
||||
|
||||
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
|
||||
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
|
||||
const label = PROVIDER_LABEL[provider] ?? provider;
|
||||
const orderStep = stepForOrderState(provider, state);
|
||||
if (orderStep === 'cancelled') {
|
||||
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
|
||||
}
|
||||
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
|
||||
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
|
||||
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
|
||||
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
|
||||
const courierAtProvider = provider === 'wolt'
|
||||
? !!courierState
|
||||
: courierState?.toLowerCase() === 'arrived_to_provider';
|
||||
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
|
||||
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
|
||||
const courierInfo = provider === 'wolt'
|
||||
? (courierState === 'delivering' ? 'kurýr veze objednávku' : 'kurýr přiřazen')
|
||||
: `kurýr: ${courierState}`;
|
||||
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
|
||||
return (
|
||||
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
|
||||
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
|
||||
{STEPS.map((stepLabel, i) => (
|
||||
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
|
||||
<div className="tracking-dot" />
|
||||
<div className="tracking-label">{stepLabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
group: OrderGroup;
|
||||
};
|
||||
|
||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */
|
||||
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */
|
||||
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
|
||||
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
|
||||
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
|
||||
@@ -30,7 +30,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [manualKey, setManualKey] = useState<string>('preparing');
|
||||
|
||||
const running = !!group.boltTrackingToken;
|
||||
const running = !!group.trackingCode;
|
||||
|
||||
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
|
||||
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
|
||||
@@ -113,7 +113,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
|
||||
<p className="mb-2">
|
||||
Stav simulace:{' '}
|
||||
{running
|
||||
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge>
|
||||
? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
|
||||
: <Badge bg="secondary">neběží</Badge>}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -141,13 +141,13 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
|
||||
id="boltDeliveredCheckbox"
|
||||
ref={boltDeliveredRef}
|
||||
type="checkbox"
|
||||
label="Upozornění na doručení objednávky (Bolt Food)"
|
||||
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
|
||||
defaultChecked={notifSettings.boltDeliveredPush ?? false}
|
||||
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Až bude skupinová objednávka sledovaná přes Bolt Food doručena, přijde vám push notifikace.
|
||||
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace Bolt.
|
||||
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
|
||||
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
|
||||
Reference in new issue
Block a user