Development of a pickup point map selector for e-commerce
Customer opens the delivery step, sees a list of 47 pickup point addresses, tries to find their district in dropdown, and closes the tab. A map with markers solves this: customer sees points near home, work, on the way — and chooses in 5 seconds.
Sources of pickup point data
Pickup points come from carrier APIs. Each has its own structure, but essence is same — list of objects with coordinates, address, working hours, weight and dimension limits.
CDEK:
GET https://api.cdek.ru/v2/deliverypoints?city_code=44&weight_max=30&type=PVZ
Authorization: Bearer {token}
Response contains entity array with fields location.latitude, location.longitude, work_time, address_comment, allowed_max_weight.
Boxberry:
GET https://api.boxberry.ru/json.php?token={token}&method=ListPoints&CityCode=77&prepaid=1
Structure differs, but data is same — coordinates, address, working hours.
Caching pickup point directory
Pickup points rarely change — once a day, sometimes less. Requesting every page load is wasteful and slow. Correct approach: scheduled sync, storage in own database:
// Artisan command: php artisan delivery:sync-pickup-points
class SyncPickupPoints extends Command
{
public function handle(CdekService $cdek, BoxberryService $boxberry): void
{
$carriers = [
'cdek' => fn() => $cdek->getAllPickupPoints(),
'boxberry' => fn() => $boxberry->getAllPickupPoints(),
];
foreach ($carriers as $carrier => $fetcher) {
$points = $fetcher();
$this->info("$carrier: {$points->count()} points");
PickupPoint::where('carrier', $carrier)->delete();
PickupPoint::insert(
$points->map(fn($p) => [
'carrier' => $carrier,
'external_id' => $p['code'],
'name' => $p['name'],
'address' => $p['address'],
'city' => $p['city'],
'lat' => $p['lat'],
'lng' => $p['lng'],
'work_time' => $p['work_time'],
'max_weight' => $p['max_weight_kg'],
'cash_allowed' => $p['cash_allowed'],
'updated_at' => now(),
])->toArray()
);
}
$this->info('Done');
}
}
Command runs via cron at night. User gets data from local database in 10–20 ms instead of 500–2000 ms from API.
Geospatial queries
After customer enters their address or shares location, show nearest pickup points. PostGIS does this elegantly:
-- Enable extension (once)
CREATE EXTENSION IF NOT EXISTS postgis;
-- Add geography column
ALTER TABLE pickup_points ADD COLUMN location geography(POINT, 4326);
UPDATE pickup_points SET location = ST_SetSRID(ST_MakePoint(lng, lat), 4326);
CREATE INDEX idx_pickup_points_location ON pickup_points USING GIST(location);
-- Query: 20 nearest points within 10 km, handling cargo up to 5 kg
SELECT
id, carrier, name, address, work_time, cash_allowed,
ST_Distance(location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)) AS distance_m
FROM pickup_points
WHERE
max_weight >= :weight
AND ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography,
10000
)
ORDER BY distance_m
LIMIT 20;
Without PostGIS, you can use haversine formula directly in SQL or PHP — but it's slower and less accurate.
Map: rendering markers
With thousands of points — rendering each as separate DOM element freezes browser. Use clustering:
import L from 'leaflet';
import 'leaflet.markercluster';
const map = L.map('pickup-map').setView([55.7558, 37.6173], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const markers = L.markerClusterGroup({
maxClusterRadius: 50,
iconCreateFunction: (cluster) => {
const count = cluster.getChildCount();
return L.divIcon({
html: `<div class="cluster-icon">${count}</div>`,
className: '',
iconSize: [40, 40],
});
},
});
// Load points within current viewport
map.on('moveend', async () => {
const bounds = map.getBounds();
const response = await fetch('/api/pickup-points?' + new URLSearchParams({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
weight: cartWeight,
}));
const points = await response.json();
markers.clearLayers();
points.forEach((point) => {
const marker = L.marker([point.lat, point.lng], {
icon: carrierIcon(point.carrier),
});
marker.bindPopup(buildPopup(point));
marker.on('click', () => selectPickupPoint(point));
markers.addLayer(marker);
});
});
map.addLayer(markers);
Load only visible area (moveend) — instead of dumping all points at once. For 50,000 points across country, this is crucial.
Popup with pickup point details
function buildPopup(point) {
return `
<div class="pickup-popup">
<div class="carrier-badge ${point.carrier}">${point.carrier.toUpperCase()}</div>
<strong>${point.name}</strong>
<p>${point.address}</p>
<p class="work-time">${point.work_time}</p>
${point.cash_allowed ? '<span class="badge">Cash accepted</span>' : ''}
<p class="delivery-cost">Delivery: <b>${formatPrice(point.cost)} ₽</b></p>
<p class="delivery-days">Terms: ${point.min_days}–${point.max_days} days</p>
<button onclick="selectPickupPoint(${point.id})">Select</button>
</div>
`;
}
Customer location detection
Browser geolocation via navigator.geolocation — most accurate, but requires permission. If denied or in different city — need fallback:
async function detectUserLocation() {
// Try IP geolocation
try {
const res = await fetch('https://ipapi.co/json/');
const data = await res.json();
return { city: data.city, lat: data.latitude, lng: data.longitude };
} catch {
return { city: 'Moscow', lat: 55.7558, lng: 37.6173 };
}
}
ipapi.co provides 1000 free requests per day. For large stores — own GeoIP database (MaxMind GeoLite2, free).
Filtering pickup points
If customer wants only fitting room points (for clothes), or postamats (24/7), or cash payment only — need filters:
const filters = {
fitting_room: false,
cash_allowed: false,
type: 'all', // 'pvz' | 'postamat' | 'all'
carrier: 'all',
};
// On filter change — re-request points
Object.keys(filters).forEach((key) => {
document.getElementById(`filter-${key}`).addEventListener('change', (e) => {
filters[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
loadPickupPoints();
});
});
Confirming choice and passing to order
After selecting point — its data goes to order form. Point ID stored as part of shipping method:
function selectPickupPoint(point) {
selectedPoint = point;
// Update UI
document.getElementById('selected-point-address').textContent = point.address;
document.getElementById('selected-point-work-time').textContent = point.work_time;
// Pass to order form
document.getElementById('delivery_type').value = 'pickup';
document.getElementById('pickup_point_id').value = point.id;
document.getElementById('pickup_carrier').value = point.carrier;
document.getElementById('pickup_external_id').value = point.external_id;
document.getElementById('delivery_cost').value = point.cost;
}
Development timeline
Map with single carrier, API data with cache, marker clustering — 4–6 days. Aggregator with multiple carriers, geolocation, filters — 2 weeks. Adding pickup points to existing order form without rearchitecture — 3–5 days.







