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:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user