Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
{
"name": "@barev/web",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/i18n": "workspace:*",
"@tanstack/react-query": "^5.104.0",
"@trpc/client": "^11.19.0",
"@trpc/tanstack-react-query": "^11.19.0",
"superjson": "^2.2.6",
"@barev/ui": "workspace:*"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@barev/api": "workspace:*",
"@types/react": "^19.2.0",
"react": "^19.2.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
/** Даты по Еревану (UTC+4, без перехода на летнее время) для выбора слотов */
const OFFSET = 4 * 3600_000;
export function yerevanDateKey(d: Date): string {
return new Date(d.getTime() + OFFSET).toISOString().slice(0, 10);
}
/** Ключи следующих n дней, начиная с сегодня по Еревану */
export function nextDays(n: number, from = new Date()): string[] {
const out: string[] = [];
for (let i = 0; i < n; i++) out.push(yerevanDateKey(new Date(from.getTime() + i * 86_400_000)));
return out;
}
/** Полдень дня — чтобы форматировать дату без сдвига */
export const dateKeyToDate = (key: string) => new Date(`${key}T12:00:00+04:00`);
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createT, formatAmd, formatDateTime, isLocale, type Locale, type TFunction } from '@barev/i18n';
import { reportClientError } from './telegram.ts';
interface I18nValue {
locale: Locale;
t: TFunction;
setLocale: (l: Locale) => void;
amd: (n: number) => string;
dt: (d: Date, opts?: Intl.DateTimeFormatOptions) => string;
}
const Ctx = createContext<I18nValue | null>(null);
export function pickLocale(...candidates: (string | null | undefined)[]): Locale {
for (const c of candidates) {
const base = c?.split('-')[0]?.toLowerCase();
if (isLocale(base)) return base;
}
return 'ru';
}
export function I18nProvider({ initial, children }: { initial: Locale; children: ReactNode }) {
const [locale, setLocale] = useState<Locale>(initial);
useEffect(() => setLocale(initial), [initial]);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
const value = useMemo<I18nValue>(
() => ({
locale,
t: createT(locale),
setLocale,
amd: (n) => formatAmd(locale, n),
dt: (d, opts) => formatDateTime(locale, d, opts),
}),
[locale],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useI18n(): I18nValue {
const v = useContext(Ctx);
if (!v) throw new Error('I18nProvider missing');
return v;
}
/** Текст ошибки API: errors.<код бизнес-ошибки> или общий */
export function errorText(t: TFunction, err: unknown): string {
reportClientError(err, 'errorText');
const e = err as { data?: { domainCode?: string | null; code?: string }; message?: string } | null;
const code = e?.data?.domainCode ?? e?.message;
if (code && /^[A-Z0-9_]+$/.test(code)) {
const s = t(`errors.${code}`);
if (s !== `errors.${code}`) return s;
}
return t('errors.generic');
}
+7
View File
@@ -0,0 +1,7 @@
export * from './telegram.ts';
export * from './router.tsx';
export * from './i18n.tsx';
export * from './trpc.tsx';
export * from './toast.tsx';
export * from './dates.ts';
export * from './params.ts';
+19
View File
@@ -0,0 +1,19 @@
import type { TFunction } from '@barev/i18n';
/** Краткое описание параметров заказа: «Генеральная · 2 комнаты · 2 санузла» */
export function paramsSummary(t: TFunction, serviceCode: string, params: Record<string, unknown>): string {
if (serviceCode !== 'cleaning') return '';
const parts = [t(`params.cleaning.cleaningType.${String(params.cleaningType)}`), t('params.cleaning.rooms.count', { count: Number(params.rooms ?? 1) })];
const baths = Number(params.bathrooms ?? 1);
if (baths > 1) parts.push(t('params.cleaning.bathrooms.count', { count: baths }));
return parts.join(' · ');
}
/** Выбранные доп. услуги: [{ code, qty, label: «Мытьё окон × 3» }] */
export function paramsExtras(t: TFunction, params: Record<string, unknown>) {
const extras = params.extras;
if (!extras || typeof extras !== 'object') return [];
return Object.entries(extras as Record<string, number>)
.filter(([, qty]) => qty > 0)
.map(([code, qty]) => ({ code, qty, label: `${t(`extras.${code}.name`)}${qty > 1 ? ` × ${qty}` : ''}` }));
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useSyncExternalStore } from 'react';
import { tg } from './telegram.ts';
/**
* Минимальный hash-роутер: Mini App открывается по одному URL, а hash переживает
* перезагрузку WebView и не требует настройки сервера.
*/
function subscribe(cb: () => void) {
window.addEventListener('hashchange', cb);
return () => window.removeEventListener('hashchange', cb);
}
// Telegram открывает Mini App с #tgWebAppData=… — это не маршрут, а параметры запуска
const snapshot = () => {
const h = window.location.hash.replace(/^#/, '');
return h.startsWith('/') ? h : '/';
};
export function useLocation() {
const raw = useSyncExternalStore(subscribe, snapshot, () => '/');
const [path, search = ''] = raw.split('?');
return { path: path || '/', query: new URLSearchParams(search) };
}
export function navigate(to: string, opts: { replace?: boolean } = {}) {
if (opts.replace) window.location.replace(`#${to}`);
else window.location.hash = to;
}
/** Сопоставление '/orders/:id' → { id } */
export function match(pattern: string, path: string): Record<string, string> | null {
const p = pattern.split('/').filter(Boolean);
const a = path.split('/').filter(Boolean);
if (p.length !== a.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < p.length; i++) {
if (p[i]!.startsWith(':')) params[p[i]!.slice(1)] = decodeURIComponent(a[i]!);
else if (p[i] !== a[i]) return null;
}
return params;
}
/** Кнопка «Назад» Telegram на всех экранах, кроме корневых */
export function useTelegramBack(path: string, roots: string[]) {
useEffect(() => {
const back = tg()?.BackButton;
if (!back) return;
const onBack = () => window.history.back();
if (roots.includes(path)) back.hide();
else {
back.show();
back.onClick(onBack);
}
return () => back.offClick(onBack);
}, [path, roots]);
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Telegram WebApp (скрипт telegram-web-app.js подключён в index.html).
* Вне Telegram (локальная разработка) initData берётся из #tgWebAppData=… — см. pnpm dev:initdata.
*/
interface TgWebApp {
initData: string;
initDataUnsafe: { user?: { id: number; first_name?: string; language_code?: string }; start_param?: string };
ready(): void;
expand(): void;
close(): void;
setHeaderColor?(color: string): void;
setBackgroundColor?(color: string): void;
BackButton: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
HapticFeedback?: { impactOccurred(s: 'light' | 'medium' | 'heavy'): void; notificationOccurred(s: 'success' | 'error' | 'warning'): void };
showConfirm?(message: string, cb: (ok: boolean) => void): void;
openLink?(url: string): void;
openTelegramLink?(url: string): void;
showScanQrPopup?(params: { text?: string }, cb: (text: string) => boolean | void): void;
closeScanQrPopup?(): void;
platform?: string;
}
declare global {
interface Window {
Telegram?: { WebApp?: TgWebApp };
}
}
export const tg = (): TgWebApp | undefined => (typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined);
/** Сканер QR Telegram (только внутри Telegram 6.4+); null — сканера нет, например в браузере */
export function scanQr(text: string): Promise<string> | null {
const app = tg();
if (!app?.showScanQrPopup || !app.initData) return null;
return new Promise((resolve) => {
app.showScanQrPopup!({ text }, (value) => {
resolve(value);
return true;
});
});
}
/** Ссылка t.me внутри Telegram открывает чат без выхода из Mini App; в браузере — новая вкладка */
export function openTelegram(url: string) {
const app = tg();
if (app?.openTelegramLink && app.initData) app.openTelegramLink(url);
else window.open(url, '_blank', 'noopener');
}
const DEV_KEY = 'barev.dev.initData';
/** initData для заголовка Authorization: tma … */
export function getInitData(): string {
const real = tg()?.initData;
if (real) {
// telegram-web-app.js уже прочитал параметры запуска — убираем их из адреса
if (window.location.hash.includes('tgWebAppData')) window.history.replaceState(null, '', window.location.pathname + window.location.search + '#/');
return real;
}
// разработка: #tgWebAppData=<urlencoded initData>, запоминаем на вкладку
const m = /tgWebAppData=([^&]+)/.exec(window.location.hash);
if (m) {
const data = decodeURIComponent(m[1]!);
try {
sessionStorage.setItem(DEV_KEY, data);
} catch {
/* приватный режим */
}
window.history.replaceState(null, '', window.location.pathname + '#/');
return data;
}
try {
return sessionStorage.getItem(DEV_KEY) ?? '';
} catch {
return '';
}
}
/** Маршрут из ?r=/orders/123 — так ведут кнопки в уведомлениях (hash занят параметрами Telegram) */
function applyStartRoute() {
const params = new URLSearchParams(window.location.search);
const r = params.get('r');
if (!r?.startsWith('/')) return;
params.delete('r');
const qs = params.size ? `?${params}` : '';
window.history.replaceState(null, '', `${window.location.pathname}${qs}#${r}`);
}
export function initTelegram(bg = '#FBF6EE') {
installErrorReporting();
getInitData();
applyStartRoute();
const app = tg();
if (!app) return;
app.ready();
app.expand();
app.setHeaderColor?.(bg);
app.setBackgroundColor?.(bg);
}
/** Язык пользователя Telegram — пока API не ответило */
export function telegramLanguage(): string | undefined {
const fromTg = tg()?.initDataUnsafe.user?.language_code;
if (fromTg) return fromTg;
try {
const user = new URLSearchParams(getInitData()).get('user');
return user ? (JSON.parse(user) as { language_code?: string }).language_code : undefined;
} catch {
return undefined;
}
}
/** Виброотклик. Не во всех клиентах Telegram он поддерживается — ошибки глушим, это не повод ломать сценарий */
function safe(fn: () => void) {
try {
fn();
} catch {
/* метод не поддерживается этой версией Telegram */
}
}
export const haptic = {
tap: () => safe(() => tg()?.HapticFeedback?.impactOccurred('light')),
ok: () => safe(() => tg()?.HapticFeedback?.notificationOccurred('success')),
error: () => safe(() => tg()?.HapticFeedback?.notificationOccurred('error')),
};
/** Подтверждение: нативное в Telegram, window.confirm в браузере */
export function confirmDialog(message: string): Promise<boolean> {
const app = tg();
if (app?.showConfirm && app.initData) {
try {
return new Promise((resolve) => app.showConfirm!(message, resolve));
} catch {
/* старая версия Telegram — обычный confirm */
}
}
return Promise.resolve(window.confirm(message));
}
/**
* Сообщить серверу об ошибке в браузере (в логе API: client error). Без персональных данных:
* текст ошибки, первые строки стека, адрес экрана и версия Telegram.
*/
export function reportClientError(err: unknown, where = 'unknown') {
try {
const e = err as { message?: string; stack?: string; data?: unknown };
if (e?.data) return; // ошибки API и так видны в логах сервера
const body = JSON.stringify({
where,
message: String(e?.message ?? err).slice(0, 300),
stack: String(e?.stack ?? '').split('\n').slice(0, 6).join('\n').slice(0, 1200),
path: window.location.hash.split('?')[0]?.slice(0, 80),
tg: tg()?.platform ? `${tg()!.platform} ${(tg() as unknown as { version?: string }).version ?? ''}` : 'browser',
});
navigator.sendBeacon?.('/client-log', new Blob([body], { type: 'application/json' }));
} catch {
/* не мешаем пользователю */
}
}
let installed = false;
/** Перехват необработанных ошибок страницы */
export function installErrorReporting() {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('error', (e) => reportClientError(e.error ?? e.message, 'window.onerror'));
window.addEventListener('unhandledrejection', (e) => reportClientError(e.reason, 'unhandledrejection'));
}
+23
View File
@@ -0,0 +1,23 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { Toast } from '@barev/ui';
type Show = (message: string, kind?: 'info' | 'error') => void;
const Ctx = createContext<Show>(() => {});
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<{ message: string; kind: 'info' | 'error' } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const show = useCallback<Show>((message, kind = 'info') => {
setToast({ message, kind });
clearTimeout(timer.current);
timer.current = setTimeout(() => setToast(null), 3200);
}, []);
return (
<Ctx.Provider value={show}>
{children}
{toast && <Toast message={toast.message} kind={toast.kind} />}
</Ctx.Provider>
);
}
export const useToast = () => useContext(Ctx);
+51
View File
@@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { useState, type ReactNode } from 'react';
import superjson from 'superjson';
import type { AppRouter } from '@barev/api';
export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>();
export type { AppRouter };
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
refetchOnWindowFocus: true,
retry: (count, err) => {
const code = (err as { data?: { httpStatus?: number } }).data?.httpStatus ?? 0;
return code >= 500 && count < 2;
},
},
},
});
}
/**
* Провайдер tRPC + React Query. `auth` — заголовки для каждого запроса
* (initData Mini App) или credentials для cookie-сессии админки.
*/
export function ApiProvider({ url = '/trpc', headers, withCredentials, children }: { url?: string; headers?: () => Record<string, string>; withCredentials?: boolean; children: ReactNode }) {
const [queryClient] = useState(makeQueryClient);
const [client] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url,
transformer: superjson,
headers: () => headers?.() ?? {},
fetch: withCredentials ? (input, init) => fetch(input, { ...init, credentials: 'include' }) : undefined,
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={client} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx", "types": ["node"] }, "include": ["src"] }