Pickup Point Map Selector for E-Commerce

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

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.