Lead Radar: исходный код
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
9749da5ae7
201 files changed
+97685
No files matched your search
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@lr/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.6.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Формулировки в тексте объявления, по которым видно, что работать с ним агенту незачем:
|
||||
* - broker — объявление разместил посредник («услуги агентства», «комиссия 50%»);
|
||||
* - noAgents — собственник не работает с агентами («агентам не беспокоить», «без посредников»).
|
||||
* Объявление с такой формулировкой считается агентским: фильтр «только собственники» его не пропускает.
|
||||
*/
|
||||
export type AgentPhraseKind = 'broker' | 'noAgents';
|
||||
|
||||
const AGENT = '(?:агент|ри[еэ]лтор|посредник|маклер|брокер)[^\\s.,;:!?()]*';
|
||||
const AGENT_HY = '(?:միջնորդ(?!ավճար)|գործակալ|ռիելթոր|բրոքեր|բռոքեր)[^\\s.,;:!?։()]*';
|
||||
/** Посредникам «не звонить», «не беспокоить», «не работаю с…» — в пределах одной фразы. */
|
||||
const REFUSE = '(?:не|ни)\\s+(?:беспоко|звон|пиш|писать|обращ|работа|сотруднича|предлага)[^\\s.,;:!?()]*';
|
||||
|
||||
// Сначала «не работает с агентами»: в таких фразах тоже есть слово «агент».
|
||||
const NO_AGENTS: RegExp[] = [
|
||||
new RegExp(`${AGENT}[^.!?\\n]{0,40}?${REFUSE}`, 'iu'),
|
||||
new RegExp(`без\\s+(?:посредник(?:ов|а)|агент(?:ов|ств|а)|ри[еэ]лтор(?:ов|а)|маклер(?:ов|а)|брокер(?:ов|а))(?![а-яё])`, 'iu'),
|
||||
// «Միջնորդները չզանգահարեն», «Սիրելի գործակալներ, չեմ համագործակցում», «առանց միջնորդի».
|
||||
new RegExp(`${AGENT_HY}[^.!?։\\n]{0,40}?չ(?:զանգ|զանագ|անհանգստ|գր|դիմ|եմ\\s+(?:համագործակց|աշխատ))`, 'iu'),
|
||||
/առանց\s+(?:միջնորդ(?!ավճար)|գործակալ|ռիելթոր|բրոքեր)/iu,
|
||||
/\bno\s+(?:agents|agencies|brokers|realtors|intermediaries)\b/i,
|
||||
];
|
||||
|
||||
const BROKER: RegExp[] = [
|
||||
/услуг[иа]\s+(?:агент(?:ства|а)|ри[еэ]лтора|посредника)/iu,
|
||||
/агентск(?:ая|ой|ую)\s+комисси/iu,
|
||||
/комисси[яиюей]\s+(?:агентства|агента|ри[еэ]лтора|посредника)/iu,
|
||||
/комисси[яиюей]\s*[:\-–—]?\s*\d{1,3}\s*%/iu,
|
||||
/\d{1,3}\s*%\s*комисси/iu,
|
||||
/ри[еэ]лтор/iu,
|
||||
/брокер/iu,
|
||||
// «Միջնորդավճար չկա» и «առանց միջնորդավճարի» — это «без комиссии», так пишет и собственник.
|
||||
/(?<!առանց\s)միջնորդավճար(?!\S*\s+չկա)/iu,
|
||||
/գործակալության\s+ծառայ/iu,
|
||||
/ռիելթոր|բրոքեր/iu,
|
||||
// «No agency fee» — «без комиссии», так пишет собственник.
|
||||
/(?<!\bno\s)\bagency\s+fee\b|\brealtor\b|\bbroker\b|\bcommission\s*:?\s*\d/i,
|
||||
];
|
||||
|
||||
/** Первая найденная формулировка в заголовке и описании объявления или null. */
|
||||
export function agentPhrase(text: string | null | undefined): { kind: AgentPhraseKind; phrase: string } | null {
|
||||
if (!text) return null;
|
||||
for (const [kind, list] of [['noAgents', NO_AGENTS], ['broker', BROKER]] as const) {
|
||||
for (const re of list) {
|
||||
const m = re.exec(text);
|
||||
if (m) return { kind, phrase: m[0].trim() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
import { CURRENCIES, FALLBACK_RATES, type Currency, type Rates } from './filters.ts';
|
||||
|
||||
// Комиссия агентства: процент от цены (при аренде — от месячной платы), фиксированная сумма или фикс + процент.
|
||||
// Руководитель задаёт её в настройках отдельно для объектов и клиентов, продажи и аренды;
|
||||
// агент может поменять условие в карточке конкретного объекта или клиента.
|
||||
|
||||
export const CommissionRule = z.object({
|
||||
/** percent — процент; fixed — фиксированная сумма; mixed — фикс + процент. */
|
||||
mode: z.enum(['percent', 'fixed', 'mixed']),
|
||||
/** Процент (при аренде бывает 100 % месячной платы и больше) либо, в режиме fixed, сумма. */
|
||||
value: z.number().min(0).max(100_000_000),
|
||||
/** Валюта фиксированной суммы. */
|
||||
currency: z.enum(CURRENCIES).default('USD'),
|
||||
/** Фиксированная часть в режиме mixed. */
|
||||
fixed: z.number().min(0).max(100_000_000).optional(),
|
||||
});
|
||||
export type CommissionRule = z.infer<typeof CommissionRule>;
|
||||
|
||||
export const COMMISSION_KEYS = ['objectSale', 'objectRent', 'clientBuyer', 'clientTenant'] as const;
|
||||
/** objectSale / objectRent — с собственника при продаже и аренде; clientBuyer / clientTenant — с покупателя и арендатора. */
|
||||
export type CommissionKey = (typeof COMMISSION_KEYS)[number];
|
||||
|
||||
export const CommissionSettings = z.object({
|
||||
objectSale: CommissionRule.nullable().default(null),
|
||||
objectRent: CommissionRule.nullable().default(null),
|
||||
clientBuyer: CommissionRule.nullable().default(null),
|
||||
clientTenant: CommissionRule.nullable().default(null),
|
||||
});
|
||||
export type CommissionSettings = z.infer<typeof CommissionSettings>;
|
||||
|
||||
/**
|
||||
* Ожидаемая комиссия по условию. Для процента нужна цена: у объекта — его цена, у клиента — бюджет.
|
||||
* null — посчитать не из чего.
|
||||
*/
|
||||
export function expectedCommission(
|
||||
rule: CommissionRule | null | undefined,
|
||||
price: number | null | undefined,
|
||||
currency: Currency | null | undefined,
|
||||
): { amount: number; currency: Currency } | null {
|
||||
if (!rule) return null;
|
||||
if (rule.mode === 'fixed') return { amount: rule.value, currency: rule.currency };
|
||||
const share = price == null ? null : Math.round(price * rule.value) / 100;
|
||||
if (rule.mode === 'mixed') {
|
||||
const fixed = rule.fixed ?? 0;
|
||||
// Цены нет — известна только фиксированная часть.
|
||||
if (share === null) return { amount: fixed, currency: rule.currency };
|
||||
// Сумма — в валюте цены: фиксированная часть приводится к ней по курсу.
|
||||
const to = currency ?? 'USD';
|
||||
const converted = to === rule.currency ? fixed : (fixed * FALLBACK_RATES[rule.currency]) / FALLBACK_RATES[to];
|
||||
return { amount: Math.round((share + converted) * 100) / 100, currency: to };
|
||||
}
|
||||
if (share === null || !(rule.value > 0)) return null;
|
||||
return { amount: share, currency: currency ?? 'USD' };
|
||||
}
|
||||
|
||||
/** Сумма в долларах — чтобы сравнивать и складывать комиссии в разных валютах. */
|
||||
export const toUsd = (amount: number, currency: Currency, rates: Rates = FALLBACK_RATES) => amount * rates[currency];
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
import { agentPhrase } from './agent-phrases.ts';
|
||||
|
||||
export const DEAL_TYPES = ['sale', 'rent'] as const;
|
||||
export type DealType = (typeof DEAL_TYPES)[number];
|
||||
|
||||
export const PROPERTY_TYPES = ['apartment', 'house', 'commercial', 'land', 'garage'] as const;
|
||||
export type PropertyType = (typeof PROPERTY_TYPES)[number];
|
||||
|
||||
export const CURRENCIES = ['USD', 'AMD', 'RUB', 'EUR'] as const;
|
||||
export type Currency = (typeof CURRENCIES)[number];
|
||||
|
||||
const range = z.number().nonnegative().optional();
|
||||
|
||||
/** Параметры фильтра (ТЗ, раздел 3). Пустое поле — «любое». */
|
||||
export const FilterParams = z.object({
|
||||
dealType: z.enum(DEAL_TYPES).optional(),
|
||||
propertyTypes: z.array(z.enum(PROPERTY_TYPES)).default([]),
|
||||
region: z.string().trim().max(60).optional(),
|
||||
districts: z.array(z.string().trim().min(1).max(60)).default([]),
|
||||
priceFrom: range,
|
||||
priceTo: range,
|
||||
/** Валюта, в которой заданы priceFrom/priceTo. */
|
||||
priceCurrency: z.enum(CURRENCIES).default('USD'),
|
||||
rooms: z.array(z.number().int().min(0).max(20)).default([]),
|
||||
areaFrom: range,
|
||||
areaTo: range,
|
||||
/** true — только собственники, false — все объявления. */
|
||||
ownersOnly: z.boolean().default(true),
|
||||
});
|
||||
export type FilterParams = z.infer<typeof FilterParams>;
|
||||
|
||||
/** Как компания определяет «агентское» объявление (ТЗ, «Собственник или агент»). */
|
||||
export const AgentRules = z.object({
|
||||
useAgencyBadge: z.boolean().default(true),
|
||||
/** Номер с более чем N активных объявлений считается агентским. */
|
||||
phoneThreshold: z.number().int().min(1).max(100).default(3),
|
||||
});
|
||||
export type AgentRules = z.infer<typeof AgentRules>;
|
||||
|
||||
export interface MatchableListing {
|
||||
dealType?: DealType | null;
|
||||
propertyType?: string | null;
|
||||
region?: string | null;
|
||||
district?: string | null;
|
||||
price?: number | null;
|
||||
currency?: Currency | null;
|
||||
rooms?: number | null;
|
||||
area?: number | null;
|
||||
isAgency: boolean;
|
||||
phone?: string | null;
|
||||
/** Заголовок и описание: по формулировкам в них видно посредника или собственника против агентов. */
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentContext {
|
||||
rules: AgentRules;
|
||||
/** Номер есть в чёрном списке компании. */
|
||||
blacklisted: boolean;
|
||||
/** Сколько активных объявлений с этим номером (0, если телефон неизвестен). */
|
||||
activeListingsForPhone: number;
|
||||
}
|
||||
|
||||
export function isAgentListing(listing: MatchableListing, ctx: AgentContext): boolean {
|
||||
if (ctx.blacklisted) return true;
|
||||
if (ctx.rules.useAgencyBadge && listing.isAgency) return true;
|
||||
if (agentPhrase(`${listing.title ?? ''}\n${listing.description ?? ''}`)) return true;
|
||||
return ctx.activeListingsForPhone > ctx.rules.phoneThreshold;
|
||||
}
|
||||
|
||||
const norm = (v: string) => v.trim().toLowerCase().replace(/[\s_-]+/g, ' ');
|
||||
|
||||
/**
|
||||
* Курсы к USD для сравнения цены объявления с границами фильтра.
|
||||
* Грубая оценка: точный курс здесь не нужен, границы фильтра — ориентир.
|
||||
*/
|
||||
export type Rates = Record<Currency, number>;
|
||||
export const FALLBACK_RATES: Rates = { USD: 1, AMD: 1 / 385, RUB: 1 / 90, EUR: 1.08 };
|
||||
|
||||
/** Подходит ли объявление под параметры фильтра. Неизвестное поле объявления не отсекает его. */
|
||||
export function matchesFilter(
|
||||
listing: MatchableListing,
|
||||
f: FilterParams,
|
||||
agent: AgentContext,
|
||||
rates: Rates = FALLBACK_RATES,
|
||||
): boolean {
|
||||
if (f.ownersOnly && isAgentListing(listing, agent)) return false;
|
||||
if (f.dealType && listing.dealType && listing.dealType !== f.dealType) return false;
|
||||
if (f.propertyTypes.length && listing.propertyType) {
|
||||
if (!(f.propertyTypes as string[]).includes(listing.propertyType)) return false;
|
||||
}
|
||||
// Регион задан в фильтре явно: объявление из другого или неопознанного региона не подходит.
|
||||
if (f.region && (!listing.region || norm(f.region) !== norm(listing.region))) return false;
|
||||
if (f.districts.length) {
|
||||
// Район задан в фильтре явно: объявление без района или с другим районом не подходит.
|
||||
if (!listing.district) return false;
|
||||
if (!f.districts.some((d) => norm(d) === norm(listing.district!))) return false;
|
||||
}
|
||||
if ((f.priceFrom !== undefined || f.priceTo !== undefined) && listing.price != null) {
|
||||
const usd = listing.price * rates[listing.currency ?? 'USD'];
|
||||
const k = rates[f.priceCurrency];
|
||||
if (f.priceFrom !== undefined && usd < f.priceFrom * k) return false;
|
||||
if (f.priceTo !== undefined && usd > f.priceTo * k) return false;
|
||||
}
|
||||
if (f.rooms.length && listing.rooms != null && !f.rooms.includes(listing.rooms)) return false;
|
||||
if (f.areaFrom !== undefined && listing.area != null && listing.area < f.areaFrom) return false;
|
||||
if (f.areaTo !== undefined && listing.area != null && listing.area > f.areaTo) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const CLIENT_KINDS = ['buyer', 'tenant'] as const;
|
||||
/** buyer — покупатель, tenant — арендатор. */
|
||||
export type ClientKind = (typeof CLIENT_KINDS)[number];
|
||||
|
||||
/** Запрос клиента: что он ищет. Те же поля, что у фильтра, без типа сделки (его задаёт тип клиента) и автора. */
|
||||
export const ClientRequest = FilterParams.omit({ dealType: true, ownersOnly: true });
|
||||
export type ClientRequest = z.infer<typeof ClientRequest>;
|
||||
|
||||
/** Подходит ли объект под запрос клиента: покупателю — продажа, арендатору — аренда. */
|
||||
export function matchesClientRequest(listing: MatchableListing, kind: ClientKind, request: Partial<ClientRequest>): boolean {
|
||||
const params = FilterParams.parse({ ...request, dealType: kind === 'buyer' ? 'sale' : 'rent', ownersOnly: false });
|
||||
return matchesFilter(listing, params, { rules: { useAgencyBadge: false, phoneThreshold: 100 }, blacklisted: false, activeListingsForPhone: 0 });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Ответ объявления на «можно с детьми» и «можно с животными». */
|
||||
export const ALLOWANCES = ['yes', 'no', 'negotiable'] as const;
|
||||
export type Allowance = (typeof ALLOWANCES)[number];
|
||||
|
||||
/** Характеристики объекта со страницы объявления: удобства и правила проживания. */
|
||||
export interface ListingFeatures {
|
||||
children?: Allowance;
|
||||
pets?: Allowance;
|
||||
/** Удобства, отмеченные в объявлении, по разделам: «Бытовая техника» → [«Холодильник», …]. */
|
||||
amenities: { group: string; items: string[] }[];
|
||||
/** Остальные характеристики парами «подпись — значение»: мебель, ремонт, балкон, предоплата… */
|
||||
attrs: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export const HOUSEHOLD_WHO = ['alone', 'friend', 'couple'] as const;
|
||||
/** alone — один, friend — с другом или соседом, couple — с партнёром (пара). */
|
||||
export type HouseholdWho = (typeof HOUSEHOLD_WHO)[number];
|
||||
|
||||
const Group = z.object({
|
||||
count: z.number().int().min(1).max(20),
|
||||
/** Возраст свободным текстом: «3 и 7 лет», «кот, 2 года». */
|
||||
age: z.string().trim().max(100).default(''),
|
||||
});
|
||||
|
||||
/** Кто будет жить: состав, дети, животные. */
|
||||
export const Household = z.object({
|
||||
who: z.enum(HOUSEHOLD_WHO).optional(),
|
||||
children: Group.optional(),
|
||||
pets: Group.optional(),
|
||||
});
|
||||
export type Household = z.infer<typeof Household>;
|
||||
|
||||
/**
|
||||
* Подходит ли объект жильцам: объявление, где прямо сказано «с детьми нельзя» или «с животными
|
||||
* нельзя», не предлагается клиенту с детьми или животными. Если в объявлении об этом ничего нет
|
||||
* или «по договорённости» — объект остаётся в подборке.
|
||||
*/
|
||||
export function fitsHousehold(features: Pick<ListingFeatures, 'children' | 'pets'> | null | undefined, household: Household | null | undefined): boolean {
|
||||
if (!features || !household) return true;
|
||||
if (household.children && features.children === 'no') return false;
|
||||
if (household.pets && features.pets === 'no') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './plans.ts';
|
||||
export * from './roles.ts';
|
||||
export * from './pipelines.ts';
|
||||
export * from './filters.ts';
|
||||
export * from './phone.ts';
|
||||
export * from './regions.ts';
|
||||
export * from './household.ts';
|
||||
export * from './commission.ts';
|
||||
export * from './pay.ts';
|
||||
export * from './agent-phrases.ts';
|
||||
export * from './rent-term.ts';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod';
|
||||
import { CURRENCIES } from './filters.ts';
|
||||
|
||||
// Вознаграждение агента. Владелец компании задаёт каждому: оклад, процент с комиссии по его
|
||||
// сделкам или оклад + процент. По этим условиям считается, кому сколько платить за период.
|
||||
|
||||
export const AgentPay = z.object({
|
||||
/** fixed — оклад; percent — процент со сделок; mixed — оклад + процент. */
|
||||
mode: z.enum(['fixed', 'percent', 'mixed']),
|
||||
/** Оклад в месяц. */
|
||||
fixed: z.number().min(0).max(100_000_000).default(0),
|
||||
/** Процент от комиссии агентства по сделкам продажи (и аренды, если для неё не задан свой). */
|
||||
percent: z.number().min(0).max(100).default(0),
|
||||
/** Процент по сделкам аренды; null — тот же, что для продажи. */
|
||||
percentRent: z.number().min(0).max(100).nullable().default(null),
|
||||
/** Валюта оклада и итоговой выплаты. */
|
||||
currency: z.enum(CURRENCIES).default('USD'),
|
||||
/** План на месяц: число сделок и сумма комиссии агента в валюте условий; null — показатель не задан. */
|
||||
planDeals: z.number().int().min(1).max(10_000).nullable().default(null),
|
||||
planCommission: z.number().positive().max(100_000_000).nullable().default(null),
|
||||
/** Бонус за выполненный план: fixed — сумма; percent — надбавка к проценту на всю комиссию агента за месяц. */
|
||||
bonusMode: z.enum(['none', 'fixed', 'percent']).default('none'),
|
||||
bonusValue: z.number().min(0).max(100_000_000).default(0),
|
||||
});
|
||||
export type AgentPay = z.infer<typeof AgentPay>;
|
||||
|
||||
/** Сколько месяцев в периоде: полный календарный месяц — 1, половина — 0,5. Даты — «ГГГГ-ММ-ДД», включительно. */
|
||||
export function monthsBetween(from: string, to: string): number {
|
||||
const day = (s: string) => new Date(`${s}T00:00:00Z`);
|
||||
const end = day(to);
|
||||
let total = 0;
|
||||
for (let cursor = day(from); cursor <= end; ) {
|
||||
const y = cursor.getUTCFullYear();
|
||||
const m = cursor.getUTCMonth();
|
||||
const daysInMonth = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
|
||||
const last = new Date(Math.min(Date.UTC(y, m, daysInMonth), end.getTime()));
|
||||
total += (last.getUTCDate() - cursor.getUTCDate() + 1) / daysInMonth;
|
||||
cursor = new Date(Date.UTC(y, m + 1, 1));
|
||||
}
|
||||
return Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
/** Вид сделки: от него зависит процент агента. */
|
||||
export const DEAL_KINDS = ['sale', 'rent'] as const;
|
||||
export type DealKind = (typeof DEAL_KINDS)[number];
|
||||
|
||||
/** Процент агента по сделке данного вида; 0 — процента в условиях нет. */
|
||||
export function payPercent(pay: AgentPay | null | undefined, kind: DealKind): number {
|
||||
if (!pay || pay.mode === 'fixed') return 0;
|
||||
return kind === 'rent' ? (pay.percentRent ?? pay.percent) : pay.percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выплата агенту за период в валюте его условий: оклад за число месяцев и процент с его части
|
||||
* комиссии по сделкам продажи и аренды (`commission` — уже в валюте условий).
|
||||
*/
|
||||
export function agentPayout(pay: AgentPay | null | undefined, commission: Record<DealKind, number>, months: number, deals = 0) {
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
const fixed = pay && pay.mode !== 'percent' ? round(pay.fixed * months) : 0;
|
||||
const shareSale = round((commission.sale * payPercent(pay, 'sale')) / 100);
|
||||
const shareRent = round((commission.rent * payPercent(pay, 'rent')) / 100);
|
||||
const share = round(shareSale + shareRent);
|
||||
const all = commission.sale + commission.rent;
|
||||
const planDone = planIsDone(pay, deals, all, months);
|
||||
const bonus = !pay || !planDone || pay.bonusMode === 'none' ? 0 : round(pay.bonusMode === 'fixed' ? pay.bonusValue : (all * pay.bonusValue) / 100);
|
||||
return { fixed, shareSale, shareRent, share, planDone, bonus, total: round(fixed + share + bonus) };
|
||||
}
|
||||
|
||||
/** План выполнен, когда достигнуты все заданные показатели; null — плана нет. За период из нескольких месяцев план кратно больше. */
|
||||
export function planIsDone(pay: AgentPay | null | undefined, deals: number, commission: number, months: number): boolean | null {
|
||||
if (!pay || (pay.planDeals == null && pay.planCommission == null)) return null;
|
||||
const dealsOk = pay.planDeals == null || deals >= pay.planDeals * months;
|
||||
const commissionOk = pay.planCommission == null || commission >= pay.planCommission * months;
|
||||
return dealsOk && commissionOk;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Приводит телефон к виду +374XXXXXXXX. Возвращает null, если это не похоже на номер. */
|
||||
export function normalizePhone(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
let d = raw.replace(/\D/g, '');
|
||||
if (!d) return null;
|
||||
if (d.startsWith('00')) d = d.slice(2);
|
||||
// Армянский номер в местной записи: 0XX XXXXXX или XX XXXXXX
|
||||
if (d.length === 9 && d.startsWith('0')) d = '374' + d.slice(1);
|
||||
else if (d.length === 8) d = '374' + d;
|
||||
if (d.length < 10 || d.length > 15) return null;
|
||||
return '+' + d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Насколько можно доверять номеру, который агенты внесли по объявлению: один агент — не подтверждён,
|
||||
* совпал у двух компаний — 80 %, у трёх и больше — 100 %.
|
||||
*/
|
||||
export function phoneConfidence(votes: number): number | null {
|
||||
if (votes >= 3) return 100;
|
||||
if (votes === 2) return 80;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const PIPELINES = ['object', 'client'] as const;
|
||||
/** object — воронка объекта (сторона собственника); client — воронка клиента (покупатель/арендатор). */
|
||||
export type Pipeline = (typeof PIPELINES)[number];
|
||||
|
||||
export interface DefaultStatus {
|
||||
/** Код есть только у системных статусов: на них завязаны пул, аналитика и биллинг. */
|
||||
code?: string;
|
||||
name: { ru: string; hy: string; en: string };
|
||||
/** Взятый лид в этом статусе возвращается в пул, если статус долго не меняется (этапы до «Договор»). */
|
||||
poolReturn?: boolean;
|
||||
}
|
||||
|
||||
export const SYSTEM_CODES = {
|
||||
object: ['new', 'taken', 'deal', 'rejected'],
|
||||
client: ['new', 'deal', 'rejected'],
|
||||
} as const satisfies Record<Pipeline, readonly string[]>;
|
||||
|
||||
export const DEFAULT_STATUSES: Record<Pipeline, DefaultStatus[]> = {
|
||||
object: [
|
||||
{ code: 'new', name: { ru: 'Новый', hy: 'Նոր', en: 'New' } },
|
||||
{ code: 'taken', poolReturn: true, name: { ru: 'Взят в работу', hy: 'Վերցված է', en: 'Taken' } },
|
||||
{ poolReturn: true, name: { ru: 'Дозвонился', hy: 'Զանգը կայացավ', en: 'Reached' } },
|
||||
{ poolReturn: true, name: { ru: 'Встреча с собственником', hy: 'Հանդիպում սեփականատիրոջ հետ', en: 'Owner meeting' } },
|
||||
{ name: { ru: 'Договор (эксклюзив)', hy: 'Պայմանագիր (էքսկլյուզիվ)', en: 'Contract (exclusive)' } },
|
||||
{ name: { ru: 'В продаже (идут показы)', hy: 'Վաճառքում (ցուցադրումներ)', en: 'On sale (showings)' } },
|
||||
{ code: 'deal', name: { ru: 'Сделка', hy: 'Գործարք', en: 'Deal' } },
|
||||
{ code: 'rejected', name: { ru: 'Отказ', hy: 'Մերժում', en: 'Rejected' } },
|
||||
],
|
||||
client: [
|
||||
{ code: 'new', name: { ru: 'Новый запрос', hy: 'Նոր հարցում', en: 'New request' } },
|
||||
{ name: { ru: 'Квалификация', hy: 'Որակավորում', en: 'Qualification' } },
|
||||
{ name: { ru: 'Подбор объектов', hy: 'Օբյեկտների ընտրություն', en: 'Selection' } },
|
||||
{ name: { ru: 'Показ', hy: 'Ցուցադրում', en: 'Showing' } },
|
||||
{ name: { ru: 'Переговоры и задаток', hy: 'Բանակցություններ և կանխավճար', en: 'Negotiation & deposit' } },
|
||||
{ code: 'deal', name: { ru: 'Сделка', hy: 'Գործարք', en: 'Deal' } },
|
||||
{ code: 'rejected', name: { ru: 'Отказ', hy: 'Մերժում', en: 'Rejected' } },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* code: 'agent' — системная причина, её ставит кнопка «Это агент»;
|
||||
* code: 'gone' — её ставит CRM, когда объявление сняли с List.am.
|
||||
*/
|
||||
export const DEFAULT_REJECT_REASONS: Record<Pipeline, { code?: string; ru: string; hy: string; en: string }[]> = {
|
||||
object: [
|
||||
{ code: 'agent', ru: 'Это агент', hy: 'Սա գործակալ է', en: 'It is an agent' },
|
||||
{ ru: 'Не выходит на связь', hy: 'Կապի չի դուրս գալիս', en: 'No answer' },
|
||||
{ ru: 'Не хочет работать с агентством', hy: 'Չի ցանկանում աշխատել գործակալության հետ', en: 'Refuses agencies' },
|
||||
{ code: 'gone', ru: 'Уже продано или сдано', hy: 'Արդեն վաճառված կամ վարձակալված է', en: 'Already sold or rented' },
|
||||
{ ru: 'Завышенная цена', hy: 'Բարձր գին', en: 'Overpriced' },
|
||||
{ ru: 'Другое', hy: 'Այլ', en: 'Other' },
|
||||
],
|
||||
client: [
|
||||
{ ru: 'Не выходит на связь', hy: 'Կապի չի դուրս գալիս', en: 'No answer' },
|
||||
{ ru: 'Купил или снял сам', hy: 'Գնել կամ վարձել է ինքնուրույն', en: 'Found on their own' },
|
||||
{ ru: 'Не подошёл бюджет', hy: 'Բյուջեն չի համապատասխանում', en: 'Budget mismatch' },
|
||||
{ ru: 'Передумал', hy: 'Մտափոխվել է', en: 'Changed mind' },
|
||||
{ ru: 'Другое', hy: 'Այլ', en: 'Other' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Правило возврата в пул действует для статусов до «Договор» — то есть первых N позиций воронки объекта. */
|
||||
export const POOL_RETURN_DEFAULT_HOURS = 24;
|
||||
export const POOL_WARN_BEFORE_HOURS = 2;
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Тарифы (ТЗ, раздел 2). Лимиты и модули проверяются на сервере. */
|
||||
export const PLANS = ['solo', 'team', 'agency'] as const;
|
||||
export type Plan = (typeof PLANS)[number];
|
||||
|
||||
export const BILLING_MODELS = ['fixed', 'percent'] as const;
|
||||
export type BillingModel = (typeof BILLING_MODELS)[number];
|
||||
|
||||
export const MODULES = ['web', 'analytics', 'archive'] as const;
|
||||
/** web — веб-кабинет; analytics — аналитика по сотрудникам; archive — база бывших клиентов и объектов. */
|
||||
export type Module = (typeof MODULES)[number];
|
||||
|
||||
export interface PlanSpec {
|
||||
maxMembers: number;
|
||||
setupFeeUsd: number;
|
||||
monthlyUsd: number;
|
||||
/** Процент от комиссии по сделкам; null — модель «%» недоступна. */
|
||||
commissionPercent: number | null;
|
||||
modules: readonly Module[];
|
||||
}
|
||||
|
||||
export const PLAN_SPECS: Record<Plan, PlanSpec> = {
|
||||
solo: { maxMembers: 1, setupFeeUsd: 0, monthlyUsd: 40, commissionPercent: null, modules: [] },
|
||||
team: {
|
||||
maxMembers: 10,
|
||||
setupFeeUsd: 500,
|
||||
monthlyUsd: 350,
|
||||
commissionPercent: 10,
|
||||
modules: ['web', 'analytics', 'archive'],
|
||||
},
|
||||
agency: {
|
||||
maxMembers: 35,
|
||||
setupFeeUsd: 750,
|
||||
monthlyUsd: 550,
|
||||
commissionPercent: 7,
|
||||
modules: ['web', 'analytics', 'archive'],
|
||||
},
|
||||
};
|
||||
|
||||
export const hasModule = (plan: Plan, module: Module) => PLAN_SPECS[plan].modules.includes(module);
|
||||
|
||||
/** Solo всегда на фиксе; у остальных модель выбирается. */
|
||||
export const allowedBillingModels = (plan: Plan): readonly BillingModel[] =>
|
||||
PLAN_SPECS[plan].commissionPercent === null ? ['fixed'] : BILLING_MODELS;
|
||||
|
||||
/** Срок отсрочки по умолчанию: столько дней после окончания подписки сервис ещё работает. */
|
||||
export const DEFAULT_GRACE_DAYS = 15;
|
||||
|
||||
export const ACCESS_STATES = ['active', 'overdue', 'restricted', 'disabled'] as const;
|
||||
/**
|
||||
* active — подписка действует; overdue — просрочена, сервис работает;
|
||||
* restricted — отсрочка истекла: без уведомлений о лидах, CRM только на чтение;
|
||||
* disabled — доступ выключен суперадмином либо компания ещё не оплачена.
|
||||
*/
|
||||
export type AccessState = (typeof ACCESS_STATES)[number];
|
||||
|
||||
export function accessState(
|
||||
company: { accessEnabled: boolean; subscriptionEndsAt: Date | null; graceDays: number },
|
||||
now = new Date(),
|
||||
): AccessState {
|
||||
if (!company.accessEnabled || !company.subscriptionEndsAt) return 'disabled';
|
||||
const end = company.subscriptionEndsAt.getTime();
|
||||
if (now.getTime() <= end) return 'active';
|
||||
return now.getTime() <= end + company.graceDays * 86_400_000 ? 'overdue' : 'restricted';
|
||||
}
|
||||
|
||||
export const canWrite = (state: AccessState) => state === 'active' || state === 'overdue';
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Регионы Армении: Ереван и десять областей (марзов). Названия — как в фильтрах и карточках. */
|
||||
export const REGIONS = [
|
||||
'Ереван', 'Арагацотн', 'Арарат', 'Армавир', 'Гегаркуник', 'Котайк', 'Лори', 'Ширак', 'Сюник', 'Тавуш', 'Вайоц Дзор',
|
||||
] as const;
|
||||
export type Region = (typeof REGIONS)[number];
|
||||
|
||||
export const YEREVAN_DISTRICTS = [
|
||||
'Аван', 'Аджапняк', 'Арабкир', 'Давташен', 'Канакер-Зейтун', 'Кентрон', 'Малатия-Себастия',
|
||||
'Нор-Норк', 'Норк-Мараш', 'Нубарашен', 'Шенгавит', 'Эребуни',
|
||||
] as const;
|
||||
|
||||
/** Города и крупные сёла → область. Населённого пункта нет в списке — область объявления неизвестна. */
|
||||
const LOCALITIES: Record<Exclude<Region, 'Ереван'>, string[]> = {
|
||||
Арагацотн: ['Аштарак', 'Апаран', 'Талин', 'Ошакан', 'Бюракан', 'Агарак', 'Уши', 'Кош', 'Арагац', 'Цахкаовит', 'Парпи', 'Сагмосаван', 'Карби', 'Мугни'],
|
||||
Арарат: ['Арташат', 'Арарат', 'Масис', 'Веди', 'Мармарашен', 'Айнтап', 'Нор Харберд', 'Геташен', 'Даштаван', 'Аревшат', 'Мхчян', 'Норабац', 'Овташат', 'Дарбник', 'Гукасаван', 'Хачпар', 'Азатаван', 'Арбат', 'Нор Кюрин', 'Покр Веди'],
|
||||
Армавир: ['Армавир', 'Вагаршапат', 'Эчмиадзин', 'Мецамор', 'Паракар', 'Мусалер', 'Мердзаван', 'Таирoв', 'Таиров', 'Норакерт', 'Баграмян', 'Аракс', 'Джрарат', 'Каракерт', 'Мясникян', 'Птгунк', 'Аревашат', 'Айгек', 'Цахкунк', 'Звартноц', 'Акналич', 'Гай'],
|
||||
Гегаркуник: ['Гавар', 'Севан', 'Мартуни', 'Варденис', 'Чамбарак', 'Цовагюх', 'Лчашен', 'Сарухан', 'Норатус', 'Варденик', 'Личк', 'Шогакат'],
|
||||
Котайк: ['Абовян', 'Раздан', 'Чаренцаван', 'Егвард', 'Бюрегаван', 'Нор Ачин', 'Цахкадзор', 'Арзни', 'Гарни', 'Гегард', 'Джрвеж', 'Зовуни', 'Прошян', 'Касах', 'Нор Гехи', 'Птгни', 'Балаовит', 'Мaйаковски', 'Маяковский', 'Арамус', 'Аринж', 'Дзорахбюр', 'Вохчаберд', 'Камарис', 'Гехашен', 'Арагюх', 'Канакераван', 'Нор Ерзнка', 'Арзакан', 'Бжни', 'Солак', 'Акунк', 'Гетамедж', 'Мрграшен', 'Кармир Блур', 'Котайк', 'Нурнус', 'Аван дзор'],
|
||||
Лори: ['Ванадзор', 'Алаверди', 'Степанаван', 'Спитак', 'Ташир', 'Ахтала', 'Туманян', 'Шамлуг', 'Одзун', 'Дсех', 'Маргаовит'],
|
||||
Ширак: ['Гюмри', 'Артик', 'Маралик', 'Ахурян', 'Ани', 'Азатан', 'Арапи', 'Ашоцк', 'Амасия'],
|
||||
Сюник: ['Капан', 'Горис', 'Сисиан', 'Мегри', 'Каджаран', 'Агарак Сюник', 'Дастакерт', 'Татев', 'Хндзореск'],
|
||||
Тавуш: ['Иджеван', 'Дилижан', 'Берд', 'Ноемберян', 'Айрум', 'Енокаван', 'Гош', 'Агарцин'],
|
||||
'Вайоц Дзор': ['Ехегнадзор', 'Вайк', 'Джермук', 'Арени', 'Малишка', 'Гладзор'],
|
||||
};
|
||||
|
||||
const key = (v: string) => v.trim().toLowerCase().replaceAll('ё', 'е').replace(/[\s_-]+/g, ' ');
|
||||
|
||||
const BY_LOCALITY = new Map<string, Region>();
|
||||
for (const [region, names] of Object.entries(LOCALITIES)) {
|
||||
for (const name of names) if (!BY_LOCALITY.has(key(name))) BY_LOCALITY.set(key(name), region as Region);
|
||||
}
|
||||
|
||||
/** Область по названию населённого пункта; null — пункт неизвестен. */
|
||||
export function regionOfLocality(locality: string | null | undefined): Region | null {
|
||||
if (!locality) return null;
|
||||
const k = key(locality);
|
||||
if (k === 'ереван' || k === 'երևան' || k === 'yerevan') return 'Ереван';
|
||||
return BY_LOCALITY.get(k) ?? null;
|
||||
}
|
||||
|
||||
/** Названия из справочника, от длинных к коротким: «Нор Харберд» проверяется раньше, чем «Нор». */
|
||||
const NAMES = [...BY_LOCALITY.keys()].sort((a, b) => b.length - a.length);
|
||||
|
||||
/**
|
||||
* Населённый пункт в конце строки (так заканчивается карточка объявления list.am:
|
||||
* «… 87 кв.м. Мецамор»). Возвращает название как в справочнике и его область.
|
||||
*/
|
||||
export function trailingLocality(text: string): { locality: string; region: Region } | null {
|
||||
const t = key(text);
|
||||
for (const name of NAMES) {
|
||||
if (t === name || t.endsWith(` ${name}`)) {
|
||||
const original = Object.values(LOCALITIES).flat().find((n) => key(n) === name)!;
|
||||
return { locality: original, region: BY_LOCALITY.get(name)! };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Срок аренды, который нужен клиенту: до 6 месяцев, от 6 до 12, от года. */
|
||||
export const RENT_TERMS = ['short', 'mid', 'long'] as const;
|
||||
export type RentTerm = (typeof RENT_TERMS)[number];
|
||||
@@ -0,0 +1,6 @@
|
||||
export const ROLES = ['superadmin', 'manager', 'agent'] as const;
|
||||
/** superadmin — владелец сервиса; manager — руководитель компании; agent — агент. */
|
||||
export type Role = (typeof ROLES)[number];
|
||||
|
||||
export const LANGS = ['ru', 'hy', 'en'] as const;
|
||||
export type Lang = (typeof LANGS)[number];
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accessState, allowedBillingModels, FilterParams, hasModule, matchesFilter, normalizePhone, regionOfLocality } from './index.ts';
|
||||
|
||||
const day = 86_400_000;
|
||||
const ctx = { rules: { useAgencyBadge: true, phoneThreshold: 3 }, blacklisted: false, activeListingsForPhone: 0 };
|
||||
const listing = {
|
||||
dealType: 'sale' as const, propertyType: 'apartment', district: 'Кентрон',
|
||||
price: 150_000, currency: 'USD' as const, rooms: 3, area: 80, isAgency: false,
|
||||
};
|
||||
|
||||
describe('тарифы и доступ', () => {
|
||||
it('Solo не видит аналитику и базу и всегда на фиксе', () => {
|
||||
expect(hasModule('solo', 'analytics')).toBe(false);
|
||||
expect(hasModule('solo', 'archive')).toBe(false);
|
||||
expect(hasModule('team', 'analytics')).toBe(true);
|
||||
expect(allowedBillingModels('solo')).toEqual(['fixed']);
|
||||
});
|
||||
|
||||
it('15 дней отсрочки, на 16-й доступ ограничен', () => {
|
||||
const end = new Date('2026-10-01T00:00:00Z');
|
||||
const c = { accessEnabled: true, subscriptionEndsAt: end, graceDays: 15 };
|
||||
expect(accessState(c, new Date(end.getTime() - day))).toBe('active');
|
||||
expect(accessState(c, new Date(end.getTime() + 15 * day))).toBe('overdue');
|
||||
expect(accessState(c, new Date(end.getTime() + 15 * day + 1))).toBe('restricted');
|
||||
expect(accessState({ ...c, accessEnabled: false }, end)).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('фильтры', () => {
|
||||
const f = (p: object) => FilterParams.parse(p);
|
||||
|
||||
it('район, цена, комнаты', () => {
|
||||
expect(matchesFilter(listing, f({ districts: ['кентрон'], priceTo: 200_000, rooms: [2, 3] }), ctx)).toBe(true);
|
||||
expect(matchesFilter(listing, f({ districts: ['Арабкир'] }), ctx)).toBe(false);
|
||||
expect(matchesFilter(listing, f({ priceTo: 100_000 }), ctx)).toBe(false);
|
||||
expect(matchesFilter(listing, f({ rooms: [1] }), ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('цена в драмах сравнивается с границей в долларах', () => {
|
||||
const amd = { ...listing, price: 38_500_000, currency: 'AMD' as const };
|
||||
expect(matchesFilter(amd, f({ priceTo: 110_000 }), ctx)).toBe(true);
|
||||
expect(matchesFilter(amd, f({ priceTo: 90_000 }), ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('«только собственники»: метка, порог N и чёрный список', () => {
|
||||
const owners = f({});
|
||||
expect(matchesFilter({ ...listing, isAgency: true }, owners, ctx)).toBe(false);
|
||||
expect(matchesFilter(listing, owners, { ...ctx, activeListingsForPhone: 3 })).toBe(true);
|
||||
expect(matchesFilter(listing, owners, { ...ctx, activeListingsForPhone: 4 })).toBe(false);
|
||||
expect(matchesFilter(listing, owners, { ...ctx, blacklisted: true })).toBe(false);
|
||||
expect(matchesFilter({ ...listing, isAgency: true }, f({ ownersOnly: false }), ctx)).toBe(true);
|
||||
const noBadge = { ...ctx, rules: { useAgencyBadge: false, phoneThreshold: 3 } };
|
||||
expect(matchesFilter({ ...listing, isAgency: true }, owners, noBadge)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('регионы', () => {
|
||||
it('область по населённому пункту', () => {
|
||||
expect(regionOfLocality('Мецамор')).toBe('Армавир');
|
||||
expect(regionOfLocality(' абовян ')).toBe('Котайк');
|
||||
expect(regionOfLocality('Ереван')).toBe('Ереван');
|
||||
expect(regionOfLocality('Неизвестное село')).toBeNull();
|
||||
});
|
||||
|
||||
it('фильтр по региону отсекает чужой и неопознанный регион', () => {
|
||||
const f = FilterParams.parse({ region: 'Ереван' });
|
||||
expect(matchesFilter({ ...listing, region: 'Ереван' }, f, ctx)).toBe(true);
|
||||
expect(matchesFilter({ ...listing, region: 'Котайк' }, f, ctx)).toBe(false);
|
||||
expect(matchesFilter({ ...listing, region: null }, f, ctx)).toBe(false);
|
||||
expect(matchesFilter({ ...listing, region: null }, FilterParams.parse({}), ctx)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('телефон', () => {
|
||||
it('нормализация армянских номеров', () => {
|
||||
expect(normalizePhone('091 12-34-56')).toBe('+37491123456');
|
||||
expect(normalizePhone('+374 77 408332')).toBe('+37477408332');
|
||||
expect(normalizePhone('abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('формулировки посредника в объявлении', () => {
|
||||
it('брокер и «не работаю с агентами» находятся, обычный текст — нет', async () => {
|
||||
const { agentPhrase } = await import('./index.ts');
|
||||
expect(agentPhrase('Сдаётся квартира. Услуги агентства 50%')?.kind).toBe('broker');
|
||||
expect(agentPhrase('Комиссия 50% от месячной оплаты')?.kind).toBe('broker');
|
||||
expect(agentPhrase('Агентам просьба не беспокоить!')).toMatchObject({ kind: 'noAgents', phrase: 'Агентам просьба не беспокоить' });
|
||||
expect(agentPhrase('Сдаю от собственника, без посредников')?.kind).toBe('noAgents');
|
||||
expect(agentPhrase('Риелторам не звонить')?.kind).toBe('noAgents');
|
||||
expect(agentPhrase('Միջնորդներ չանհանգստացնել')?.kind).toBe('noAgents');
|
||||
expect(agentPhrase('Միջնորդավճար 50%')?.kind).toBe('broker');
|
||||
expect(agentPhrase('Светлая квартира, рядом метро, без мебели. Не агентство.')).toBeNull();
|
||||
expect(agentPhrase('Без агентской комиссии')?.kind).toBe('broker');
|
||||
// Реальные формулировки с list.am.
|
||||
for (const text of ['БРОКЕРАМ НИ ПРИ КАКИХ УСЛОВИЯХ НЕ ЗВОНИТЬ', 'Брокеры, мне не звоните.', 'С брокерами не работаем!',
|
||||
'С риелторскими агентствами не сотрудничаю.', 'Посредников, риелторов агентств просим не беспокоить', 'Без брокеров и посредников.',
|
||||
'Միջնորդները չզանգահարեն։', 'Անշարժ գույքի Գործակալությունից Չզանգահարել։', 'Սիրելի գործակալներ, չեմ համագործակցում։', 'Առանց միջնորդի']) {
|
||||
expect(agentPhrase(text)?.kind, text).toBe('noAgents');
|
||||
}
|
||||
// «Не агентство», «без комиссии» пишет собственник — это не повод отсекать объявление.
|
||||
for (const text of ['ԳՈՐԾԱԿԱԼՈՒԹՅՈՒՆ ՉԻ։', 'Գործակալություն չի! Սեփականատերն եմ!', 'Միջնորդավճար չկա։', 'Direct from owner — no agency fee.', 'Անմիջապես սեփականատիրոջից՝ առանց միջնորդավճարի։']) {
|
||||
expect(agentPhrase(text), text).toBeNull();
|
||||
}
|
||||
expect(agentPhrase('ամսեվճարի գինը կթանգանա միջնորդավճարի չափով')?.kind).toBe('broker');
|
||||
});
|
||||
|
||||
it('объявление с такой формулировкой не проходит фильтр «только собственники»', () => {
|
||||
const f = FilterParams.parse({ ownersOnly: true });
|
||||
expect(matchesFilter({ ...listing, description: 'Агентам не беспокоить' }, f, ctx)).toBe(false);
|
||||
expect(matchesFilter({ ...listing, description: 'Хорошая квартира' }, f, ctx)).toBe(true);
|
||||
expect(matchesFilter({ ...listing, description: 'Агентам не беспокоить' }, FilterParams.parse({ ownersOnly: false }), ctx)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "allowImportingTsExtensions": true }, "include": ["src"] }
|
||||
Reference in new issue
Block a user