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
+1529
-198
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { defaultParams, discountFor, LOCALES } from '@barev/core';
|
||||
import {
|
||||
import { rateOrder,
|
||||
applyAgencyCode,
|
||||
availableSlots,
|
||||
cancelByClient,
|
||||
@@ -229,6 +229,10 @@ export const clientRouter = router({
|
||||
reschedule: clientProcedure
|
||||
.input(z.object({ id: z.string(), scheduledAt: z.date() }))
|
||||
.mutation(({ ctx, input }) => rescheduleByClient(ctx.svc, { orderId: input.id, clientId: ctx.client.id, scheduledAt: input.scheduledAt })),
|
||||
/** Оценка после выполнения: звёзды и комментарий */
|
||||
rate: consentedClientProcedure
|
||||
.input(z.object({ id: z.string(), score: z.number().int().min(1).max(5), comment: z.string().max(1000).optional() }))
|
||||
.mutation(({ ctx, input }) => rateOrder(ctx.svc, { orderId: input.id, clientId: ctx.client.id, score: input.score, comment: input.comment })),
|
||||
/** ORD-7 */
|
||||
repeat: clientProcedure.input(z.object({ id: z.string() })).query(({ ctx, input }) => repeatDraft(ctx.db, input.id, ctx.client.id)),
|
||||
}),
|
||||
|
||||
@@ -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' }) })}
|
||||
{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>
|
||||
</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 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>
|
||||
</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>
|
||||
</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}`} />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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 }}>
|
||||
<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>
|
||||
|
||||
<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' })}
|
||||
{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 className="bc-row">
|
||||
<div>
|
||||
<dt>
|
||||
<Icon name="pin" size={18} />
|
||||
{t('client.detail.where')}
|
||||
</dt>
|
||||
<dd>
|
||||
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
|
||||
{o.addressText}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{o.providerName && (
|
||||
<div className="bc-row">
|
||||
<div>
|
||||
<dt>
|
||||
<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>}
|
||||
{t(`client.detail.providerLabel.${o.flow}`)}
|
||||
</dt>
|
||||
<dd>{o.providerName ?? <span className="bc-muted">{t('client.detail.whoPending')}</span>}</dd>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<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="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>
|
||||
<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>
|
||||
|
||||
@@ -455,7 +455,10 @@
|
||||
"perks": "Perks",
|
||||
"profile": "Profile",
|
||||
"plan": "Plan",
|
||||
"docs": "Documents"
|
||||
"docs": "Documents",
|
||||
"orders": "Orders",
|
||||
"card": "Card",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"openInTelegram": {
|
||||
"title": "Open in Telegram",
|
||||
@@ -480,7 +483,14 @@
|
||||
"discount": "−{{pct}}% with Barev+",
|
||||
"upsellTitle": "Up to −{{pct}}% with Barev Elite",
|
||||
"upsellText": "Cleaning, repairs, transfers, paperwork and partner gifts",
|
||||
"levelUntil": "{{plan}} active until {{date}}"
|
||||
"levelUntil": "{{plan}} active until {{date}}",
|
||||
"now": "Now",
|
||||
"moreActive": "More active: {{count}}",
|
||||
"orderTitle": "Book a service",
|
||||
"cardShow": "Show QR",
|
||||
"free": "Free level",
|
||||
"chatTitle": "Any question — ask in chat",
|
||||
"chatText": "We reply in your language"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
@@ -547,7 +557,13 @@
|
||||
"LANGUAGE": "Teacher",
|
||||
"CAREER": "Coach",
|
||||
"DOCUMENTS": "Specialist"
|
||||
}
|
||||
},
|
||||
"when": "When",
|
||||
"where": "Where",
|
||||
"who": "Provider",
|
||||
"whoPending": "being assigned",
|
||||
"payment": "Payment",
|
||||
"ask": "Question about this order"
|
||||
},
|
||||
"perks": {
|
||||
"title": "Perks",
|
||||
@@ -670,6 +686,22 @@
|
||||
"team": "Barev Club",
|
||||
"fileTooBig": "The file is larger than 10 MB",
|
||||
"file": "File"
|
||||
},
|
||||
"rate": {
|
||||
"title": "How did it go?",
|
||||
"text": "Your rating helps us keep the quality high",
|
||||
"comment": "Comment — optional",
|
||||
"send": "Rate",
|
||||
"thanks": "Thanks for your rating!",
|
||||
"yours": "Your rating",
|
||||
"change": "Change",
|
||||
"star": "{{count}} of 5",
|
||||
"prompt": "Rate your order"
|
||||
},
|
||||
"card": {
|
||||
"title": "Club card",
|
||||
"partners": "Club partners",
|
||||
"plans": "Levels and discounts"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
|
||||
@@ -455,7 +455,10 @@
|
||||
"perks": "Descuentos",
|
||||
"profile": "Perfil",
|
||||
"plan": "Plan",
|
||||
"docs": "Documentos"
|
||||
"docs": "Documentos",
|
||||
"orders": "Pedidos",
|
||||
"card": "Tarjeta",
|
||||
"chat": "Chat"
|
||||
},
|
||||
"openInTelegram": {
|
||||
"title": "Abre en Telegram",
|
||||
@@ -480,7 +483,14 @@
|
||||
"discount": "−{{pct}}% con Barev+",
|
||||
"upsellTitle": "Hasta −{{pct}}% con Barev Elite",
|
||||
"upsellText": "Limpieza, reparaciones, traslados, trámites y regalos de socios",
|
||||
"levelUntil": "{{plan}} activo hasta el {{date}}"
|
||||
"levelUntil": "{{plan}} activo hasta el {{date}}",
|
||||
"now": "Ahora",
|
||||
"moreActive": "Más activos: {{count}}",
|
||||
"orderTitle": "Reservar",
|
||||
"cardShow": "Mostrar QR",
|
||||
"free": "Nivel gratuito",
|
||||
"chatTitle": "Cualquier pregunta, al chat",
|
||||
"chatText": "Respondemos en tu idioma"
|
||||
},
|
||||
"services": {
|
||||
"title": "Servicios",
|
||||
@@ -547,7 +557,13 @@
|
||||
"LANGUAGE": "Profesor/a",
|
||||
"CAREER": "Asesor",
|
||||
"DOCUMENTS": "Especialista"
|
||||
}
|
||||
},
|
||||
"when": "Cuándo",
|
||||
"where": "Dónde",
|
||||
"who": "Profesional",
|
||||
"whoPending": "asignando",
|
||||
"payment": "Pago",
|
||||
"ask": "Pregunta sobre el pedido"
|
||||
},
|
||||
"perks": {
|
||||
"title": "Descuentos",
|
||||
@@ -670,6 +686,22 @@
|
||||
"team": "Barev Club",
|
||||
"fileTooBig": "El archivo supera los 10 MB",
|
||||
"file": "Archivo"
|
||||
},
|
||||
"rate": {
|
||||
"title": "¿Qué tal fue?",
|
||||
"text": "Tu valoración nos ayuda a cuidar la calidad",
|
||||
"comment": "Comentario (opcional)",
|
||||
"send": "Valorar",
|
||||
"thanks": "¡Gracias por tu valoración!",
|
||||
"yours": "Tu valoración",
|
||||
"change": "Cambiar",
|
||||
"star": "{{count}} de 5",
|
||||
"prompt": "Valora tu pedido"
|
||||
},
|
||||
"card": {
|
||||
"title": "Tarjeta del club",
|
||||
"partners": "Socios del club",
|
||||
"plans": "Niveles y descuentos"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
|
||||
@@ -455,7 +455,10 @@
|
||||
"perks": "Զեղչեր",
|
||||
"profile": "Պրոֆիլ",
|
||||
"plan": "Պլան",
|
||||
"docs": "Փաստաթղթեր"
|
||||
"docs": "Փաստաթղթեր",
|
||||
"orders": "Պատվերներ",
|
||||
"card": "Քարտ",
|
||||
"chat": "Չատ"
|
||||
},
|
||||
"openInTelegram": {
|
||||
"title": "Բացեք Telegram-ում",
|
||||
@@ -480,7 +483,14 @@
|
||||
"discount": "−{{pct}}% Barev+-ով",
|
||||
"upsellTitle": "Մինչև −{{pct}}% Barev Elite-ով",
|
||||
"upsellText": "Մաքրում, վերանորոգում, տրանսֆեր, փաստաթղթեր և նվերներ գործընկերներից",
|
||||
"levelUntil": "{{plan}}-ը գործում է մինչև {{date}}"
|
||||
"levelUntil": "{{plan}}-ը գործում է մինչև {{date}}",
|
||||
"now": "Հիմա",
|
||||
"moreActive": "Եվս ակտիվ՝ {{count}}",
|
||||
"orderTitle": "Պատվիրել",
|
||||
"cardShow": "Ցույց տալ QR",
|
||||
"free": "Անվճար մակարդակ",
|
||||
"chatTitle": "Ցանկացած հարց՝ չատում",
|
||||
"chatText": "Կպատասխանենք ձեր լեզվով"
|
||||
},
|
||||
"services": {
|
||||
"title": "Ծառայություններ",
|
||||
@@ -547,7 +557,13 @@
|
||||
"LANGUAGE": "Դասավանդող",
|
||||
"CAREER": "Խորհրդատու",
|
||||
"DOCUMENTS": "Մասնագետ"
|
||||
}
|
||||
},
|
||||
"when": "Երբ",
|
||||
"where": "Որտեղ",
|
||||
"who": "Կատարող",
|
||||
"whoPending": "ընտրում ենք",
|
||||
"payment": "Վճարում",
|
||||
"ask": "Հարց պատվերի վերաբերյալ"
|
||||
},
|
||||
"perks": {
|
||||
"title": "Զեղչեր",
|
||||
@@ -670,6 +686,22 @@
|
||||
"team": "Barev Club",
|
||||
"fileTooBig": "Ֆայլը 10 ՄԲ-ից մեծ է",
|
||||
"file": "Ֆայլ"
|
||||
},
|
||||
"rate": {
|
||||
"title": "Ինչպե՞ս անցավ",
|
||||
"text": "Գնահատականը կօգնի մեզ հետևել որակին",
|
||||
"comment": "Մեկնաբանություն՝ ըստ ցանկության",
|
||||
"send": "Գնահատել",
|
||||
"thanks": "Շնորհակալություն գնահատականի համար",
|
||||
"yours": "Ձեր գնահատականը",
|
||||
"change": "Փոխել",
|
||||
"star": "{{count}}՝ 5-ից",
|
||||
"prompt": "Գնահատեք պատվերը"
|
||||
},
|
||||
"card": {
|
||||
"title": "Ակումբային քարտ",
|
||||
"partners": "Ակումբի գործընկերներ",
|
||||
"plans": "Մակարդակներ և զեղչեր"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
|
||||
@@ -455,7 +455,10 @@
|
||||
"perks": "Скидки",
|
||||
"profile": "Профиль",
|
||||
"plan": "План",
|
||||
"docs": "Документы"
|
||||
"docs": "Документы",
|
||||
"orders": "Заказы",
|
||||
"card": "Карта",
|
||||
"chat": "Чат"
|
||||
},
|
||||
"openInTelegram": {
|
||||
"title": "Открой в Telegram",
|
||||
@@ -480,7 +483,14 @@
|
||||
"discount": "−{{pct}}% с Barev+",
|
||||
"upsellTitle": "Скидки до −{{pct}}% с Barev Elite",
|
||||
"upsellText": "Клининг, ремонт, трансфер, документы и подарки партнёров",
|
||||
"levelUntil": "{{plan}} активен до {{date}}"
|
||||
"levelUntil": "{{plan}} активен до {{date}}",
|
||||
"now": "Сейчас",
|
||||
"moreActive": "Ещё активных: {{count}}",
|
||||
"orderTitle": "Заказать",
|
||||
"cardShow": "Показать QR",
|
||||
"free": "Бесплатный уровень",
|
||||
"chatTitle": "Любой вопрос — в чат",
|
||||
"chatText": "Ответим на вашем языке"
|
||||
},
|
||||
"services": {
|
||||
"title": "Услуги",
|
||||
@@ -547,7 +557,13 @@
|
||||
"LANGUAGE": "Преподаватель",
|
||||
"CAREER": "Консультант",
|
||||
"DOCUMENTS": "Специалист"
|
||||
}
|
||||
},
|
||||
"when": "Когда",
|
||||
"where": "Где",
|
||||
"who": "Исполнитель",
|
||||
"whoPending": "подбираем",
|
||||
"payment": "Оплата",
|
||||
"ask": "Вопрос по заказу"
|
||||
},
|
||||
"perks": {
|
||||
"title": "Скидки",
|
||||
@@ -670,6 +686,22 @@
|
||||
"team": "Barev Club",
|
||||
"fileTooBig": "Файл больше 10 МБ",
|
||||
"file": "Файл"
|
||||
},
|
||||
"rate": {
|
||||
"title": "Как всё прошло?",
|
||||
"text": "Оценка поможет нам следить за качеством",
|
||||
"comment": "Комментарий — по желанию",
|
||||
"send": "Оценить",
|
||||
"thanks": "Спасибо за оценку!",
|
||||
"yours": "Ваша оценка",
|
||||
"change": "Изменить",
|
||||
"star": "{{count}} из 5",
|
||||
"prompt": "Оцените заказ"
|
||||
},
|
||||
"card": {
|
||||
"title": "Клубная карта",
|
||||
"partners": "Партнёры клуба",
|
||||
"plans": "Уровни и скидки"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
|
||||
@@ -455,7 +455,10 @@
|
||||
"perks": "折扣",
|
||||
"profile": "我的",
|
||||
"plan": "计划",
|
||||
"docs": "证件"
|
||||
"docs": "证件",
|
||||
"orders": "订单",
|
||||
"card": "会员卡",
|
||||
"chat": "聊天"
|
||||
},
|
||||
"openInTelegram": {
|
||||
"title": "请在 Telegram 中打开",
|
||||
@@ -480,7 +483,14 @@
|
||||
"discount": "Barev+ 享 −{{pct}}%",
|
||||
"upsellTitle": "Barev Elite 最高 −{{pct}}%",
|
||||
"upsellText": "保洁、维修、接送、证件和合作伙伴礼品",
|
||||
"levelUntil": "{{plan}} 有效期至 {{date}}"
|
||||
"levelUntil": "{{plan}} 有效期至 {{date}}",
|
||||
"now": "进行中",
|
||||
"moreActive": "其他进行中:{{count}}",
|
||||
"orderTitle": "预订服务",
|
||||
"cardShow": "出示二维码",
|
||||
"free": "免费等级",
|
||||
"chatTitle": "有任何问题,请在聊天中提问",
|
||||
"chatText": "我们用您的语言回复"
|
||||
},
|
||||
"services": {
|
||||
"title": "服务",
|
||||
@@ -547,7 +557,13 @@
|
||||
"LANGUAGE": "老师",
|
||||
"CAREER": "顾问",
|
||||
"DOCUMENTS": "专员"
|
||||
}
|
||||
},
|
||||
"when": "时间",
|
||||
"where": "地点",
|
||||
"who": "服务人员",
|
||||
"whoPending": "正在分配",
|
||||
"payment": "付款",
|
||||
"ask": "关于此订单的问题"
|
||||
},
|
||||
"perks": {
|
||||
"title": "折扣",
|
||||
@@ -670,6 +686,22 @@
|
||||
"team": "Barev Club",
|
||||
"fileTooBig": "文件超过 10 MB",
|
||||
"file": "文件"
|
||||
},
|
||||
"rate": {
|
||||
"title": "服务体验如何?",
|
||||
"text": "您的评价帮助我们保证服务质量",
|
||||
"comment": "评论(可选)",
|
||||
"send": "提交评价",
|
||||
"thanks": "感谢您的评价!",
|
||||
"yours": "您的评价",
|
||||
"change": "修改",
|
||||
"star": "{{count}} / 5",
|
||||
"prompt": "请评价订单"
|
||||
},
|
||||
"card": {
|
||||
"title": "俱乐部会员卡",
|
||||
"partners": "俱乐部合作伙伴",
|
||||
"plans": "等级与折扣"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
|
||||
@@ -19,3 +19,4 @@ export * from './sales.ts';
|
||||
export * from './texts.ts';
|
||||
export * from './promo.ts';
|
||||
export * from './partners.ts';
|
||||
export * from './ratings.ts';
|
||||
@@ -12,6 +12,7 @@ const clientOrderInclude = {
|
||||
history: { orderBy: { createdAt: 'asc' } },
|
||||
checklist: { orderBy: { sortOrder: 'asc' } },
|
||||
paymentMethod: true,
|
||||
rating: { select: { score: true, comment: true } },
|
||||
} satisfies Prisma.OrderInclude;
|
||||
|
||||
type ClientOrderRow = Prisma.OrderGetPayload<{ include: typeof clientOrderInclude }>;
|
||||
@@ -27,6 +28,9 @@ function clientOrderView(o: ClientOrderRow) {
|
||||
flow,
|
||||
status: o.status,
|
||||
active: !isTerminal(flow, o.status),
|
||||
/** заказ выполнен — можно оценить */
|
||||
done: getFlow(flow).done.includes(o.status),
|
||||
rating: o.rating,
|
||||
scheduledAt: o.scheduledAt,
|
||||
durationMinutes: o.durationMinutes,
|
||||
isOnline: o.isOnline,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getFlow, type ServiceFlow } from '@barev/core';
|
||||
import { DomainError, type Ctx } from './context.ts';
|
||||
|
||||
/**
|
||||
* Оценка клиента после выполненного заказа: 1–5 звёзд и комментарий.
|
||||
* Повторная оценка заменяет прежнюю; рейтинг исполнителя (ratingSum/ratingCount) пересчитывается.
|
||||
*/
|
||||
export async function rateOrder(ctx: Ctx, input: { orderId: string; clientId: string; score: number; comment?: string | null }) {
|
||||
const score = Math.round(input.score);
|
||||
if (score < 1 || score > 5) throw new DomainError('INVALID_PARAMS');
|
||||
const comment = input.comment?.trim() || null;
|
||||
return ctx.db.$transaction(async (tx) => {
|
||||
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { service: true, rating: true } });
|
||||
if (!order || order.clientId !== input.clientId) throw new DomainError('NOT_FOUND');
|
||||
if (!getFlow(order.service.flow as ServiceFlow).done.includes(order.status)) throw new DomainError('TRANSITION_NOT_ALLOWED');
|
||||
const prev = order.rating;
|
||||
const providerId = prev?.providerId ?? order.providerId;
|
||||
const rating = prev
|
||||
? await tx.rating.update({ where: { id: prev.id }, data: { score, comment } })
|
||||
: await tx.rating.create({ data: { orderId: order.id, clientId: input.clientId, providerId, score, comment } });
|
||||
if (providerId) {
|
||||
await tx.provider.update({
|
||||
where: { id: providerId },
|
||||
data: prev ? { ratingSum: { increment: score - prev.score } } : { ratingSum: { increment: score }, ratingCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
return { score: rating.score, comment: rating.comment };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createOrder, DomainError, giveConsent, rateOrder, startClient } from '../src/index.ts';
|
||||
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
|
||||
|
||||
let fx: Awaited<ReturnType<typeof fixtures>>;
|
||||
beforeEach(async () => {
|
||||
fx = await fixtures();
|
||||
});
|
||||
afterAll(() => db.$disconnect());
|
||||
|
||||
const code = (p: Promise<unknown>) => p.then(() => null, (e: unknown) => (e instanceof DomainError ? e.code : String(e)));
|
||||
|
||||
describe('оценка заказа клиентом', () => {
|
||||
it('только после выполнения; повторная оценка заменяет прежнюю и пересчитывает рейтинг исполнителя', async () => {
|
||||
const { ctx } = makeCtx(NOW);
|
||||
const s = await startClient(ctx, tgUser(901));
|
||||
await giveConsent(ctx, s.clientId);
|
||||
const o = await createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
|
||||
await db.order.update({ where: { id: o.id }, data: { providerId: fx.maria.id } });
|
||||
const before = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
|
||||
|
||||
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 }))).toBe('TRANSITION_NOT_ALLOWED');
|
||||
|
||||
await db.order.update({ where: { id: o.id }, data: { status: 'DONE' } });
|
||||
const other = await startClient(ctx, tgUser(902));
|
||||
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: other.clientId, score: 5 }))).toBe('NOT_FOUND');
|
||||
expect(await code(rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 7 }))).toBe('INVALID_PARAMS');
|
||||
|
||||
expect(await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 3, comment: ' поздно пришла ' })).toEqual({ score: 3, comment: 'поздно пришла' });
|
||||
let p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
|
||||
expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([3, 1]);
|
||||
|
||||
await rateOrder(ctx, { orderId: o.id, clientId: s.clientId, score: 5 });
|
||||
p = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id } });
|
||||
expect([p.ratingSum - before.ratingSum, p.ratingCount - before.ratingCount]).toEqual([5, 1]);
|
||||
expect(await db.rating.count({ where: { orderId: o.id } })).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user