Розробка калькулятора доставки на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка калькулятора доставки на сайті
Середня
~2-3 робочих дні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розроблення калькулятора доставки на сайті

Калькулятор доставки повинен давати точну відповідь до оформлення замовлення — не «від 300 гривень», а конкретну суму з вибором способу та дати. Розмиті формулювання на checkout — прямопрезентование причина відмов. За даними Baymard Institute, неочікувана вартість доставки на checkout — причина брошеної корзини в 48% випадків.

Архітектура

Запит (місто + товари + вага) → DeliveryCalculator → [Carrier APIs] → Список варіантів → Frontend

Калькулятор повинен працювати як на сторінці товару (приблизно), так і в корзині (точно, з урахуванням всього складу).

Схема даних

CREATE TABLE delivery_zones (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(255),
    country     CHAR(2) DEFAULT 'RU',
    regions     TEXT[],                    -- коди регіонів ФІАС
    cities      TEXT[],                    -- коди міст КЛАДР
    carrier_id  INT REFERENCES carriers(id)
);

CREATE TABLE delivery_rates (
    id              BIGSERIAL PRIMARY KEY,
    carrier_id      INT REFERENCES carriers(id),
    zone_id         BIGINT REFERENCES delivery_zones(id),
    method          VARCHAR(50),           -- 'courier', 'pickup', 'post'
    weight_from_g   INT DEFAULT 0,
    weight_to_g     INT,
    price           NUMERIC(10,2) NOT NULL,
    days_min        SMALLINT,
    days_max        SMALLINT,
    free_from       NUMERIC(12,2),         -- безплатно при сумі від X
    is_active       BOOLEAN DEFAULT TRUE
);

Сервіс обчислення

class DeliveryCalculatorService
{
    public function calculate(DeliveryRequest $request): DeliveryResult
    {
        $totalWeight = $this->calculateWeight($request->items);
        $totalPrice  = $request->items->sum(fn($i) => $i->price * $i->quantity);

        $zone = $this->zoneResolver->resolve($request->destination);

        if (!$zone) {
            return $this->calculateViaApi($request, $totalWeight, $totalPrice);
        }

        $rates = DeliveryRate::where('zone_id', $zone->id)
            ->where('weight_from_g', '<=', $totalWeight)
            ->where(fn($q) => $q->whereNull('weight_to_g')->orWhere('weight_to_g', '>=', $totalWeight))
            ->where('is_active', true)
            ->with('carrier')
            ->orderBy('price')
            ->get();

        $options = $rates->map(function ($rate) use ($totalPrice) {
            $price = ($rate->free_from && $totalPrice >= $rate->free_from) ? 0 : $rate->price;

            return new DeliveryOption(
                carrierId:  $rate->carrier_id,
                method:     $rate->method,
                name:       $rate->carrier->name . ' — ' . $rate->method_label,
                price:      $price,
                daysMin:    $rate->days_min,
                daysMax:    $rate->days_max,
                isFree:     $price === 0.0,
            );
        });

        return new DeliveryResult(options: $options, destination: $request->destination);
    }

    private function calculateWeight(Collection $items): int
    {
        return $items->sum(function ($item) {
            $product = $item->product;
            $weight  = $product->weight_g ?? 500; // дефолт 500г якщо не вказано
            return $weight * $item->quantity;
        });
    }
}

Інтеграція з API CDEK

class CdekDeliveryProvider implements DeliveryProviderInterface
{
    private string $baseUrl = 'https://api.cdek.ru/v2';

    public function calculate(DeliveryRequest $request, int $weightG): array
    {
        $token = $this->getToken();

        $response = Http::withToken($token)
            ->post("{$this->baseUrl}/calculator/tarifflist", [
                'type'          => 1,  // 1 = інтернет-магазин
                'currency'      => 1,  // RUB
                'lang'          => 'rus',
                'from_location' => ['code' => config('cdek.from_city_code')],
                'to_location'   => ['address' => $request->destination->address],
                'packages'      => [[
                    'weight' => $weightG,
                    'length' => 30,
                    'width'  => 20,
                    'height' => 10,
                ]],
            ]);

        if (!$response->successful()) return [];

        return collect($response->json('tariff_codes', []))
            ->map(fn($t) => new DeliveryOption(
                carrierId:  'cdek',
                method:     $this->mapTariffToMethod($t['tariff_code']),
                name:       'CDEK — ' . $t['tariff_name'],
                price:      $t['delivery_sum'],
                daysMin:    $t['period_min'],
                daysMax:    $t['period_max'],
            ))
            ->toArray();
    }

    private function getToken(): string
    {
        return Cache::remember('cdek_token', 3600, function () {
            $response = Http::post("{$this->baseUrl}/oauth/token", [
                'grant_type'    => 'client_credentials',
                'client_id'     => config('cdek.client_id'),
                'client_secret' => config('cdek.client_secret'),
            ]);
            return $response->json('access_token');
        });
    }
}

Інтеграція з Поштою України

class UkrPostProvider implements DeliveryProviderInterface
{
    public function calculate(DeliveryRequest $request, int $weightG): array
    {
        $response = Http::withHeaders([
            'Authorization' => 'Bearer ' . config('ukrpost.token'),
            'Content-Type'  => 'application/json',
        ])->post('https://api.ukrposhta.ua/calculate', [
            'weight'   => $weightG,
            'from'     => config('ukrpost.from_city'),
            'to'       => $request->destination->city,
            'declared' => (int) ($request->declaredValue * 100),
        ]);

        if (!$response->successful()) return [];

        $total = $response->json('total', 0) / 100;

        return [new DeliveryOption(
            carrierId: 'ukrpost',
            method:    'post',
            name:      'Укрпошта',
            price:     $total,
            daysMin:   $response->json('days.min') ?? 7,
            daysMax:   $response->json('days.max') ?? 14,
        )];
    }
}

API-ендпоінт для фронтенду

class DeliveryCalculatorController extends Controller
{
    public function calculate(CalculateRequest $request): JsonResponse
    {
        $cacheKey = 'delivery.' . md5(serialize($request->validated()));

        $result = Cache::remember($cacheKey, 300, function () use ($request) {
            return $this->calculator->calculate(
                DeliveryRequest::fromArray($request->validated())
            );
        });

        return response()->json([
            'options'     => DeliveryOptionResource::collection($result->options),
            'destination' => $result->destination->label,
        ]);
    }
}

Фронтенд-компонент

const DeliveryCalculator: React.FC<{ cartItems: CartItem[] }> = ({ cartItems }) => {
  const [city, setCity]       = useState('');
  const [options, setOptions] = useState<DeliveryOption[]>([]);
  const [loading, setLoading] = useState(false);

  const calculate = useDebouncedCallback(async (cityValue: string) => {
    if (cityValue.length < 3) return;
    setLoading(true);
    try {
      const res = await api.post('/delivery/calculate', {
        destination: cityValue,
        items: cartItems.map(i => ({ product_id: i.id, quantity: i.qty })),
      });
      setOptions(res.data.options);
    } finally {
      setLoading(false);
    }
  }, 600);

  return (
    <div>
      <input
        placeholder="Введіть місто доставки"
        value={city}
        onChange={e => { setCity(e.target.value); calculate(e.target.value); }}
        className="border rounded px-3 py-2 w-full"
      />

      {loading && <p className="text-sm text-gray-400 mt-2">Рахуємо вартість...</p>}

      {options.length > 0 && (
        <ul className="mt-3 space-y-2">
          {options.map(opt => (
            <li key={opt.id} className="flex justify-between items-center border rounded px-3 py-2">
              <div>
                <p className="font-medium">{opt.name}</p>
                <p className="text-sm text-gray-500">{opt.daysMin}–{opt.daysMax} днів</p>
              </div>
              <span className="font-semibold">
                {opt.isFree ? 'Безплатно' : formatPrice(opt.price)}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

Підказка міста через DaData

const [suggestions, setSuggestions] = useState<string[]>([]);

const fetchCities = useDebouncedCallback(async (query: string) => {
  const res = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Token ${DADATA_TOKEN}`,
    },
    body: JSON.stringify({ query, from_bound: { value: 'city' }, to_bound: { value: 'city' }, count: 5 }),
  });
  const data = await res.json();
  setSuggestions(data.suggestions.map((s: any) => s.value));
}, 400);

Графік реалізації

  • Схема даних + DeliveryCalculatorService з таблицею зон: 1 день
  • API CDEK: 1 день
  • Укрпошта API: 0.5 дня
  • Фронтенд-компонент + підказка міст: 1 день
  • Кеширування + API-ендпоінт: 0.5 дня

Разом: 3–4 робочі дні.