Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}` : ''}` }));
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2023", "DOM"], "jsx": "react-jsx", "types": ["node"] }, "include": ["src"] }
|
||||
Reference in new issue
Block a user