Bariki cashback, order edit, and current work in progress

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:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+2
View File
@@ -16,6 +16,7 @@ import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
import { Chat } from './screens/Chat.tsx';
import { Points } from './screens/Points.tsx';
const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
@@ -65,6 +66,7 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else if (path === '/chat') screen = <Chat />;
else if (path === '/points') screen = <Points />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path);
return (
+102
View File
@@ -933,3 +933,105 @@
.chat-compose__send:disabled {
opacity: 0.4;
}
/* барики в форме заказа */
.points-toggle {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 18px;
background: var(--bc-perks, #f6e3b4);
cursor: pointer;
}
.points-toggle[data-off] {
opacity: 0.6;
cursor: default;
}
.points-toggle input {
width: 22px;
height: 22px;
margin-top: 1px;
accent-color: var(--bc-plum);
flex: none;
}
.points-toggle small {
display: block;
font-size: 13px;
opacity: 0.8;
margin-top: 2px;
}
/* ───── барики ───── */
.points-hero {
gap: 6px;
}
.points-hero__num {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 46px;
line-height: 1;
letter-spacing: -0.03em;
}
.points-hero__ghost {
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
}
.points-rates {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.points-rates th,
.points-rates td {
padding: 6px 4px;
text-align: right;
}
.points-rates th:first-child,
.points-rates td:first-child {
text-align: left;
}
.points-rates th {
font-weight: 600;
opacity: 0.6;
}
.points-rates [data-on] {
font-weight: 800;
opacity: 1;
color: var(--bc-plum);
}
.points-dots {
display: flex;
gap: 8px;
}
.points-dots span {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bc-bg);
border: 2px solid rgba(74, 16, 40, 0.15);
}
.points-dots span[data-mark] {
border-color: var(--bc-plum);
}
.points-dots span[data-on] {
background: var(--bc-plum);
border-color: var(--bc-plum);
}
.points-row {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 4px;
border-bottom: 1px solid rgba(74, 16, 40, 0.08);
}
.points-row:last-child {
border-bottom: 0;
}
.points-plus {
color: var(--bc-home-ink, #2f5b1f);
white-space: nowrap;
}
.points-minus {
white-space: nowrap;
}
@@ -112,9 +112,15 @@ export function OrderDetail({ id }: { id: string }) {
<span>−{amd(o.discountAmount)}</span>
</div>
)}
{o.pointsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(o.pointsUsed)}</span>
</div>
)}
<div className="bc-between total">
<span>{t('client.order.total')}</span>
<span>{amd(o.totalPrice)}</span>
<span>{amd(o.totalPrice - o.pointsUsed)}</span>
</div>
{o.cancelFee > 0 && (
<div className="bc-between">
+27 -1
View File
@@ -31,6 +31,8 @@ export function OrderForm({ code }: { code: string }) {
const [promoInput, setPromoInput] = useState('');
const [promo, setPromo] = useState('');
const [promoError, setPromoError] = useState<string | null>(null);
// барики: списать в этом заказе (с промокодом нельзя)
const [usePts, setUsePts] = useState(false);
const [touched, setTouched] = useState(false);
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
@@ -132,10 +134,13 @@ export function OrderForm({ code }: { code: string }) {
paymentMethodId: methodId || undefined,
repeatOfId: repeatId ?? undefined,
promoCode: q?.promo ? promo : undefined,
usePoints: ptsUsed || undefined,
});
}
const q = quote.data;
const ptsAvailable = !!q?.points.allowed && !q.promo;
const ptsUsed = usePts && ptsAvailable ? q!.points.max : 0;
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
@@ -294,6 +299,27 @@ export function OrderForm({ code }: { code: string }) {
) : (
<Spinner />
)}
{q && q.points.balance > 0 && (
<label className="points-toggle" data-off={!ptsAvailable || undefined}>
<input type="checkbox" checked={usePts && ptsAvailable} disabled={!ptsAvailable} onChange={(e) => setUsePts(e.target.checked)} />
<span>
<b>{t('client.points.useInOrder', { amount: amd(q.points.max).replace(/\s?֏$/, '') })}</b>
<small>
{q.promo
? t('client.points.notWithPromo')
: !q.points.allowed
? t('client.points.belowMin', { min: amd(q.points.min).replace(/\s?֏$/, ''), balance: amd(q.points.balance).replace(/\s?֏$/, '') })
: t('client.points.useHint', { pct: q.points.sharePct, balance: amd(q.points.balance).replace(/\s?֏$/, '') })}
</small>
</span>
</label>
)}
{ptsUsed > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.points.paidLine')}</span>
<span>−{amd(ptsUsed)}</span>
</div>
)}
<PromoField
value={promoInput}
onChange={(v) => {
@@ -325,7 +351,7 @@ export function OrderForm({ code }: { code: string }) {
{t('client.order.total')}
</span>
<span className="bc-num" style={{ fontSize: 22 }}>
{q ? amd(q.totalPrice) : '…'}
{q ? amd(q.totalPrice - ptsUsed) : '…'}
</span>
</div>
{slot && (
+213
View File
@@ -0,0 +1,213 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Card, Center, Field, Icon, Sheet, Spinner } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LEVEL_NAME, PageHead, type Level } from '../lib.tsx';
/** Экран «Барики»: баланс, ожидание, сгорание, ставки по уровню, серия уборок, маршрут, история */
export function Points() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const p = useQuery(trpc.client.points.summary.queryOptions());
const history = useQuery(trpc.client.points.history.queryOptions());
const [pay, setPay] = useState(false);
const n = (v: number) => new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : locale).format(v);
const date = (d: Date) => dt(d, { day: 'numeric', month: 'long' });
const name = (m: Record<string, string> | null | undefined) => (m ? m[locale] || m.ru || m.en || '' : '');
if (!p.data)
return (
<Center>
<Spinner />
</Center>
);
const d = p.data;
const tier = d.tier as Level;
return (
<main className="app app--plain">
<PageHead title={t('client.points.title')} />
<Card tone="dark" className="bc-stack points-hero">
<span className="bc-small" style={{ opacity: 0.75 }}>
{t('client.points.balance')}
</span>
<div className="points-hero__num">{n(d.balance)}</div>
<span className="bc-small" style={{ opacity: 0.85 }}>
{t('client.points.equals', { amount: n(Math.max(0, d.balance)) })}
</span>
{d.pending > 0 && <span className="bc-small">⏳ {t('client.points.pending', { amount: n(d.pending), date: d.pendingNextAt ? date(d.pendingNextAt) : '—' })}</span>}
{d.burnAt && <span className="bc-small">🔥 {t('client.points.burn', { amount: n(d.burnAmount), date: date(d.burnAt) })}</span>}
<div className="bc-row" style={{ gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
<Button size="sm" variant="light" disabled={d.balance < d.redeem.min} onClick={() => setPay(true)}>
{t('client.points.pay')}
</Button>
<a className="bc-btn bc-btn--outline bc-btn--sm points-hero__ghost" href="#/profile?code=1">
{t('client.points.enterCode')}
</a>
</div>
{d.openRequests > 0 && <span className="bc-small">{t('client.points.requestPending')}</span>}
</Card>
<Card className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t('client.points.rates')}</h2>
<Badge>{LEVEL_NAME[tier]}</Badge>
</div>
<table className="points-rates">
<thead>
<tr>
<th />
<th data-on={tier === 'BAREV' || undefined}>Barev</th>
<th data-on={tier === 'BAREV_PLUS' || undefined}>Barev+</th>
<th data-on={tier === 'ELITE' || undefined}>Elite</th>
</tr>
</thead>
<tbody>
{d.rates.map((r) => (
<tr key={r.code}>
<td>{name(r.name)}</td>
<td data-on={tier === 'BAREV' || undefined}>{r.barev}%</td>
<td data-on={tier === 'BAREV_PLUS' || undefined}>{r.plus}%</td>
<td data-on={tier === 'ELITE' || undefined}>{r.elite}%</td>
</tr>
))}
</tbody>
</table>
{tier === 'BAREV' && d.plusWouldGet > 0 && (
<a href="#/membership" className="bc-small" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
{t('client.points.plusWouldGet', { amount: n(d.plusWouldGet) })}
</a>
)}
</Card>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.points.streak')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3, 4, 5, 6].map((i) => (
<span key={i} data-on={d.streak.count >= i || undefined} data-mark={i === 3 || i === 6 || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{d.streak.next
? t('client.points.streakLeft', { left: d.streak.next - d.streak.count, bonus: d.streak.next === 3 ? 2 : 3, days: d.streak.gapDays })
: t('client.points.streakMax', { bonus: d.streak.bonusPct })}
</p>
</Card>
{d.route && (
<Card tone="home" className="bc-stack">
<h2 className="bc-h3">{t('client.points.route')}</h2>
<div className="points-dots" aria-hidden="true">
{[1, 2, 3].map((i) => (
<span key={i} data-on={d.route!.done >= i || undefined} />
))}
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.routeText', { done: d.route.done, amount: n(d.route.amount), date: date(d.route.until) })}
</p>
</Card>
)}
<section className="bc-stack" style={{ gap: 8 }}>
<h2 className="bc-h3">{t('client.points.history')}</h2>
{!history.data ? (
<Spinner />
) : history.data.length === 0 ? (
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.points.empty')}
</p>
) : (
<Card tight className="bc-stack" style={{ gap: 0 }}>
{history.data.map((r) => (
<div key={`${r.type}:${r.id}`} className="points-row">
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600 }}>
{r.type === 'bonus' ? name(r.bonus) : r.type === 'accrual' ? name(r.category) : t(`client.points.type.${r.type}`)}
{r.orderRef ? <span className="bc-muted"> {r.orderRef}</span> : null}
</div>
<div className="bc-small bc-muted">
{dt(r.at, { day: 'numeric', month: 'short' })} · {t(`client.points.status.${r.status}`)}
{r.status === 'PENDING' && r.availableAt ? ` · ${t('client.points.availableFrom', { date: date(r.availableAt) })}` : ''}
{r.expiresAt && r.status === 'ACTIVE' ? ` · ${t('client.points.until', { date: date(r.expiresAt) })}` : ''}
</div>
{r.comment && <div className="bc-small">💬 {r.comment}</div>}
</div>
<b className={r.amount > 0 ? 'points-plus' : 'points-minus'}>
{r.amount > 0 ? '+' : '−'}
{n(Math.abs(r.amount))}
</b>
</div>
))}
</Card>
)}
</section>
{pay && <PaySheet balance={d.balance} sharePct={d.redeem.sharePct} min={d.redeem.min} onClose={() => setPay(false)} />}
</main>
);
}
/** Оплатить бариками: выбрать заказ или ввести сумму — запрос уходит команде */
function PaySheet({ balance, sharePct, min, onClose }: { balance: number; sharePct: number; min: number; onClose: () => void }) {
const { t, amd, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const orders = useQuery(trpc.client.orders.list.queryOptions());
const [orderId, setOrderId] = useState<string>('');
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const order = orders.data?.active.find((o) => o.id === orderId);
const max = order ? Math.min(balance, Math.floor(((order.totalPrice - order.pointsUsed) * sharePct) / 100)) : balance;
const value = Number(amount) || 0;
const send = useMutation(
trpc.client.points.request.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.points.requested'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const payable = orders.data?.active.filter((o) => o.paymentStatus === 'UNPAID') ?? [];
return (
<Sheet open onClose={onClose} label={t('client.points.pay')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('client.points.pay')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.points.payHint', { pct: sharePct, min: amd(min).replace(/\s?֏$/, '') })}
</p>
<Field label={t('client.points.forOrder')}>
<select className="bc-input" value={orderId} onChange={(e) => setOrderId(e.target.value)}>
<option value="">{t('client.points.otherOrder')}</option>
{payable.map((o) => (
<option key={o.id} value={o.id}>
#{o.number} · {t(`services.${o.serviceCode}.name`)} · {dt(o.scheduledAt, { day: 'numeric', month: 'short' })}
</option>
))}
</select>
</Field>
<Field label={t('client.points.amount')}>
<input className="bc-input" inputMode="numeric" value={amount} placeholder={String(max)} onChange={(e) => setAmount(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
<p className="bc-small bc-muted" style={{ margin: '-6px 0 0' }}>
{t('client.points.max', { max: amd(max).replace(/\s?֏$/, '') })}
</p>
{!order && (
<Field label={t('client.points.note')}>
<input className="bc-input" value={note} maxLength={300} placeholder={t('client.points.notePh')} onChange={(e) => setNote(e.target.value)} />
</Field>
)}
<Button block disabled={send.isPending || value < min || value > max} onClick={() => send.mutate({ amount: value, orderId: orderId || undefined, note: note.trim() || undefined })}>
{t('client.points.send')}
</Button>
</div>
</Sheet>
);
}
@@ -42,6 +42,14 @@ export function Profile() {
<LanguagePicker />
<nav className="links">
<a href="#/points">
<Icon name="star" size={20} />
<span>
{t('client.points.title')}
{me.data ? <b style={{ marginLeft: 8 }}>{new Intl.NumberFormat('ru-RU').format(me.data.points)}</b> : null}
</span>
<Icon name="arrow" size={18} />
</a>
<a href="#/orders">
<Icon name="clock" size={20} />
<span>{t('client.orders.title')}</span>