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

+85
View File
@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { Card } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import { LEVEL_NAME, LEVEL_TONE, PageHead, type Level } from '../lib.tsx';
import { Qr } from '../qr.tsx';
/**
* Клубная карта с живым QR (меняется каждую минуту — скриншот не сработает) и партнёры клуба:
* что положено на твоём уровне и сколько осталось по лимиту.
*/
export function Perks() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions());
const qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
const partners = useQuery(trpc.client.partners.queryOptions());
const level = (me.data?.membership.level ?? 'BAREV') as Level;
const tone = LEVEL_TONE[level];
const left = useSecondsLeft(qr.data?.expiresAt);
const name = (l: Record<string, string>) => l[locale] || l.ru || '';
return (
<main className="app">
<PageHead title={t('client.perks.title')} back={false} />
<section className="card-qr" style={{ background: level === 'BAREV' ? 'var(--bc-plum)' : tone.bg, color: level === 'BAREV' ? '#fff' : tone.ink }}>
<div className="card-qr__top">
<span>Barev Club · {LEVEL_NAME[level]}</span>
<b>{me.data?.firstName}</b>
</div>
<div className="card-qr__code">{qr.data ? <Qr value={qr.data.url} size={208} label={t('client.partners.qrLabel')} /> : <div style={{ width: 208, height: 208 }} />}</div>
<div className="card-qr__meta">
<span className="bc-num">{me.data?.cardCode}</span>
<span>{t('client.partners.qrRefresh', { seconds: left })}</span>
</div>
</section>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.partners.howTo')}
</p>
{partners.data?.length === 0 ? (
<Card tone="perks" className="bc-stack">
<h2 className="bc-h3">{t('client.perks.soonTitle')}</h2>
<p style={{ margin: 0 }}>{t('client.perks.soonText')}</p>
</Card>
) : (
<ul className="partners">
{partners.data?.map((p) => (
<li key={p.id} className="partner">
<span className="partner__cat">{t(`client.partners.cat.${p.category}`)}</span>
<b className="partner__name">{p.name}</b>
{p.address && <span className="bc-small bc-muted">{p.address}</span>}
<ul className="offers">
{(p.offers.length ? p.offers : [{ id: 'main', title: p.offer, eligible: true, remaining: null, minLevel: null, limit: 'NONE' as const, limitCount: 1 }]).map((o) => (
<li key={o.id} className="offer" data-eligible={o.eligible} data-used={o.remaining === 0}>
<span>{name(o.title)}</span>
<small>
{!o.eligible
? t('client.partners.needLevel', { level: LEVEL_NAME[(o.minLevel ?? 'BAREV') as Level] })
: o.remaining === null
? o.minLevel
? LEVEL_NAME[o.minLevel as Level]
: t('client.partners.allMembers')
: o.remaining > 0
? t(`client.partners.limit_${o.limit}`, { count: o.limitCount })
: t(`client.partners.used_${o.limit}`)}
</small>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</main>
);
}
function useSecondsLeft(expiresAt?: Date) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
return expiresAt ? Math.max(0, Math.ceil((new Date(expiresAt).getTime() - now) / 1000)) : 60;
}