Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
260 lines
13 KiB
TypeScript
260 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|