Установка и быстрый старт
@type/site-client — серверный клиент API сайта: типизированные методы, ошибки как классы и проверка подписи вебхука. Зависимостей нет; нужны fetch и Web Crypto, поэтому пакет работает в Node.js 20+ и в Edge-рантайме. К фреймворку не привязан — ниже примеры для Next.js (App Router).
Установка
Пакет пока не опубликован в публичном реестре npm. Архив type-site-client-0.5.0.tgz выдаёт владелец аккаунта CRM; положите его в репозиторий сайта (например, в vendor/) и установите:
pnpm add ./vendor/type-site-client-0.5.0.tgz
# или: npm install ./vendor/type-site-client-0.5.0.tgzТекущая версия — 0.5.0, изменения — в истории версий.
Пакет работает только на сервере. При импорте в браузерную сборку он сразу бросает ошибку — так ключ не попадёт на страницу случайно. В Next.js дополнительно поставьте
import 'server-only'в модуль, который создаёт клиент.
Переменные окружения
Только серверные — без префикса NEXT_PUBLIC_:
TYPE_API_URL=https://crm.example.com # адрес вашей CRM, без пути
TYPE_API_KEY=tk_<key_id>_<secret> # ключ с правом «Аренда: сайт»
TYPE_WEBHOOK_SECRET=<секрет вебхука> # Настройки → Аренда → СайтФайлы .env* с этими значениями не должны попадать в репозиторий.
Первый запрос
// lib/crm.ts
import 'server-only';
import { createSiteClient } from '@type/site-client';
export const crm = createSiteClient({
baseUrl: process.env.TYPE_API_URL!,
apiKey: process.env.TYPE_API_KEY!,
});
// Проверка подключения: компания, валюта, языки, время заезда и выезда.
const info = await crm.ping();baseUrl принимается только с https:// (кроме localhost), иначе ключ ушёл бы открытым текстом.
Методы
| Метод | Маршрут | Что возвращает |
|---|---|---|
ping() | GET /ping | компания, валюта, языки, время заезда и выезда |
groups() | GET /groups | районы с числом объектов |
units({ lang }) | GET /units | все объекты (кроме архивных) |
unit(id, { lang }) | GET /units/{id} | один объект |
occupancy({ date_from, date_to, unit_ids }) | GET /occupancy | занятые интервалы; date_to не включительно, период до 400 дней |
quote({ unit_id, check_in, check_out, guests }) | GET /quote | расчёт по ночам; при занятых датах — available: false и violations |
prices({ check_in, check_out, guests, group_id }) | GET /prices | цена и доступность всех объектов на даты |
createBookingRequest(body, { visitorIp }) | POST /booking-requests | заявка на бронь |
legacyUrls() | GET /legacy-urls | старые адреса для переадресаций |
photoUrl(url) | — | абсолютный адрес фото |
Отдельной функцией, без клиента, экспортируется verifyWebhook(body, headers, secret) — проверка подписи вебхука.
Пути маршрутов — от /api/modules/rental/site. Каждый метод последним аргументом принимает { signal, init }: signal — для отмены, init — дополнительные поля fetch (заголовок Authorization ими не переопределить).
Клиент по умолчанию ставит cache: 'no-store'. Кэшируйте результаты сами — например, unstable_cache с тегами — и сбрасывайте их вебхуком.
Каталог: серверный компонент
// app/[lang]/apartamente/page.tsx
import { unstable_cache } from 'next/cache';
import { crm } from '@/lib/crm';
import type { Lang } from '@type/site-client';
const getUnits = unstable_cache((lang: Lang) => crm.units({ lang }), ['rental-units'], {
tags: ['rental:units'],
revalidate: 3600,
});
export default async function Catalog({ params }: { params: Promise<{ lang: Lang }> }) {
const { lang } = await params;
const { units } = await getUnits(lang);
return (
<ul>
{units.map((u) => (
<li key={u.id}>
<a href={`/${lang}/apartament/${u.slug}`}>
{u.photos[0] && <img src={crm.photoUrl(u.photos[0].url)} alt={u.name} />}
<h2>{u.name}</h2>
<p>{u.price} {u.currency}</p>
</a>
</li>
))}
</ul>
);
}Ссылки на фото постоянные и открываются без ключа: CRM отвечает переадресацией 302 на файл в хранилище. Для next/image разрешите в images.remotePatterns хост CRM и хост хранилища или используйте unoptimized.
Заявка на бронь: route handler
// app/api/booking/route.ts
import { RentalUnavailableError, SiteNetworkError, TooManyAttemptsError } from '@type/site-client';
import { crm } from '@/lib/crm';
export async function POST(req: Request) {
const f = await req.json();
// IP посетителя — из заголовка вашего прокси; по нему CRM считает лимит заявок.
const visitorIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
try {
const res = await crm.createBookingRequest(
{
request_id: f.request_id, // UUID формы: повтор с тем же id не создаст вторую заявку
unit_id: f.unit_id,
check_in: f.check_in,
check_out: f.check_out,
guests: Number(f.guests ?? 1),
name: f.name,
phone: f.phone || null,
email: f.email || null,
message: f.message || null,
lang: f.lang,
page_url: f.page_url ?? null,
consent: true,
hp: f.hp ?? '', // скрытое поле-ловушка, должно быть пустым
elapsed_ms: Number(f.elapsed_ms ?? 0), // сколько форма была открыта
},
visitorIp ? { visitorIp } : {},
);
return Response.json(res, { status: 201 });
} catch (e) {
if (e instanceof RentalUnavailableError) return Response.json({ violations: e.violations }, { status: 422 });
if (e instanceof TooManyAttemptsError) {
return Response.json({ error: 'too_many_attempts' }, { status: 429, headers: { 'Retry-After': String(e.retryAfter ?? 60) } });
}
if (e instanceof SiteNetworkError) return Response.json({ error: 'unavailable' }, { status: 503 });
throw e;
}
}Заявка создаёт в CRM сделку на этапе «Заявка» с расчётом цены; клиент ищется по телефону, затем по e-mail. Если CRM была недоступна, повторите отправку с тем же request_id — вторая сделка не появится.
Антиспам: поле hp должно быть пустым, elapsed_ms — не меньше 3000. Если проверка не пройдена, ответ такой же, как при успехе, но заявка не создаётся.
Ошибки
Все ошибки наследуют SiteApiError с полями status, code и body.
| Класс | Когда |
|---|---|
ApiKeyInvalidError | 401 api_key_invalid — ключа нет, он неверный, отозван, истёк или IP не разрешён. Причину видно только в CRM, в журнале ключа |
ScopeMissingError | 403 scope_missing — у ключа нет права; scope — какого (rental:site) |
ScopeUnavailableError | 403 scope_unavailable — право есть, но модуль аренды выключен |
TooManyAttemptsError | 429 too_many_attempts; retryAfter — секунды из Retry-After или null |
RentalUnavailableError | 422 rental_unavailable у заявки; violations — почему |
SiteApiError | прочее: validation_error, not_found, bad_response… |
SiteNetworkError | CRM не ответила: сеть или тайм-аут (5 с для чтения, 10 с для заявки) |
Ошибки ключа (401, 403) — проблема настройки сайта, а не посетителя: залогируйте code и покажите посетителю нейтральное «сервис временно недоступен». Сам ключ в сообщения ошибок не попадает.