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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
'use client';
import { encodeStartPayload, normalizeAgencyCode, pickUtm, type Locale } from '@barev/core';
import { LOCALE_LABELS, LOCALES } from '@barev/i18n';
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
const AGENCY_KEY = 'barev.agency';
interface Ctx {
href: string;
agency: string | null;
query: string;
}
const LinkCtx = createContext<Ctx>({ href: '', agency: null, query: '' });
/**
* LND-3: ссылка в бота с payload (язык, агентство, UTM). Без JS работает ссылка с языком (SSR);
* в браузере дописываем код агентства из ?a= и, если есть UTM, получаем короткий токен у API.
* Код агентства запоминается: клиент мог перейти на лендинг по QR, а нажать CTA позже.
*/
export function BotLinkProvider({ locale, bot, children }: { locale: Locale; bot: string; children: ReactNode }) {
const base = `https://t.me/${bot}?start=${encodeStartPayload({ locale })}`;
const [state, setState] = useState<Ctx>({ href: base, agency: null, query: '' });
useEffect(() => {
const params = new URLSearchParams(window.location.search);
let agency = params.get('a') ? normalizeAgencyCode(params.get('a')!) : null;
try {
if (agency) localStorage.setItem(AGENCY_KEY, agency);
else agency = localStorage.getItem(AGENCY_KEY);
} catch {
/* приватный режим */
}
const utm = pickUtm(params);
const query = window.location.search;
const simple = `https://t.me/${bot}?start=${encodeStartPayload({ locale, agency: agency ?? undefined })}`;
setState({ href: simple, agency, query });
if (Object.keys(utm).length === 0) return;
fetch(`${API}/trpc/public.startLink`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ json: { locale, agency: agency ?? undefined, utm } }),
})
.then((r) => (r.ok ? r.json() : null))
.then((b: { result?: { data?: { json?: { url: string } } } } | null) => {
const url = b?.result?.data?.json?.url;
if (url) setState((s) => ({ ...s, href: url }));
})
.catch(() => {});
}, [bot, locale]);
return <LinkCtx.Provider value={state}>{children}</LinkCtx.Provider>;
}
/** Код агентства из ?a= или сохранённый ранее — для подарка на /land3 */
export function useAgency(): string | null {
return useContext(LinkCtx).agency;
}
export function BotLink({ className, style, children }: { className?: string; style?: React.CSSProperties; children: ReactNode }) {
const { href } = useContext(LinkCtx);
return (
<a href={href} className={className} style={style} rel="noopener">
{children}
</a>
);
}
/** LND-2: переключатель языков; выбор запоминается в cookie и уходит в payload. path — страница без языка, например /clean */
export function LangSwitch({ current, path = '' }: { current: Locale; path?: string }) {
const { query } = useContext(LinkCtx);
return (
<>
{LOCALES.map((l) => (
<a
key={l}
href={`/${l}${path}${query}`}
className="lang"
aria-current={l === current}
hrefLang={l}
lang={l}
onClick={() => {
document.cookie = `barev_lang=${l}; path=/; max-age=31536000; samesite=lax`;
}}
>
{LOCALE_LABELS[l]}
</a>
))}
</>
);
}
/** Языки на мобилке: вместо ряда из пяти кнопок — текущий язык, по нажатию раскрывается список */
export function LangMenu({ current, path = '' }: { current: Locale; path?: string }) {
const ref = useRef<HTMLDetailsElement>(null);
useEffect(() => {
const close = (e: PointerEvent) => {
if (ref.current?.open && !ref.current.contains(e.target as Node)) ref.current.open = false;
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, []);
return (
<details ref={ref} className="lang-menu">
<summary className="lang lang-menu__current">
{LOCALE_LABELS[current]}
<svg width="10" height="6" viewBox="0 0 10 6" aria-hidden="true">
<path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</summary>
<div className="lang-menu__list">
<LangSwitch current={current} path={path} />
</div>
</details>
);
}