Files
BarevClub/apps/admin/src/pages/Partners.tsx
T
2026-10-25 22:55:29 +04:00

260 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}