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

+259
View File
@@ -0,0 +1,259 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALES } from '@barev/i18n';
import { Badge, Button, Card, Field } from '@barev/ui';
import QRCode from 'qrcode';
import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { Fragment, useEffect, useState } from 'react';
const CATEGORIES = ['cafe', 'restaurant', 'shop', 'beauty', 'other'] as const;
type Draft = { id?: string; category: (typeof CATEGORIES)[number]; name: string; offer: Record<string, string>; address: string; active: boolean };
const EMPTY: Draft = { category: 'cafe', name: '', offer: { ru: '', en: '', zh: '', hy: '', es: '' }, address: '', active: true };
/** Партнёры клуба: что получает клиент по клубной карте — показывается в приложении на вкладке «Скидки» */
export function Partners() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.partners.list.queryOptions());
const [d, setD] = useState<Draft | null>(null);
const [open, setOpen] = useState<string | null>(null);
const save = useMutation(
trpc.admin.partners.save.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
setD(null);
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<div className="bc-between">
<h1 className="bc-h2">{t('admin.nav.partners')}</h1>
<Button size="sm" onClick={() => setD(EMPTY)}>
{t('admin.add')}
</Button>
</div>
{d && (
<Card className="form-grid">
<Field label={t('admin.partners.name')}>
<input className="bc-input" value={d.name} onChange={(e) => setD({ ...d, name: e.target.value })} />
</Field>
<Field label={t('admin.partners.category')}>
<select className="bc-input" value={d.category} onChange={(e) => setD({ ...d, category: e.target.value as Draft['category'] })}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`client.partners.cat.${c}`)}
</option>
))}
</select>
</Field>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.partners.offer')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={d.offer[l] ?? ''} placeholder={l === 'ru' ? '−15% на всё меню' : ''} onChange={(e) => setD({ ...d, offer: { ...d.offer, [l]: e.target.value } })} />
</Field>
))}
<Field label={t('admin.partners.address')}>
<input className="bc-input" value={d.address} onChange={(e) => setD({ ...d, address: e.target.value })} />
</Field>
<label className="bc-row">
<input type="checkbox" checked={d.active} onChange={(e) => setD({ ...d, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="full bc-row">
<Button
disabled={!d.name.trim() || !d.offer.ru?.trim()}
onClick={() => save.mutate({ ...d, address: d.address.trim() || null, offer: { ru: d.offer.ru ?? '', en: d.offer.en ?? '', zh: d.offer.zh ?? '', hy: d.offer.hy ?? '', es: d.offer.es ?? '' } })}
>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setD(null)}>
{t('common.cancel')}
</Button>
</div>
</Card>
)}
<table className="grid">
<thead>
<tr>
<th>{t('admin.partners.name')}</th>
<th>{t('admin.partners.offer')}</th>
<th>{t('admin.partners.address')}</th>
<th />
</tr>
</thead>
<tbody>
{list.data?.map((p) => {
const offer = p.offer as Record<string, string>;
return (
<Fragment key={p.id}>
<tr style={{ opacity: p.active ? 1 : 0.5 }}>
<td>
<b>{p.name}</b>
<div className="bc-small bc-muted">{t(`client.partners.cat.${p.category}`)}</div>
<div className="bc-small bc-muted">
{t('admin.partners.summary', { offers: p.offers.length, members: p.members.length, visits: p._count.usages })}
</div>
</td>
<td>{offer[locale] || offer.ru}</td>
<td className="bc-small">{p.address ?? '—'}</td>
<td>
<div className="bc-chips">
<Button size="sm" variant="light" onClick={() => setOpen(open === p.id ? null : p.id)}>
{t('admin.partners.manage')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setD({ id: p.id, category: p.category as Draft['category'], name: p.name, offer, address: p.address ?? '', active: p.active })}>
{t('admin.edit')}
</Button>
</div>
</td>
</tr>
{open === p.id && (
<tr>
<td colSpan={4} style={{ background: 'var(--bc-bg)' }}>
<PartnerDetails partner={p} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</>
);
}
type Offer = { id: string; title: unknown; minLevel: 'BAREV_PLUS' | 'ELITE' | null; limit: 'NONE' | 'ONCE' | 'WEEK' | 'MONTH'; limitCount: number; active: boolean };
type OfferDraft = { id?: string; title: Record<string, string>; minLevel: Offer['minLevel']; limit: Offer['limit']; limitCount: number; active: boolean };
const LEVELS = [null, 'BAREV_PLUS', 'ELITE'] as const;
const LIMITS = ['NONE', 'ONCE', 'WEEK', 'MONTH'] as const;
/** Предложения партнёра по уровням и лимитам, ссылка для собственника, команда */
function PartnerDetails({ partner }: { partner: { id: string; offers: Offer[]; members: { id: string; role: 'OWNER' | 'STAFF'; user: { firstName: string | null; lastName: string | null; username: string | null } }[] } }) {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [od, setOd] = useState<OfferDraft | null>(null);
const [link, setLink] = useState<string | null>(null);
const [qr, setQr] = useState('');
useEffect(() => {
if (link) void QRCode.toString(link, { type: 'svg', margin: 1 }).then(setQr);
}, [link]);
const done = {
onSuccess: () => {
toast(t('admin.saved'));
setOd(null);
void qc.invalidateQueries();
},
onError: (e: unknown) => toast(errorText(t, e), 'error'),
};
const saveOffer = useMutation(trpc.admin.partners.saveOffer.mutationOptions(done));
const removeMember = useMutation(trpc.admin.partners.removeMember.mutationOptions(done));
const owner = useMutation(trpc.admin.partners.ownerInvite.mutationOptions({ onSuccess: (r) => setLink(r.url), onError: done.onError }));
const levelName = (l: Offer['minLevel']) => (l === 'ELITE' ? 'Barev Elite' : l === 'BAREV_PLUS' ? 'Barev+' : t('admin.partners.allMembers'));
const limitName = (o: { limit: Offer['limit']; limitCount: number }) => t(`admin.partners.limit_${o.limit}`, { count: o.limitCount });
return (
<div className="bc-stack" style={{ gap: 12 }}>
<div className="bc-between">
<b>{t('admin.partners.offers')}</b>
<Button size="sm" onClick={() => setOd({ title: { ru: '', en: '', zh: '', hy: '', es: '' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true })}>
{t('admin.add')}
</Button>
</div>
{od && (
<div className="form-grid" style={{ background: 'var(--bc-surface)', padding: 12, borderRadius: 16 }}>
{LOCALES.map((l) => (
<Field key={l} label={`${t('admin.partners.offerTitle')} · ${l.toUpperCase()}`}>
<input className="bc-input" value={od.title[l] ?? ''} placeholder={l === 'ru' ? 'Кофе в подарок' : ''} onChange={(e) => setOd({ ...od, title: { ...od.title, [l]: e.target.value } })} />
</Field>
))}
<Field label={t('admin.partners.minLevel')}>
<select className="bc-input" value={od.minLevel ?? ''} onChange={(e) => setOd({ ...od, minLevel: (e.target.value || null) as Offer['minLevel'] })}>
{LEVELS.map((l) => (
<option key={l ?? 'all'} value={l ?? ''}>
{levelName(l)}
</option>
))}
</select>
</Field>
<Field label={t('admin.partners.limit')}>
<select className="bc-input" value={od.limit} onChange={(e) => setOd({ ...od, limit: e.target.value as Offer['limit'] })}>
{LIMITS.map((l) => (
<option key={l} value={l}>
{t(`admin.partners.limitKind_${l}`)}
</option>
))}
</select>
</Field>
{(od.limit === 'WEEK' || od.limit === 'MONTH') && (
<Field label={t('admin.partners.limitCount')}>
<input className="bc-input" type="number" min={1} max={100} value={od.limitCount} onChange={(e) => setOd({ ...od, limitCount: Number(e.target.value) || 1 })} />
</Field>
)}
<label className="bc-row">
<input type="checkbox" checked={od.active} onChange={(e) => setOd({ ...od, active: e.target.checked })} /> {t('admin.active')}
</label>
<div className="full bc-row">
<Button disabled={!od.title.ru?.trim()} onClick={() => saveOffer.mutate({ ...od, partnerId: partner.id, title: { ru: od.title.ru ?? '', en: od.title.en ?? '', zh: od.title.zh ?? '', hy: od.title.hy ?? '', es: od.title.es ?? '' } })}>
{t('admin.save')}
</Button>
<Button variant="light" onClick={() => setOd(null)}>
{t('common.cancel')}
</Button>
</div>
</div>
)}
{partner.offers.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noOffers')}</p>}
{partner.offers.map((o) => {
const title = o.title as Record<string, string>;
return (
<div key={o.id} className="bc-between" style={{ opacity: o.active ? 1 : 0.5 }}>
<span>
<b>{title[locale] || title.ru}</b>{' '}
<Badge style={{ background: o.minLevel === 'ELITE' ? 'var(--bc-perks)' : o.minLevel ? 'var(--bc-pink)' : 'var(--bc-surface)' }}>{levelName(o.minLevel)}</Badge>{' '}
<span className="bc-small bc-muted">{limitName(o)}</span>
</span>
<Button size="sm" variant="ghost" onClick={() => setOd({ id: o.id, title: { ...title }, minLevel: o.minLevel, limit: o.limit, limitCount: o.limitCount, active: o.active })}>
{t('admin.edit')}
</Button>
</div>
);
})}
<div className="bc-between" style={{ marginTop: 6 }}>
<b>{t('admin.partners.team')}</b>
<Button size="sm" variant="outline" onClick={() => owner.mutate({ partnerId: partner.id })}>
{t('admin.partners.ownerLink')}
</Button>
</div>
{link && (
<div className="bc-row" style={{ alignItems: 'flex-start', gap: 14 }}>
<div style={{ width: 160, height: 160, background: '#fff', borderRadius: 12, padding: 6 }} dangerouslySetInnerHTML={{ __html: qr }} />
<div className="bc-stack" style={{ gap: 6, flex: 1 }}>
<span className="bc-small">{t('admin.partners.ownerLinkHint')}</span>
<input className="bc-input mono" readOnly value={link} onFocus={(e) => e.target.select()} />
</div>
</div>
)}
{partner.members.length === 0 && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('admin.partners.noTeam')}</p>}
{partner.members.map((m) => (
<div key={m.id} className="bc-between">
<span>
<b>
{m.user.firstName} {m.user.lastName ?? ''}
</b>
{m.user.username && <span className="bc-small bc-muted"> @{m.user.username}</span>}
<span className="bc-small bc-muted"> · {t(m.role === 'OWNER' ? 'pro.partner.owner' : 'pro.partner.staff')}</span>
</span>
<Button size="sm" variant="ghost" onClick={async () => (await confirmDialog(t('admin.partners.removeConfirm'))) && removeMember.mutate({ memberId: m.id })}>
{t('pro.partner.remove')}
</Button>
</div>
))}
</div>
);
}