Stage 6 (part 1): new client mini-app look and order ratings
Plum hero with a club-card ticket, a 'Now' block with the live order timeline, new order cards and order screen, QR club card screen, new nav (Home / Orders / Chat / Card / Profile). Clients rate completed orders (1-5 stars + comment); provider rating is recalculated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8866616782
commit
ff527ad6d2
18 files changed
+1536
-205
No files matched your search
@@ -17,7 +17,7 @@ import { Profile } from './screens/Profile.tsx';
|
||||
import { Services } from './screens/Services.tsx';
|
||||
import { Chat } from './screens/Chat.tsx';
|
||||
|
||||
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs'];
|
||||
const ROOTS = ['/', '/orders', '/services', '/perks', '/profile', '/docs'];
|
||||
|
||||
export function App({ hasInitData }: { hasInitData: boolean }) {
|
||||
const trpc = useTRPC();
|
||||
@@ -66,7 +66,7 @@ function Routes({ segment }: { segment: 'MOVING' | 'LIVING' }) {
|
||||
else if (path === '/membership') screen = <Membership />;
|
||||
else if (path === '/chat') screen = <Chat />;
|
||||
else screen = segment === 'MOVING' ? <Move /> : <Home />;
|
||||
const withNav = ROOTS.includes(path) || path === '/orders';
|
||||
const withNav = ROOTS.includes(path);
|
||||
return (
|
||||
<>
|
||||
{screen}
|
||||
@@ -97,7 +97,7 @@ function MoveNav({ path }: { path: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** ORD-1: Главная / Услуги / + / Скидки / Профиль */
|
||||
/** Главная / Заказы / Чат (в центре) / Клубная карта / Профиль; услуги — с главной */
|
||||
function BottomNav({ path }: { path: string }) {
|
||||
const { t } = useI18n();
|
||||
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
|
||||
@@ -109,11 +109,11 @@ function BottomNav({ path }: { path: string }) {
|
||||
return (
|
||||
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
|
||||
{item('/', 'home', t('client.nav.home'))}
|
||||
{item('/services', 'grid', t('client.nav.services'))}
|
||||
<a href="#/order/cleaning" className="bc-bottomnav__plus" aria-label={t('client.nav.newOrder')}>
|
||||
<Icon name="plus" size={26} strokeWidth={2.6} />
|
||||
{item('/orders', 'clock', t('client.nav.orders'))}
|
||||
<a href="#/chat" className="bc-bottomnav__plus" aria-label={t('client.nav.chat')}>
|
||||
<Icon name="chat" size={24} strokeWidth={2.4} />
|
||||
</a>
|
||||
{item('/perks', 'gift', t('client.nav.perks'))}
|
||||
{item('/perks', 'gift', t('client.nav.card'))}
|
||||
{item('/profile', 'user', t('client.nav.profile'))}
|
||||
</nav>
|
||||
);
|
||||
|
||||
+139
-11
@@ -1,4 +1,4 @@
|
||||
import { CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
|
||||
import { CATEGORY_ICON, CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
|
||||
import { paramsSummary, useI18n } from '@barev/web';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -38,19 +38,31 @@ export function useParamsSummary() {
|
||||
return (serviceCode: string, params: Record<string, unknown>) => paramsSummary(t, serviceCode, params);
|
||||
}
|
||||
|
||||
/** Строка заявки как в превью «Мои заявки» макета */
|
||||
export function OrderRow({ o }: { o: OrderLike }) {
|
||||
/** Карточка заказа в списке: иконка направления, услуга, время, статус и полоска прогресса */
|
||||
export function OrderRow({ o }: { o: OrderLike & { active?: boolean; steps?: readonly string[]; rating?: { score: number } | null; done?: boolean } }) {
|
||||
const { t, dt } = useI18n();
|
||||
const summary = useParamsSummary();
|
||||
const idx = o.steps ? o.steps.indexOf(o.status) : -1;
|
||||
const progress = o.active && o.steps && idx >= 0 ? (idx + 1) / o.steps.length : null;
|
||||
return (
|
||||
<a href={`#/orders/${o.id}`} className={`order-row ${toneClass(o.category)}`}>
|
||||
<div className="order-row__main">
|
||||
<div className="order-row__title">
|
||||
{t(`services.${o.serviceCode}.name`)} · {summary(o.serviceCode, o.params)}
|
||||
</div>
|
||||
<div className="bc-small">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
|
||||
<a href={`#/orders/${o.id}`} className="order-card" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
|
||||
<span className={`order-card__icon ${toneClass(o.category)}`}>
|
||||
<Icon name={CATEGORY_ICON[o.category as CategoryCode] ?? 'home'} size={22} />
|
||||
</span>
|
||||
<span className="order-card__main">
|
||||
<span className="order-card__title">{t(`services.${o.serviceCode}.name`)}</span>
|
||||
<span className="order-card__sub">{summary(o.serviceCode, o.params)}</span>
|
||||
<span className="order-card__when">{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{progress !== null && (
|
||||
<span className="order-card__bar" aria-hidden="true">
|
||||
<span style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="order-card__side">
|
||||
<span className="order-card__status">{t(`status.${o.flow}.${o.status}`)}</span>
|
||||
{o.rating ? <Stars value={o.rating.score} size={12} /> : o.done ? <span className="order-card__rate">{t('client.rate.prompt')}</span> : null}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -93,3 +105,119 @@ export function itemHint(names: RelocationNames | undefined, kind: 'STEP' | 'DOC
|
||||
const h = names?.[`${kind}:${item.code}`]?.hint;
|
||||
return h?.[locale] || h?.ru || null;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
label: ReactNode;
|
||||
state: 'done' | 'current' | 'todo';
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
/** Вертикальная шкала как на сайте: зелёные точки — пройдено, розовая — сейчас */
|
||||
export function Timeline({ items, compact }: { items: TimelineItem[]; compact?: boolean }) {
|
||||
return (
|
||||
<ol className={`tl${compact ? ' tl--compact' : ''}`}>
|
||||
{items.map((s, i) => (
|
||||
<li key={i} className={`tl__item tl__item--${s.state}`}>
|
||||
<span className="tl__dot" aria-hidden="true">
|
||||
{s.state === 'done' && <Icon name="check" size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="tl__body">
|
||||
<span className="tl__label">{s.label}</span>
|
||||
{s.meta && <span className="tl__meta">{s.meta}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OrderSteps {
|
||||
flow: string;
|
||||
status: string;
|
||||
/** false — заказ завершён: последний шаг тоже зелёный */
|
||||
active?: boolean;
|
||||
steps: readonly string[];
|
||||
history: { to: string; event: string | null; at: Date }[];
|
||||
}
|
||||
|
||||
/** Шаги заказа по истории статусов: пройденные, текущий, остальные */
|
||||
export function useOrderTimeline() {
|
||||
const { t, dt } = useI18n();
|
||||
return (o: OrderSteps): TimelineItem[] => {
|
||||
const passed = new Set(o.history.map((h) => h.to));
|
||||
if (o.status === 'CANCELLED')
|
||||
return [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }];
|
||||
const currentIdx = o.steps.indexOf(o.status);
|
||||
return o.steps.map((s, i) => {
|
||||
const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
|
||||
return {
|
||||
label: t(`status.${o.flow}.${s}`),
|
||||
state: i < currentIdx || (i === currentIdx && o.active === false) ? 'done' : i === currentIdx ? 'current' : 'todo',
|
||||
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const STAR = 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z';
|
||||
|
||||
/** Звёзды 1–5; без onChange — только показ */
|
||||
export function Stars({ value, onChange, size = 34 }: { value: number; onChange?: (v: number) => void; size?: number }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="stars" role={onChange ? 'radiogroup' : 'img'} aria-label={onChange ? undefined : t('client.rate.star', { count: value })}>
|
||||
{[1, 2, 3, 4, 5].map((n) => {
|
||||
const svg = (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={STAR} fill={n <= value ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={1.8} strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
return onChange ? (
|
||||
<button key={n} type="button" role="radio" aria-checked={n === value} aria-label={t('client.rate.star', { count: n })} data-on={n <= value || undefined} onClick={() => onChange(n)}>
|
||||
{svg}
|
||||
</button>
|
||||
) : (
|
||||
<span key={n} data-on={n <= value || undefined}>
|
||||
{svg}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Клубная карта-«билет» в стиле карт на сайте */
|
||||
export function ClubTicket({ level, name, code, until, href, action }: { level: Level; name?: string; code?: string | null; until?: ReactNode; href?: string; action?: ReactNode }) {
|
||||
const id = level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite';
|
||||
const body = (
|
||||
<>
|
||||
<span className="ticket__main">
|
||||
<span className="ticket__club">Barev Club</span>
|
||||
<span className="ticket__level">{LEVEL_NAME[level]}</span>
|
||||
<span className="ticket__meta">
|
||||
{name && <b>{name}</b>}
|
||||
{code && <span className="bc-num">{code}</span>}
|
||||
</span>
|
||||
{until && <span className="ticket__until">{until}</span>}
|
||||
</span>
|
||||
{action && <span className="ticket__stub">{action}</span>}
|
||||
</>
|
||||
);
|
||||
return href ? (
|
||||
<a className="ticket" data-plan={id} href={href}>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<div className="ticket" data-plan={id}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QrGlyph({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h2v2h-2zM18 14h2M14 18v2M18 18h2v2h-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './app.css';
|
||||
import './redesign.css';
|
||||
import { ApiProvider, loadTextOverrides, getInitData, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
/* Этап 6: новый вид клиентского приложения — в стиле barevclub.com (слива, розовый, зелёные точки) */
|
||||
|
||||
:root {
|
||||
--plum-2: #5e1a36;
|
||||
--plum-line: rgba(246, 195, 211, 0.22);
|
||||
--green: #a8c38a;
|
||||
--green-ink: #1f3311;
|
||||
}
|
||||
|
||||
/* ───── сливовый верх, как на сайте ───── */
|
||||
.app--hero {
|
||||
padding-top: 0;
|
||||
}
|
||||
.hero {
|
||||
margin: 0 -16px 4px;
|
||||
padding: 18px 16px 22px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
border-radius: 0 0 32px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero__word {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: -18px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 112px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--bc-pink);
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero__hello {
|
||||
position: relative;
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 30px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.hero__hello span {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.hero__hello small {
|
||||
display: block;
|
||||
font-family: var(--bc-font-body);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hero__note {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
/* ───── клубная карта-«билет» ───── */
|
||||
.ticket {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: 132px;
|
||||
border-radius: 24px;
|
||||
text-decoration: none;
|
||||
color: var(--bc-plum);
|
||||
background: var(--bc-pink);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ticket[data-plan='free'] {
|
||||
background: var(--plum-2);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1.5px var(--plum-line);
|
||||
}
|
||||
.ticket[data-plan='elite'] {
|
||||
background: var(--bc-perks);
|
||||
}
|
||||
.ticket__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.ticket__club {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.ticket__level {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ticket__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket__until {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* корешок: пунктир и вырезы, как у билета */
|
||||
.ticket__stub {
|
||||
flex: none;
|
||||
width: 92px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
padding: 10px 8px;
|
||||
border-left: 2px dashed currentColor;
|
||||
border-left-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
.ticket__stub::before,
|
||||
.ticket__stub::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -11px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bc-plum);
|
||||
}
|
||||
.ticket__stub::before {
|
||||
top: -10px;
|
||||
}
|
||||
.ticket__stub::after {
|
||||
bottom: -10px;
|
||||
}
|
||||
|
||||
/* ───── секции ───── */
|
||||
.sect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.sect__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.sect__title {
|
||||
margin: 4px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sect__link {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.live-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 4px rgba(168, 195, 138, 0.3);
|
||||
animation: live 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes live {
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px rgba(168, 195, 138, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ───── «Сейчас» ───── */
|
||||
.now-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 28px;
|
||||
background: var(--bc-surface);
|
||||
text-decoration: none;
|
||||
color: var(--bc-ink);
|
||||
box-shadow: var(--bc-shadow);
|
||||
}
|
||||
.now-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.now-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.now-card__main b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.now-card__main span {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.now-card__when {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-bg);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.now-card__when::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.more-link {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.empty-now {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 28px;
|
||||
background: var(--bc-surface);
|
||||
}
|
||||
.empty-now p {
|
||||
margin: 0;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
|
||||
/* ───── шкала как на сайте ───── */
|
||||
.tl {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tl__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.tl__item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tl__item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 22px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--bc-line);
|
||||
}
|
||||
.tl__item--done::before {
|
||||
background: var(--green);
|
||||
}
|
||||
.tl__item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
.tl__dot {
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bc-line);
|
||||
background: var(--bc-surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--green-ink);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tl__item--done .tl__dot {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.tl__item--current .tl__dot {
|
||||
border: 6px solid var(--bc-accent);
|
||||
box-shadow: 0 0 0 4px rgba(184, 35, 90, 0.15);
|
||||
}
|
||||
.tl__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl__label {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tl__item--todo .tl__label {
|
||||
color: var(--bc-ink-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tl__item--current .tl__label {
|
||||
color: var(--bc-accent);
|
||||
}
|
||||
.tl__meta {
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
/* компактно на главной — шаги в строку */
|
||||
.tl--compact {
|
||||
flex-direction: row;
|
||||
}
|
||||
.tl--compact .tl__item {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl--compact .tl__item::before {
|
||||
left: 22px;
|
||||
right: 0;
|
||||
top: 10px;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
}
|
||||
.tl--compact .tl__item:last-child {
|
||||
flex: none;
|
||||
}
|
||||
.tl--compact .tl__body {
|
||||
display: none;
|
||||
}
|
||||
.tl--compact .tl__item--current .tl__body {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl--compact .tl__item--current .tl__meta {
|
||||
display: none;
|
||||
}
|
||||
.tl--compact {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.tl--compact .tl__item:last-child.tl__item--current .tl__body {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* ───── карточки заказов ───── */
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.order-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 24px;
|
||||
background: var(--bc-surface);
|
||||
text-decoration: none;
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.order-card[data-status='cancelled'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.order-card__icon {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.order-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.order-card__title {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.order-card__sub,
|
||||
.order-card__when {
|
||||
font-size: 13px;
|
||||
color: var(--bc-ink-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.order-card__when {
|
||||
color: var(--bc-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
.order-card__bar {
|
||||
margin-top: 8px;
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--bc-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.order-card__bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--green);
|
||||
}
|
||||
.order-card__side {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.order-card__status {
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: var(--bc-bg);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.order-card[data-status='active'] .order-card__status {
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.order-card__rate {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-accent);
|
||||
}
|
||||
.order-card .stars {
|
||||
color: #e9a400;
|
||||
}
|
||||
|
||||
/* ───── карточка заказа ───── */
|
||||
.od-hero {
|
||||
margin: -16px -16px 0;
|
||||
padding: 12px 16px 22px;
|
||||
border-radius: 0 0 32px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.od-hero__bar {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.od-hero__num {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.od-hero__status {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--bc-pill);
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.od-hero__status[data-status='done'] {
|
||||
background: var(--bc-surface);
|
||||
color: var(--green-ink);
|
||||
}
|
||||
.od-hero__status[data-status='cancelled'] {
|
||||
background: var(--bc-surface);
|
||||
color: var(--bc-danger);
|
||||
}
|
||||
.od-hero__title {
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 32px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.od-hero__sub {
|
||||
font-size: 15px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.facts {
|
||||
margin: 0;
|
||||
padding: 4px 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bc-surface);
|
||||
}
|
||||
.facts > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--bc-line);
|
||||
}
|
||||
.facts > div:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.facts dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.facts dd {
|
||||
margin: 0 0 0 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.facts dd::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel {
|
||||
padding: 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bc-surface);
|
||||
}
|
||||
.panel__title {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ───── оценка ───── */
|
||||
.rate {
|
||||
padding: 18px;
|
||||
border-radius: 28px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rate__title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
}
|
||||
.rate__text {
|
||||
margin: -4px 0 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.rate .stars {
|
||||
color: var(--bc-perks);
|
||||
}
|
||||
.rate__input {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rate--done {
|
||||
background: var(--bc-surface);
|
||||
color: var(--bc-ink);
|
||||
}
|
||||
.rate--done .stars {
|
||||
color: #e9a400;
|
||||
}
|
||||
.rate__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.rate__change {
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--bc-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rate__comment {
|
||||
margin: 0;
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.rate-nudge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bc-perks);
|
||||
color: var(--bc-perks-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rate-nudge > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rate-nudge b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 16px;
|
||||
}
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.stars button {
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.stars button:active {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
.stars [data-on] svg {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* ───── кнопки-«таблетки» как на сайте ───── */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 46px;
|
||||
padding: 0 20px;
|
||||
border-radius: var(--bc-pill);
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.pill--block {
|
||||
width: 100%;
|
||||
}
|
||||
.pill--dark {
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
.pill--pink {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.pill--green {
|
||||
background: var(--green);
|
||||
color: var(--green-ink);
|
||||
}
|
||||
.screen-head .pill--dark {
|
||||
width: 46px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ───── заголовок экрана ───── */
|
||||
.screen-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.screen-head__title {
|
||||
margin: 0;
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ───── направления ───── */
|
||||
.tiles .tile {
|
||||
min-height: 118px;
|
||||
border-radius: 26px;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.tiles .tile .tile__title {
|
||||
margin-top: auto;
|
||||
font-size: 18px;
|
||||
}
|
||||
.tile__note {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ───── чат ───── */
|
||||
.chat-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 28px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chat-cta > span:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.chat-cta b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 17px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ───── QR-карта ───── */
|
||||
.hero--card {
|
||||
align-items: stretch;
|
||||
padding-top: 22px;
|
||||
}
|
||||
.hero--card .pill {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.qr-card {
|
||||
border-radius: 28px;
|
||||
padding: 16px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.qr-card[data-plan='free'] {
|
||||
background: var(--plum-2);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1.5px var(--plum-line);
|
||||
}
|
||||
.qr-card[data-plan='elite'] {
|
||||
background: var(--bc-perks);
|
||||
}
|
||||
.qr-card__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.qr-card__top b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-size: 26px;
|
||||
}
|
||||
.qr-card__code {
|
||||
align-self: center;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
line-height: 0;
|
||||
}
|
||||
.qr-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.qr-card__timer {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.bc-segmented button[aria-pressed='true'] {
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ───── нижнее меню — слива и розовая кнопка чата ───── */
|
||||
.bc-bottomnav {
|
||||
background: var(--bc-plum);
|
||||
box-shadow: 0 10px 30px rgba(74, 16, 40, 0.35);
|
||||
}
|
||||
.bc-bottomnav a,
|
||||
.bc-bottomnav button {
|
||||
color: rgba(246, 195, 211, 0.6);
|
||||
}
|
||||
.bc-bottomnav [aria-current='page'] {
|
||||
color: var(--bc-pink);
|
||||
}
|
||||
.bc-bottomnav .bc-bottomnav__plus {
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
@@ -1,103 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CATEGORY_ICON, Card, Chip, Icon, Spinner, type CategoryCode } from '@barev/ui';
|
||||
import { navigate, useI18n, useTRPC } from '@barev/web';
|
||||
import { LEVEL_NAME, LevelBadge, levelDiscount, OrderRow, toneClass, type Level } from '../lib.tsx';
|
||||
import { CATEGORY_ICON, Icon, Spinner, type CategoryCode } from '@barev/ui';
|
||||
import { useI18n, useTRPC } from '@barev/web';
|
||||
import { ClubTicket, LEVEL_NAME, levelDiscount, QrGlyph, Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary, type Level } from '../lib.tsx';
|
||||
|
||||
/** Главная для тех, кто уже живёт в Армении: заказы, быстрые кнопки, направления со скидкой по уровню */
|
||||
/**
|
||||
* Главная для тех, кто уже живёт в Армении.
|
||||
* Сверху — сливовый блок как на сайте: приветствие и клубная карта.
|
||||
* Дальше — «Сейчас» (ближайший заказ со шкалой), просьба оценить, направления, чат.
|
||||
*/
|
||||
export function Home() {
|
||||
const { t, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const me = useQuery(trpc.client.me.queryOptions());
|
||||
const orders = useQuery({ ...trpc.client.orders.list.queryOptions(), refetchInterval: 20_000 });
|
||||
const catalog = useQuery(trpc.client.catalog.queryOptions());
|
||||
const timeline = useOrderTimeline();
|
||||
const summary = useParamsSummary();
|
||||
const level = (me.data?.membership.level ?? 'BAREV') as Level;
|
||||
const plus = level !== 'BAREV';
|
||||
const lastDone = orders.data?.history.find((o) => o.status !== 'CANCELLED');
|
||||
const now = orders.data?.active[0];
|
||||
const moreActive = (orders.data?.active.length ?? 0) - 1;
|
||||
const toRate = orders.data?.history.find((o) => o.done && !o.rating);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<header className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div className="bc-small bc-muted">{t('client.home.hello')}</div>
|
||||
<h1 className="bc-h2">{me.data?.firstName ?? t('client.home.guest')}</h1>
|
||||
<main className="app app--hero">
|
||||
<section className="hero">
|
||||
<div className="hero__word" aria-hidden="true">
|
||||
Barev
|
||||
</div>
|
||||
<LevelBadge level={level} />
|
||||
</header>
|
||||
<h1 className="hero__hello">
|
||||
<small>{t('client.home.hello')}</small>
|
||||
<span>{me.data?.firstName ?? t('client.home.guest')}</span>
|
||||
</h1>
|
||||
<ClubTicket
|
||||
level={level}
|
||||
code={me.data?.cardCode}
|
||||
href="#/perks"
|
||||
until={
|
||||
level !== 'BAREV' && me.data?.membership.endsAt
|
||||
? t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })
|
||||
: t('client.home.free')
|
||||
}
|
||||
action={
|
||||
<>
|
||||
<QrGlyph />
|
||||
<span>{t('client.home.cardShow')}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{plus && me.data?.membership.endsAt && (
|
||||
<Card tone="perks" tight>
|
||||
<div className="bc-row">
|
||||
<Icon name="gift" />
|
||||
<div className="bc-small" style={{ fontWeight: 600 }}>
|
||||
{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })}
|
||||
</div>
|
||||
{orders.isPending ? (
|
||||
<Spinner />
|
||||
) : now ? (
|
||||
<section className="sect">
|
||||
<div className="sect__head">
|
||||
<h2 className="sect__title">
|
||||
<span className="live-dot" aria-hidden="true" />
|
||||
{t('client.home.now')}
|
||||
</h2>
|
||||
<a href="#/orders" className="sect__link">
|
||||
{t('client.home.allOrders')}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
<a href={`#/orders/${now.id}`} className="now-card">
|
||||
<span className="now-card__top">
|
||||
<span className={`order-card__icon ${toneClass(now.category)}`}>
|
||||
<Icon name={CATEGORY_ICON[now.category as CategoryCode] ?? 'home'} size={22} />
|
||||
</span>
|
||||
<span className="now-card__main">
|
||||
<b>{t(`services.${now.serviceCode}.name`)}</b>
|
||||
<span>{summary(now.serviceCode, now.params)}</span>
|
||||
</span>
|
||||
<Icon name="arrow" size={20} />
|
||||
</span>
|
||||
<span className="now-card__when">
|
||||
<Icon name="clock" size={18} />
|
||||
{dt(now.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<Timeline items={timeline(now)} compact />
|
||||
</a>
|
||||
{moreActive > 0 && (
|
||||
<a href="#/orders" className="more-link">
|
||||
{t('client.home.moreActive', { count: moreActive })}
|
||||
<Icon name="arrow" size={16} />
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="empty-now">
|
||||
<p>{t('client.home.noOrders')}</p>
|
||||
<a href="#/order/cleaning" className="pill pill--dark">
|
||||
<Icon name="plus" size={18} strokeWidth={2.6} />
|
||||
{t('services.cleaning.name')}
|
||||
</a>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!plus && (
|
||||
{toRate && (
|
||||
<a href={`#/orders/${toRate.id}?rate=1`} className="rate-nudge">
|
||||
<span>
|
||||
<b>{t('client.rate.prompt')}</b>
|
||||
<span>
|
||||
{t(`services.${toRate.serviceCode}.name`)} · {dt(toRate.scheduledAt, { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
</span>
|
||||
<Stars value={0} size={22} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<section className="sect">
|
||||
<div className="sect__head">
|
||||
<h2 className="sect__title">{t('client.home.orderTitle')}</h2>
|
||||
<a href="#/services" className="sect__link">
|
||||
{t('client.nav.services')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="tiles">
|
||||
{(catalog.data?.categories ?? []).map((c) => (
|
||||
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
|
||||
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={26} />
|
||||
<span className="tile__title">{t(`categories.${c.code}.name`)}</span>
|
||||
<span className="tile__note">{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{level === 'BAREV' && (
|
||||
<a href="#/membership" className="upsell">
|
||||
<b>{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}</b>
|
||||
<span className="bc-small">{t('client.home.upsellText')}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<Card style={{ padding: 16 }}>
|
||||
<div className="bc-between" style={{ padding: '0 4px 8px' }}>
|
||||
<h2 className="bc-h3">{t('client.home.myOrders')}</h2>
|
||||
<a href="#/orders" className="bc-small bc-muted">
|
||||
{t('client.home.allOrders')}
|
||||
</a>
|
||||
</div>
|
||||
{orders.isPending ? (
|
||||
<Spinner />
|
||||
) : orders.data && orders.data.active.length > 0 ? (
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
{orders.data.active.slice(0, 4).map((o) => (
|
||||
<OrderRow key={o.id} o={o} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bc-stack" style={{ padding: '4px 4px 6px' }}>
|
||||
<p className="bc-muted" style={{ margin: 0 }}>
|
||||
{t('client.home.noOrders')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="quick">
|
||||
<Chip onClick={() => navigate('/order/cleaning')}>
|
||||
<Icon name="home" size={18} />
|
||||
{t('services.cleaning.name')}
|
||||
</Chip>
|
||||
{lastDone && (
|
||||
<Chip onClick={() => navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}>
|
||||
<Icon name="repeat" size={18} />
|
||||
{t('client.home.repeatLast')}
|
||||
</Chip>
|
||||
)}
|
||||
{!me.data?.agencyName && (
|
||||
<Chip onClick={() => navigate('/profile?code=1')}>
|
||||
<Icon name="gift" size={18} />
|
||||
{t('client.home.enterCode')}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tiles">
|
||||
{(catalog.data?.categories ?? []).map((c) => (
|
||||
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
|
||||
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<div className="tile__title">{t(`categories.${c.code}.name`)}</div>
|
||||
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={24} />
|
||||
</div>
|
||||
<div className="bc-small">
|
||||
{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<a href="#/chat" className="chat-cta">
|
||||
<span>
|
||||
<b>{t('client.home.chatTitle')}</b>
|
||||
<span>{t('client.home.chatText')}</span>
|
||||
</span>
|
||||
<span className="pill pill--green">
|
||||
<Icon name="chat" size={18} />
|
||||
{t('client.nav.chat')}
|
||||
</span>
|
||||
</a>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Center, Icon, Sheet, Spinner, Steps } from '@barev/ui';
|
||||
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { PageHead, toneClass, useParamsSummary } from '../lib.tsx';
|
||||
import { Button, Center, Icon, Sheet, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, dateKeyToDate, errorText, haptic, navigate, nextDays, paramsExtras, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Stars, Timeline, toneClass, useOrderTimeline, useParamsSummary } from '../lib.tsx';
|
||||
|
||||
export function OrderDetail({ id }: { id: string }) {
|
||||
const { t, amd, dt, locale } = useI18n();
|
||||
@@ -10,6 +10,7 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const summary = useParamsSummary();
|
||||
const timeline = useOrderTimeline();
|
||||
const order = useQuery({ ...trpc.client.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
|
||||
const preview = useQuery({ ...trpc.client.orders.cancelPreview.queryOptions({ id }), enabled: !!order.data?.active });
|
||||
const [reschedule, setReschedule] = useState(false);
|
||||
@@ -32,22 +33,8 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
</Center>
|
||||
);
|
||||
const o = order.data;
|
||||
|
||||
// шкала прогресса: пройденные статусы по истории, текущий, остальные
|
||||
const passed = new Set(o.history.map((h) => h.to));
|
||||
const currentIdx = o.steps.indexOf(o.status);
|
||||
const steps =
|
||||
o.status === 'CANCELLED'
|
||||
? [...o.steps.filter((s) => passed.has(s)).map((s) => ({ label: t(`status.${o.flow}.${s}`), state: 'done' as const })), { label: t(`status.${o.flow}.CANCELLED`), state: 'current' as const }]
|
||||
: o.steps.map((s, i) => {
|
||||
const at = o.history.find((h) => h.to === s && !h.event)?.at ?? o.history.find((h) => h.to === s)?.at;
|
||||
return {
|
||||
label: t(`status.${o.flow}.${s}`),
|
||||
state: i < currentIdx ? ('done' as const) : i === currentIdx ? ('current' as const) : ('todo' as const),
|
||||
meta: at && i <= currentIdx ? dt(at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : undefined,
|
||||
};
|
||||
});
|
||||
const arrived = o.history.some((h) => h.event === 'arrived');
|
||||
const extras = paramsExtras(t, o.params);
|
||||
|
||||
async function onCancel() {
|
||||
const p = preview.data?.policy;
|
||||
@@ -58,47 +45,63 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<main className="app app--plain">
|
||||
<PageHead title={`#${o.number}`} />
|
||||
<section className={`od-hero ${toneClass(o.category)}`}>
|
||||
<div className="od-hero__bar">
|
||||
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
|
||||
<Icon name="back" />
|
||||
</button>
|
||||
<span className="od-hero__num">#{o.number}</span>
|
||||
</div>
|
||||
<span className="od-hero__status" data-status={o.status === 'CANCELLED' ? 'cancelled' : o.active ? 'active' : 'done'}>
|
||||
{t(`status.${o.flow}.${o.status}`)}
|
||||
{arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''}
|
||||
</span>
|
||||
<h1 className="od-hero__title">{t(`services.${o.serviceCode}.name`)}</h1>
|
||||
<div className="od-hero__sub">
|
||||
{summary(o.serviceCode, o.params)}
|
||||
{extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card className={toneClass(o.category)} style={{ padding: 20 }}>
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<h2 className="bc-h2">{t(`services.${o.serviceCode}.name`)}</h2>
|
||||
<span className="bc-badge">{t(`status.${o.flow}.${o.status}`)}</span>
|
||||
</div>
|
||||
<div>{summary(o.serviceCode, o.params)}</div>
|
||||
{paramsExtras(t, o.params).length > 0 && <div className="bc-small">+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}</div>}
|
||||
<div className="bc-row" style={{ fontWeight: 600 }}>
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="clock" size={18} />
|
||||
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
{o.addressText && (
|
||||
<div className="bc-row">
|
||||
{t('client.detail.when')}
|
||||
</dt>
|
||||
<dd>{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}</dd>
|
||||
</div>
|
||||
{o.addressText && (
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="pin" size={18} />
|
||||
{t('client.detail.where')}
|
||||
</dt>
|
||||
<dd>
|
||||
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
|
||||
{o.addressText}
|
||||
</div>
|
||||
)}
|
||||
{o.providerName && (
|
||||
<div className="bc-row">
|
||||
<Icon name="user" size={18} />
|
||||
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
|
||||
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="user" size={18} />
|
||||
{t(`client.detail.providerLabel.${o.flow}`)}
|
||||
</dt>
|
||||
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
|
||||
</div>
|
||||
</Card>
|
||||
</dl>
|
||||
|
||||
<Card>
|
||||
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
|
||||
{t('client.detail.progress')}
|
||||
</h2>
|
||||
<Steps items={steps} />
|
||||
</Card>
|
||||
{o.done && <RateBlock id={o.id} rating={o.rating} />}
|
||||
|
||||
<section className="panel">
|
||||
<h2 className="panel__title">{t('client.detail.progress')}</h2>
|
||||
<Timeline items={timeline(o)} />
|
||||
</section>
|
||||
|
||||
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
|
||||
|
||||
<Card className="price-lines">
|
||||
<section className="panel price-lines">
|
||||
<div className="bc-between">
|
||||
<span>{t('client.order.price')}</span>
|
||||
<span>{amd(o.basePrice)}</span>
|
||||
@@ -123,16 +126,31 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
{t(`payment.${o.paymentStatus}`)}
|
||||
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{o.note && (
|
||||
<Card tight>
|
||||
<section className="panel">
|
||||
<div className="bc-small bc-muted">{t('client.order.note')}</div>
|
||||
<div>{o.note}</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
{!o.active && o.status !== 'CANCELLED' && (
|
||||
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
<Icon name="repeat" size={18} />
|
||||
{t('client.detail.repeat')}
|
||||
</Button>
|
||||
)}
|
||||
{o.status === 'CANCELLED' && (
|
||||
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
{t('client.detail.orderAgain')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="light" block onClick={() => navigate('/chat')}>
|
||||
<Icon name="chat" size={18} />
|
||||
{t('client.detail.ask')}
|
||||
</Button>
|
||||
{o.active && preview.data?.canReschedule && (
|
||||
<Button variant="light" block onClick={() => setReschedule(true)}>
|
||||
{t('client.detail.reschedule')}
|
||||
@@ -148,17 +166,6 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
{t('client.detail.viaOperator', { hours: preview.data.freeHours })}
|
||||
</p>
|
||||
)}
|
||||
{!o.active && o.status !== 'CANCELLED' && (
|
||||
<Button variant="dark" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
<Icon name="repeat" size={18} />
|
||||
{t('client.detail.repeat')}
|
||||
</Button>
|
||||
)}
|
||||
{o.status === 'CANCELLED' && (
|
||||
<Button variant="light" block onClick={() => navigate(`/order/${o.serviceCode}?repeat=${o.id}`)}>
|
||||
{t('client.detail.orderAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RescheduleSheet open={reschedule} onClose={() => setReschedule(false)} order={o} />
|
||||
@@ -166,20 +173,76 @@ export function OrderDetail({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Оценка после выполнения: звёзды и комментарий; уже оценённый заказ показывает оценку с кнопкой «Изменить» */
|
||||
function RateBlock({ id, rating }: { id: string; rating: { score: number; comment: string | null } | null }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const { query } = useLocation();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [editing, setEditing] = useState(!rating);
|
||||
const [score, setScore] = useState(rating?.score ?? 0);
|
||||
const [comment, setComment] = useState(rating?.comment ?? '');
|
||||
useEffect(() => {
|
||||
if (query.get('rate') && ref.current) ref.current.scrollIntoView({ block: 'center' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const rate = useMutation(
|
||||
trpc.client.orders.rate.mutationOptions({
|
||||
onSuccess: () => {
|
||||
haptic.ok();
|
||||
toast(t('client.rate.thanks'));
|
||||
setEditing(false);
|
||||
void qc.invalidateQueries({ queryKey: trpc.client.orders.pathKey() });
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!editing && rating)
|
||||
return (
|
||||
<section className="rate rate--done" ref={ref}>
|
||||
<div className="bc-between">
|
||||
<span className="rate__label">{t('client.rate.yours')}</span>
|
||||
<button type="button" className="rate__change" onClick={() => setEditing(true)}>
|
||||
{t('client.rate.change')}
|
||||
</button>
|
||||
</div>
|
||||
<Stars value={rating.score} size={26} />
|
||||
{rating.comment && <p className="rate__comment">{rating.comment}</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rate" ref={ref}>
|
||||
<h2 className="rate__title">{t('client.rate.title')}</h2>
|
||||
<p className="rate__text">{t('client.rate.text')}</p>
|
||||
<Stars value={score} onChange={setScore} size={40} />
|
||||
{score > 0 && (
|
||||
<>
|
||||
<textarea className="rate__input" rows={2} maxLength={1000} placeholder={t('client.rate.comment')} value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
<button type="button" className="pill pill--pink pill--block" disabled={rate.isPending} onClick={() => rate.mutate({ id, score, comment: comment.trim() || undefined })}>
|
||||
{t('client.rate.send')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoReport({ photos }: { photos: { code: string; fileId: string }[] }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
|
||||
{t('client.detail.photoReport')}
|
||||
</h2>
|
||||
<section className="panel">
|
||||
<h2 className="panel__title">{t('client.detail.photoReport')}</h2>
|
||||
<div className="photos">
|
||||
{photos.map((p) => (
|
||||
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Center, LinkButton, Segmented, Spinner } from '@barev/ui';
|
||||
import { Center, Icon, Segmented, Spinner } from '@barev/ui';
|
||||
import { useI18n, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { OrderRow, PageHead } from '../lib.tsx';
|
||||
import { OrderRow } from '../lib.tsx';
|
||||
|
||||
export function Orders() {
|
||||
const { t } = useI18n();
|
||||
@@ -18,7 +18,12 @@ export function Orders() {
|
||||
const list = orders.data[tab];
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={t('client.orders.title')} />
|
||||
<header className="screen-head">
|
||||
<h1 className="screen-head__title">{t('client.orders.title')}</h1>
|
||||
<a href="#/services" className="pill pill--dark" aria-label={t('client.orders.order')}>
|
||||
<Icon name="plus" size={18} strokeWidth={2.6} />
|
||||
</a>
|
||||
</header>
|
||||
<Segmented
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -28,12 +33,14 @@ export function Orders() {
|
||||
]}
|
||||
/>
|
||||
{list.length === 0 ? (
|
||||
<div className="bc-stack" style={{ alignItems: 'center', padding: 24 }}>
|
||||
<p className="bc-muted">{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p>
|
||||
<LinkButton href="#/services">{t('client.orders.order')}</LinkButton>
|
||||
<div className="empty-now">
|
||||
<p>{t(tab === 'active' ? 'client.home.noOrders' : 'client.orders.noHistory')}</p>
|
||||
<a href="#/services" className="pill pill--dark">
|
||||
{t('client.orders.order')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
<div className="order-list">
|
||||
{list.map((o) => (
|
||||
<OrderRow key={o.id} o={o} />
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card } from '@barev/ui';
|
||||
import { Card, Icon } from '@barev/ui';
|
||||
import { useI18n, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LEVEL_NAME, LEVEL_TONE, PageHead, type Level } from '../lib.tsx';
|
||||
import { LEVEL_NAME, type Level } from '../lib.tsx';
|
||||
import { Qr } from '../qr.tsx';
|
||||
|
||||
/**
|
||||
@@ -16,27 +16,33 @@ export function Perks() {
|
||||
const qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
|
||||
const partners = useQuery(trpc.client.partners.queryOptions());
|
||||
const level = (me.data?.membership.level ?? 'BAREV') as Level;
|
||||
const tone = LEVEL_TONE[level];
|
||||
const left = useSecondsLeft(qr.data?.expiresAt);
|
||||
const name = (l: Record<string, string>) => l[locale] || l.ru || '';
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={t('client.perks.title')} back={false} />
|
||||
<section className="card-qr" style={{ background: level === 'BAREV' ? 'var(--bc-plum)' : tone.bg, color: level === 'BAREV' ? '#fff' : tone.ink }}>
|
||||
<div className="card-qr__top">
|
||||
<span>Barev Club · {LEVEL_NAME[level]}</span>
|
||||
<b>{me.data?.firstName}</b>
|
||||
</div>
|
||||
<div className="card-qr__code">{qr.data ? <Qr value={qr.data.url} size={208} label={t('client.partners.qrLabel')} /> : <div style={{ width: 208, height: 208 }} />}</div>
|
||||
<div className="card-qr__meta">
|
||||
<span className="bc-num">{me.data?.cardCode}</span>
|
||||
<span>{t('client.partners.qrRefresh', { seconds: left })}</span>
|
||||
<main className="app app--hero">
|
||||
<section className="hero hero--card">
|
||||
<h1 className="hero__hello">{t('client.card.title')}</h1>
|
||||
<div className="qr-card" data-plan={level === 'BAREV' ? 'free' : level === 'BAREV_PLUS' ? 'plus' : 'elite'}>
|
||||
<div className="qr-card__top">
|
||||
<span>Barev Club</span>
|
||||
<b>{LEVEL_NAME[level]}</b>
|
||||
</div>
|
||||
<div className="qr-card__code">{qr.data ? <Qr value={qr.data.url} size={200} label={t('client.partners.qrLabel')} /> : <div style={{ width: 200, height: 200 }} />}</div>
|
||||
<div className="qr-card__meta">
|
||||
<span>
|
||||
<b>{me.data?.firstName}</b> · <span className="bc-num">{me.data?.cardCode}</span>
|
||||
</span>
|
||||
<span className="qr-card__timer">{t('client.partners.qrRefresh', { seconds: left })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero__note">{t('client.partners.howTo')}</p>
|
||||
<a href="#/membership" className="pill pill--pink">
|
||||
{t('client.card.plans')}
|
||||
<Icon name="arrow" size={18} />
|
||||
</a>
|
||||
</section>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('client.partners.howTo')}
|
||||
</p>
|
||||
<h2 className="sect__title">{t('client.card.partners')}</h2>
|
||||
{partners.data?.length === 0 ? (
|
||||
<Card tone="perks" className="bc-stack">
|
||||
<h2 className="bc-h3">{t('client.perks.soonTitle')}</h2>
|
||||
|
||||
Reference in new issue
Block a user