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:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user