From ff527ad6d2fb5a37c1a533cebcea26fc5fb8d2e1 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Wed, 21 Oct 2026 03:51:58 +0400 Subject: [PATCH] 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) --- apps/api/src/routers/client.ts | 6 +- apps/miniapp-client/src/App.tsx | 14 +- apps/miniapp-client/src/lib.tsx | 150 +++- apps/miniapp-client/src/main.tsx | 1 + apps/miniapp-client/src/redesign.css | 845 ++++++++++++++++++ apps/miniapp-client/src/screens/Home.tsx | 201 +++-- .../src/screens/OrderDetail.tsx | 201 +++-- apps/miniapp-client/src/screens/Orders.tsx | 21 +- apps/miniapp-client/src/screens/Perks.tsx | 40 +- packages/i18n/locales/en.json | 38 +- packages/i18n/locales/es.json | 38 +- packages/i18n/locales/hy.json | 38 +- packages/i18n/locales/ru.json | 38 +- packages/i18n/locales/zh.json | 38 +- packages/services/src/index.ts | 1 + packages/services/src/queries.ts | 4 + packages/services/src/ratings.ts | 29 + packages/services/test/ratings.test.ts | 38 + 18 files changed, 1536 insertions(+), 205 deletions(-) create mode 100644 apps/miniapp-client/src/redesign.css create mode 100644 packages/services/src/ratings.ts create mode 100644 packages/services/test/ratings.test.ts diff --git a/apps/api/src/routers/client.ts b/apps/api/src/routers/client.ts index 96d6a51..f71f130 100644 --- a/apps/api/src/routers/client.ts +++ b/apps/api/src/routers/client.ts @@ -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)), }), diff --git a/apps/miniapp-client/src/App.tsx b/apps/miniapp-client/src/App.tsx index 3aeca77..eff48a0 100644 --- a/apps/miniapp-client/src/App.tsx +++ b/apps/miniapp-client/src/App.tsx @@ -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 = ; else if (path === '/chat') screen = ; else screen = segment === 'MOVING' ? : ; - 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[0]['name'], label: string) => ( @@ -109,11 +109,11 @@ function BottomNav({ path }: { path: string }) { return ( ); diff --git a/apps/miniapp-client/src/lib.tsx b/apps/miniapp-client/src/lib.tsx index 0d91c7e..8d784d8 100644 --- a/apps/miniapp-client/src/lib.tsx +++ b/apps/miniapp-client/src/lib.tsx @@ -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) => 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 ( - -
-
- {t(`services.${o.serviceCode}.name`)} · {summary(o.serviceCode, o.params)} -
-
{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
-
- {t(`status.${o.flow}.${o.status}`)} +
+ + + + + {t(`services.${o.serviceCode}.name`)} + {summary(o.serviceCode, o.params)} + {dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + {progress !== null && ( + + + {t(`status.${o.flow}.${o.status}`)} + {o.rating ? : o.done ? {t('client.rate.prompt')} : null} + ); } @@ -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 ( +
    + {items.map((s, i) => ( +
  1. + + + {s.label} + {s.meta && {s.meta}} + +
  2. + ))} +
+ ); +} + +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 ( +
+ {[1, 2, 3, 4, 5].map((n) => { + const svg = ( + + ); + return onChange ? ( + + ) : ( + + {svg} + + ); + })} +
+ ); +} + +/** Клубная карта-«билет» в стиле карт на сайте */ +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 = ( + <> + + Barev Club + {LEVEL_NAME[level]} + + {name && {name}} + {code && {code}} + + {until && {until}} + + {action && {action}} + + ); + return href ? ( + + {body} + + ) : ( +
+ {body} +
+ ); +} + +export function QrGlyph({ size = 28 }: { size?: number }) { + return ( + + ); +} diff --git a/apps/miniapp-client/src/main.tsx b/apps/miniapp-client/src/main.tsx index beb587c..ecb16a7 100644 --- a/apps/miniapp-client/src/main.tsx +++ b/apps/miniapp-client/src/main.tsx @@ -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'; diff --git a/apps/miniapp-client/src/redesign.css b/apps/miniapp-client/src/redesign.css new file mode 100644 index 0000000..981b8b5 --- /dev/null +++ b/apps/miniapp-client/src/redesign.css @@ -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); +} diff --git a/apps/miniapp-client/src/screens/Home.tsx b/apps/miniapp-client/src/screens/Home.tsx index 286409a..56abc9d 100644 --- a/apps/miniapp-client/src/screens/Home.tsx +++ b/apps/miniapp-client/src/screens/Home.tsx @@ -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 ( -
-
-
-
{t('client.home.hello')}
-

{me.data?.firstName ?? t('client.home.guest')}

+
+
+ - -
+

+ {t('client.home.hello')} + {me.data?.firstName ?? t('client.home.guest')} +

+ + + {t('client.home.cardShow')} + + } + /> + - {plus && me.data?.membership.endsAt && ( - -
- -
- {t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })} -
+ {orders.isPending ? ( + + ) : now ? ( +
+
+

+

+ + {t('client.home.allOrders')} +
- + + + + + + + {t(`services.${now.serviceCode}.name`)} + {summary(now.serviceCode, now.params)} + + + + + + {dt(now.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} + + + + {moreActive > 0 && ( + + {t('client.home.moreActive', { count: moreActive })} + + + )} +
+ ) : ( +
+

{t('client.home.noOrders')}

+ + + {t('services.cleaning.name')} + +
)} - {!plus && ( + {toRate && ( + + + {t('client.rate.prompt')} + + {t(`services.${toRate.serviceCode}.name`)} · {dt(toRate.scheduledAt, { day: 'numeric', month: 'short' })} + + + + + )} + +
+
+

{t('client.home.orderTitle')}

+ + {t('client.nav.services')} + +
+ +
+ + {level === 'BAREV' && ( {t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })} {t('client.home.upsellText')} )} - -
-

{t('client.home.myOrders')}

- - {t('client.home.allOrders')} - -
- {orders.isPending ? ( - - ) : orders.data && orders.data.active.length > 0 ? ( -
- {orders.data.active.slice(0, 4).map((o) => ( - - ))} -
- ) : ( -
-

- {t('client.home.noOrders')} -

-
- )} -
- -
- navigate('/order/cleaning')}> - - {t('services.cleaning.name')} - - {lastDone && ( - navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}> - - {t('client.home.repeatLast')} - - )} - {!me.data?.agencyName && ( - navigate('/profile?code=1')}> - - {t('client.home.enterCode')} - - )} -
- - + + + {t('client.home.chatTitle')} + {t('client.home.chatText')} + + + + {t('client.nav.chat')} + +
); } diff --git a/apps/miniapp-client/src/screens/OrderDetail.tsx b/apps/miniapp-client/src/screens/OrderDetail.tsx index 20c0cc8..d296af2 100644 --- a/apps/miniapp-client/src/screens/OrderDetail.tsx +++ b/apps/miniapp-client/src/screens/OrderDetail.tsx @@ -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 }) { ); 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 (
- +
+
+ + #{o.number} +
+ + {t(`status.${o.flow}.${o.status}`)} + {arrived && o.status === 'EN_ROUTE' ? ` · ${t('client.detail.arrived')}` : ''} + +

{t(`services.${o.serviceCode}.name`)}

+
+ {summary(o.serviceCode, o.params)} + {extras.length > 0 && ` + ${extras.map((e) => e.label).join(', ')}`} +
+
- -
-
-

{t(`services.${o.serviceCode}.name`)}

- {t(`status.${o.flow}.${o.status}`)} -
-
{summary(o.serviceCode, o.params)}
- {paramsExtras(t, o.params).length > 0 &&
+ {paramsExtras(t, o.params).map((e) => e.label).join(', ')}
} -
+
+
+
- {dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} -
- {o.addressText && ( -
+ {t('client.detail.when')} + +
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
+
+ {o.addressText && ( +
+
+ {t('client.detail.where')} +
+
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''} {o.addressText} -
- )} - {o.providerName && ( -
- - {t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName} - {arrived && o.status === 'EN_ROUTE' && {t('client.detail.arrived')}} -
- )} + +
+ )} +
+
+ + {t(`client.detail.providerLabel.${o.flow}`)} +
+
{o.providerName ?? {t('client.detail.whoPending')}}
- + - -

- {t('client.detail.progress')} -

- -
+ {o.done && } + +
+

{t('client.detail.progress')}

+ +
{o.photos.length > 0 && } - +
{t('client.order.price')} {amd(o.basePrice)} @@ -123,16 +126,31 @@ export function OrderDetail({ id }: { id: string }) { {t(`payment.${o.paymentStatus}`)} {o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
- +
{o.note && ( - +
{t('client.order.note')}
{o.note}
- +
)}
+ {!o.active && o.status !== 'CANCELLED' && ( + + )} + {o.status === 'CANCELLED' && ( + + )} + {o.active && preview.data?.canReschedule && ( - )} - {o.status === 'CANCELLED' && ( - - )}
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(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 ( +
+
+ {t('client.rate.yours')} + +
+ + {rating.comment &&

{rating.comment}

} +
+ ); + + return ( +
+

{t('client.rate.title')}

+

{t('client.rate.text')}

+ + {score > 0 && ( + <> +