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:
@@ -7,8 +7,8 @@ import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
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, setBoltTracking, getOrderDates,
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates,
|
||||
} from '../../../types';
|
||||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||
@@ -24,26 +24,57 @@ import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
|
||||
import PendingPayments from '../components/PendingPayments';
|
||||
import BoltOrderProgress from '../components/BoltOrderProgress';
|
||||
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||||
|
||||
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;
|
||||
const IS_DEV = process.env.NODE_ENV === 'development';
|
||||
|
||||
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
|
||||
function extractBoltToken(input: string): string | null {
|
||||
/** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */
|
||||
const TRACKING_URL_PREFIX: Record<TrackingProvider, string> = {
|
||||
bolt: 'https://food.bolt.eu/sharedActiveOrder/',
|
||||
wolt: 'https://track.wolt.com/',
|
||||
};
|
||||
/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */
|
||||
const TRACKING_CODE_REGEX: Record<TrackingProvider, RegExp> = {
|
||||
bolt: /^[0-9a-f]{64}$/i,
|
||||
wolt: /^[A-Za-z0-9_-]{20,32}$/,
|
||||
};
|
||||
const TRACKING_HOST: Record<TrackingProvider, string> = {
|
||||
bolt: 'bolt.eu',
|
||||
wolt: 'wolt.com',
|
||||
};
|
||||
|
||||
/** Sestaví odkaz na sledování objednávky ze služby a kódu. */
|
||||
function trackingUrlFor(provider: TrackingProvider, code: string): string {
|
||||
return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu)
|
||||
* a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě.
|
||||
*/
|
||||
function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
|
||||
let hostname = '';
|
||||
let lastSegment = '';
|
||||
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;
|
||||
const url = new URL(trimmed);
|
||||
hostname = url.hostname.toLowerCase();
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
lastSegment = segments[segments.length - 1] ?? '';
|
||||
} catch {
|
||||
return null;
|
||||
// Vstup není URL — zkusíme ho vzít jako samotný kód
|
||||
}
|
||||
for (const provider of Object.values(TrackingProvider)) {
|
||||
const regex = TRACKING_CODE_REGEX[provider];
|
||||
if (!hostname && regex.test(trimmed)) return { provider, code: trimmed };
|
||||
const host = TRACKING_HOST[provider];
|
||||
const hostMatches = hostname === host || hostname.endsWith(`.${host}`);
|
||||
if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
|
||||
@@ -99,7 +130,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; boltUrl: string }>>({});
|
||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; trackingUrl: string }>>({});
|
||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
|
||||
@@ -276,7 +307,7 @@ export default function OrderGroupsPage() {
|
||||
const handleSaveTimes = async (group: OrderGroup) => {
|
||||
const times = editTimes[group.id];
|
||||
if (!times) return;
|
||||
const { orderedAt, deliveryAt, boltUrl } = times;
|
||||
const { orderedAt, deliveryAt, trackingUrl } = times;
|
||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||
return;
|
||||
@@ -285,16 +316,16 @@ export default function OrderGroupsPage() {
|
||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
// 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)');
|
||||
// Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny
|
||||
const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null;
|
||||
if (trackingUrl.trim() && !tracking) {
|
||||
setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)');
|
||||
return;
|
||||
}
|
||||
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
|
||||
const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider;
|
||||
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 && trackingChanged) {
|
||||
ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } }));
|
||||
}
|
||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||
};
|
||||
@@ -731,13 +762,13 @@ export default function OrderGroupsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
|
||||
<small className="text-muted text-nowrap">Odkaz pro sledování (Bolt Food / Wolt):</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 } }))}
|
||||
placeholder={`${TRACKING_URL_PREFIX.wolt}…`}
|
||||
value={editTimes[group.id]?.trackingUrl ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
@@ -750,8 +781,10 @@ export default function OrderGroupsPage() {
|
||||
{(() => {
|
||||
const canEdit = !isReadOnly && isCreator;
|
||||
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
|
||||
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
|
||||
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
|
||||
const trackingUrl = group.trackingProvider && group.trackingCode
|
||||
? trackingUrlFor(group.trackingProvider, group.trackingCode)
|
||||
: null;
|
||||
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } }));
|
||||
return (
|
||||
<>
|
||||
{canEdit && (
|
||||
@@ -775,9 +808,9 @@ export default function OrderGroupsPage() {
|
||||
onClick={startEdit}
|
||||
>
|
||||
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>
|
||||
{group.trackingCode && group.trackingProvider && (
|
||||
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}</Tooltip>}>
|
||||
<Badge bg="success" className="ms-1">{PROVIDER_LABEL[group.trackingProvider]}</Badge>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</small>
|
||||
@@ -802,9 +835,14 @@ export default function OrderGroupsPage() {
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{group.boltOrderState && (
|
||||
{group.trackingOrderState && group.trackingProvider && (
|
||||
<div className="mt-2">
|
||||
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
|
||||
<OrderProgress
|
||||
provider={group.trackingProvider}
|
||||
state={group.trackingOrderState}
|
||||
courierState={group.trackingCourierState}
|
||||
tracking={!!group.trackingCode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{IS_DEV && (
|
||||
|
||||
Reference in New Issue
Block a user