Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#FBF6EE" />
<title>Barev Club</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400;1,500&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@barev/miniapp-client",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174 --strictPort",
"build": "vite build",
"preview": "vite preview --port 5174",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@barev/core": "workspace:*",
"@barev/i18n": "workspace:*",
"@barev/ui": "workspace:*",
"@barev/web": "workspace:*",
"@tanstack/react-query": "^5.104.0",
"qrcode": "^1.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@barev/api": "workspace:*",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.0",
"vite": "^8.0.0"
}
}
+149
View File
@@ -0,0 +1,149 @@
import { useQuery } from '@tanstack/react-query';
import { Center, Icon, Spinner } from '@barev/ui';
import { I18nProvider, match, openTelegram, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
import type { ReactNode } from 'react';
import { Consent } from './screens/Consent.tsx';
import { Docs } from './screens/Docs.tsx';
import { Move } from './screens/Move.tsx';
import { Segment } from './screens/Segment.tsx';
import { ServiceRequest } from './screens/ServiceRequest.tsx';
import { Home } from './screens/Home.tsx';
import { Membership } from './screens/Membership.tsx';
import { OrderDetail } from './screens/OrderDetail.tsx';
import { OrderForm } from './screens/OrderForm.tsx';
import { Orders } from './screens/Orders.tsx';
import { Perks } from './screens/Perks.tsx';
import { Profile } from './screens/Profile.tsx';
import { Services } from './screens/Services.tsx';
const ROOTS = ['/', '/services', '/perks', '/profile', '/docs'];
export function App({ hasInitData }: { hasInitData: boolean }) {
const trpc = useTRPC();
const me = useQuery({ ...trpc.client.me.queryOptions(), enabled: hasInitData });
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
return (
<I18nProvider initial={locale}>
{!hasInitData || me.error?.data?.httpStatus === 401 ? (
<OpenInTelegram />
) : me.isPending ? (
<Center>
<Spinner />
</Center>
) : me.error ? (
<LoadError />
) : !me.data.consented ? (
<Consent />
) : !me.data.segment ? (
<Segment />
) : (
<Routes segment={me.data.segment} conciergeUrl={me.data.conciergeUrl} />
)}
</I18nProvider>
);
}
/**
* Два сценария: MOVING — план переезда, документы, консьерж; LIVING — услуги со скидками, партнёры, заказы.
* Остальные экраны (услуги, подписка, заказы) доступны из обоих.
*/
function Routes({ segment, conciergeUrl }: { segment: 'MOVING' | 'LIVING'; conciergeUrl: string }) {
const { path } = useLocation();
useTelegramBack(path, ROOTS);
let screen: ReactNode;
let m: Record<string, string> | null;
if (path === '/') screen = segment === 'MOVING' ? <Move /> : <Home />;
else if (path === '/docs') screen = <Docs />;
else if ((m = match('/request/:code', path))) screen = <ServiceRequest code={m.code!} />;
else if (path === '/services') screen = <Services />;
else if ((m = match('/order/:code', path))) screen = <OrderForm code={m.code!} />;
else if (path === '/orders') screen = <Orders />;
else if ((m = match('/orders/:id', path))) screen = <OrderDetail id={m.id!} />;
else if (path === '/perks') screen = <Perks />;
else if (path === '/profile') screen = <Profile />;
else if (path === '/membership') screen = <Membership />;
else screen = segment === 'MOVING' ? <Move /> : <Home />;
const withNav = ROOTS.includes(path) || path === '/orders';
return (
<>
{screen}
{withNav && (segment === 'MOVING' ? <MoveNav path={path} conciergeUrl={conciergeUrl} /> : <BottomNav path={path} />)}
</>
);
}
/** Меню переезда: План / Документы / Консьерж (чат с ботом) / Услуги / Профиль */
function MoveNav({ path, conciergeUrl }: { path: string; conciergeUrl: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
<Icon name={icon} size={22} />
{label}
</a>
);
return (
<nav className="bc-bottomnav" aria-label={t('client.nav.label')}>
{item('/', 'globe', t('client.nav.plan'))}
{item('/docs', 'docs', t('client.nav.docs'))}
<button type="button" className="bc-bottomnav__plus" onClick={() => openTelegram(conciergeUrl)} aria-label={t('client.concierge.title')}>
<Icon name="chat" size={24} strokeWidth={2.4} />
</button>
{item('/services', 'grid', t('client.nav.services'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
);
}
/** ORD-1: Главная / Услуги / + / Скидки / Профиль */
function BottomNav({ path }: { path: string }) {
const { t } = useI18n();
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
<Icon name={icon} size={22} />
{label}
</a>
);
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} />
</a>
{item('/perks', 'gift', t('client.nav.perks'))}
{item('/profile', 'user', t('client.nav.profile'))}
</nav>
);
}
function OpenInTelegram() {
const { t } = useI18n();
return (
<div className="app app--plain">
<Center>
<div className="bc-stack" style={{ textAlign: 'center', alignItems: 'center' }}>
<Icon name="logo" size={48} style={{ color: 'var(--bc-plum)' }} />
<h1 className="bc-h2">{t('client.openInTelegram.title')}</h1>
<p className="bc-muted">{t('client.openInTelegram.text')}</p>
</div>
</Center>
</div>
);
}
function LoadError() {
const { t } = useI18n();
return (
<div className="app app--plain">
<Center>
<div className="bc-stack" style={{ textAlign: 'center', alignItems: 'center' }}>
<p>{t('errors.generic')}</p>
<button className="bc-btn bc-btn--dark" onClick={() => window.location.reload()}>
{t('common.retry')}
</button>
</div>
</Center>
</div>
);
}
+781
View File
@@ -0,0 +1,781 @@
.app {
max-width: 560px;
margin: 0 auto;
padding: 16px 16px calc(100px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 12px;
}
.app--plain {
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.tiles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.tile {
min-height: 132px;
padding: 16px;
border-radius: 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
text-decoration: none;
gap: 10px;
}
.tile__title {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 20px;
line-height: 1.05;
}
.order-row {
padding: 12px 14px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
min-height: 56px;
}
.order-row__main {
flex: 1;
min-width: 0;
}
.order-row__title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
margin: 0 -16px;
padding: 0 16px;
}
.quick::-webkit-scrollbar {
display: none;
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.option {
text-align: left;
padding: 14px 16px;
min-height: 72px;
border-radius: 22px;
border: 2px solid transparent;
background: var(--bc-surface);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
}
.option[aria-pressed='true'] {
border-color: var(--bc-ink);
}
.days {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
margin: 0 -16px;
padding: 2px 16px;
}
.days::-webkit-scrollbar {
display: none;
}
.day {
flex: none;
width: 58px;
height: 68px;
border-radius: 20px;
border: 2px solid transparent;
background: var(--bc-surface);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
gap: 2px;
}
.day[aria-pressed='true'] {
background: var(--bc-dark);
color: #fff;
}
.day__num {
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 20px;
line-height: 1;
}
.slots {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.slot {
min-height: var(--bc-tap);
border-radius: var(--bc-pill);
border: 2px solid transparent;
background: var(--bc-surface);
font-weight: 600;
cursor: pointer;
}
.slot[aria-pressed='true'] {
background: var(--bc-dark);
color: #fff;
}
.price-lines {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 15px;
}
.price-lines .total {
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 22px;
}
.sticky-cta {
position: sticky;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 10;
}
.page-head {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
}
.back {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: var(--bc-surface);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.club-card {
border-radius: 32px;
padding: 22px;
min-height: 190px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.kv {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 14px;
font-size: 14px;
}
.kv dt {
color: var(--bc-ink-2);
}
.kv dd {
margin: 0;
font-weight: 500;
}
.photos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.photos img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 16px;
}
.extra-row {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 6px 0;
border-bottom: 1px solid var(--bc-line);
}
.extra-row:last-child {
border-bottom: none;
}
.extra-toggle {
min-width: 96px;
height: var(--bc-tap);
padding: 0 16px;
border-radius: var(--bc-pill);
border: 2px solid var(--bc-ink);
background: transparent;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.extra-toggle[aria-pressed='true'] {
background: var(--bc-ok);
border-color: var(--bc-ok);
color: #fff;
}
/* ═════════ выбор сценария ═════════ */
.seg {
min-height: 100dvh;
justify-content: center;
background: var(--bc-plum);
color: var(--bc-bg);
max-width: none;
}
.seg > * {
width: 100%;
max-width: 528px;
margin-left: auto;
margin-right: auto;
}
.seg__word {
font-family: var(--bc-font-head);
font-weight: 800;
font-style: italic;
font-size: clamp(88px, 28vw, 150px);
line-height: 0.85;
letter-spacing: -0.05em;
color: var(--bc-pink);
}
.seg__title {
margin: 18px 0 0;
font-family: var(--bc-font-head);
font-weight: 800;
font-size: 30px;
line-height: 1.05;
letter-spacing: -0.02em;
}
.seg__sub {
margin: 0 0 10px;
color: #e9c9d5;
}
.seg__choices {
display: flex;
flex-direction: column;
gap: 10px;
}
.seg__choice {
display: flex;
gap: 14px;
align-items: flex-start;
width: 100%;
padding: 18px;
border: 0;
border-radius: 24px;
background: var(--bc-pink);
color: var(--bc-plum);
font: inherit;
text-align: left;
cursor: pointer;
}
.seg__choice + .seg__choice {
background: var(--bc-perks);
}
.seg__choice > span {
display: flex;
flex-direction: column;
gap: 4px;
}
.seg__choice b {
font-family: var(--bc-font-head);
font-size: 20px;
}
.seg__choice svg {
flex: none;
margin-top: 2px;
}
/* ═════════ план переезда ═════════ */
.move-hero {
display: flex;
flex-direction: column;
gap: 6px;
padding: 22px 20px 20px;
border-radius: 32px;
background: var(--bc-plum);
color: var(--bc-bg);
}
.move-hero__label {
font-size: 14px;
font-weight: 600;
color: var(--bc-pink);
}
.move-hero__when {
font-family: var(--bc-font-head);
font-weight: 800;
font-style: italic;
font-size: 34px;
line-height: 1;
letter-spacing: -0.03em;
}
.move-hero__days {
font-size: 15px;
color: #e9c9d5;
}
.move-hero__bar {
height: 8px;
margin-top: 12px;
border-radius: 999px;
background: rgba(246, 195, 211, 0.22);
overflow: hidden;
}
.move-hero__bar span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--bc-home);
transition: width 0.4s ease;
}
.move-hero__progress {
font-size: 14px;
color: #e9c9d5;
}
.move-steps {
position: relative;
margin: 0;
padding: 0;
list-style: none;
}
/* ось как в таймлайне на сайте */
.move-steps::before {
content: '';
position: absolute;
left: 10px;
top: 14px;
bottom: 14px;
width: 2px;
background: var(--bc-line);
}
.move-step {
position: relative;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px 0 12px;
}
.move-step__dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-top: 1px;
border-radius: 50%;
border: 2px solid var(--bc-plum);
background: var(--bc-surface);
color: var(--bc-home-ink);
}
.move-step[data-status='IN_PROGRESS'] .move-step__dot {
border-color: var(--bc-accent);
background: var(--bc-accent);
box-shadow: 0 0 0 5px rgba(184, 35, 90, 0.15);
}
.move-step[data-status='DONE'] .move-step__dot {
border-color: var(--bc-home);
background: var(--bc-home);
}
.move-step__title {
font-weight: 600;
line-height: 1.3;
}
.move-step[data-status='DONE'] .move-step__title {
color: var(--bc-ink-2);
}
.move-step__note {
margin-top: 4px;
padding: 8px 10px;
border-radius: 12px;
background: var(--bc-bg);
font-size: 13px;
line-height: 1.4;
}
.move-step__status {
padding: 4px 10px;
border-radius: 999px;
background: var(--bc-bg);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.move-step[data-status='IN_PROGRESS'] .move-step__status {
background: var(--bc-pink);
color: var(--bc-plum);
}
.move-step[data-status='DONE'] .move-step__status {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.move-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.move-action {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 120px;
padding: 16px;
border: 0;
border-radius: 28px;
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.move-action b {
margin-top: auto;
font-family: var(--bc-font-head);
font-size: 18px;
}
/* ═════════ конструктор переезда ═════════ */
.mf-choices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mf-choice,
.mf-package {
padding: 14px;
border: 2px solid var(--bc-line);
border-radius: 20px;
background: var(--bc-surface);
font: inherit;
font-weight: 600;
text-align: left;
color: var(--bc-ink);
cursor: pointer;
}
.mf-package {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 400;
}
.mf-package b {
font-size: 17px;
}
.mf-choice[aria-pressed='true'],
.mf-package[aria-pressed='true'] {
border-color: var(--bc-plum);
background: var(--bc-pink);
}
.mf-price {
font-family: var(--bc-font-head);
font-weight: 700;
color: var(--bc-plum);
white-space: nowrap;
}
.mf-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--bc-line);
font-weight: 600;
}
.mf-seg {
display: inline-flex;
padding: 3px;
border-radius: 999px;
background: var(--bc-bg);
}
.mf-seg button {
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
cursor: pointer;
}
.mf-seg button[aria-checked='true'] {
background: var(--bc-plum);
color: #fff;
}
.mf-budget {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* ═════════ документы ═════════ */
.docs {
margin: 0;
padding: 0;
list-style: none;
}
.doc {
display: flex;
gap: 12px;
align-items: flex-start;
width: 100%;
padding: 12px;
border: 0;
border-radius: 18px;
background: transparent;
font: inherit;
text-align: left;
color: var(--bc-ink);
cursor: pointer;
}
.doc > span:last-child {
display: flex;
flex-direction: column;
gap: 2px;
}
.doc:disabled {
cursor: default;
opacity: 0.6;
}
.doc__box {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 2px solid var(--bc-plum);
color: #fff;
}
.doc[aria-pressed='true'] .doc__box {
background: var(--bc-plum);
}
.doc[aria-pressed='true'] b {
text-decoration: line-through;
color: var(--bc-ink-3);
}
/* ═════════ подписка: клубные карты ═════════ */
.club {
display: flex;
flex-direction: column;
aspect-ratio: 1.586;
padding: 20px 22px;
border-radius: 22px;
border: 1.5px solid var(--bc-line);
background: var(--bc-surface);
color: var(--bc-plum);
}
.club[data-plan='plus'] {
border-color: var(--bc-pink);
background: var(--bc-pink);
}
.club[data-plan='elite'] {
border-color: var(--bc-perks);
background: var(--bc-perks);
}
.club__top {
display: flex;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
}
.club__chip {
width: 42px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, currentColor 14%, transparent);
border: 1.5px solid color-mix(in srgb, currentColor 28%, transparent);
}
.club__name {
margin-top: auto;
font-family: var(--bc-font-head);
font-weight: 800;
font-style: italic;
font-size: 38px;
line-height: 1;
letter-spacing: -0.03em;
}
.club__price {
margin-top: 6px;
font-family: var(--bc-font-head);
font-weight: 600;
font-size: 18px;
}
.club-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0;
padding: 0 4px;
list-style: none;
}
.club-list li {
display: flex;
gap: 10px;
align-items: flex-start;
line-height: 1.4;
}
.club-list svg {
flex: none;
margin-top: 1px;
color: var(--bc-accent);
}
.club-state {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border-radius: 20px;
background: var(--bc-surface);
text-align: center;
}
.club-state span {
font-size: 14px;
color: var(--bc-ink-2);
}
/* ═════════ услуги, партнёры ═════════ */
.req-done {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--bc-home);
color: var(--bc-home-ink);
}
.upsell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
border-radius: 24px;
background: var(--bc-perks);
color: var(--bc-plum);
text-decoration: none;
}
.upsell b {
font-family: var(--bc-font-head);
font-size: 18px;
}
.partners {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.partner {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
border-radius: 24px;
background: var(--bc-surface);
}
.partner__cat {
font-size: 12px;
font-weight: 600;
color: var(--bc-ink-3);
}
.partner__name {
font-family: var(--bc-font-head);
font-size: 18px;
}
.partner__offer {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
background: var(--bc-pink);
color: var(--bc-plum);
font-size: 14px;
font-weight: 600;
}
/* клубная карта с живым QR */
.card-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 20px;
border-radius: 32px;
}
.card-qr__top {
display: flex;
justify-content: space-between;
align-items: baseline;
width: 100%;
font-size: 13px;
font-weight: 600;
}
.card-qr__top b {
font-family: var(--bc-font-head);
font-size: 18px;
}
.card-qr__code {
padding: 12px;
border-radius: 24px;
background: #fff;
line-height: 0;
}
.card-qr__meta {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 13px;
opacity: 0.85;
}
.card-qr__meta .bc-num {
letter-spacing: 3px;
}
.offers {
display: flex;
flex-direction: column;
gap: 6px;
margin: 6px 0 0;
padding: 0;
list-style: none;
}
.offer {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
border-radius: 14px;
background: var(--bc-pink);
color: var(--bc-plum);
font-weight: 600;
font-size: 14px;
}
.offer small {
font-weight: 500;
font-size: 12px;
white-space: nowrap;
opacity: 0.8;
}
.offer[data-eligible='false'],
.offer[data-used='true'] {
background: var(--bc-bg);
color: var(--bc-ink-3);
}
+95
View File
@@ -0,0 +1,95 @@
import { CATEGORY_TONE, Icon, type CategoryCode } from '@barev/ui';
import { paramsSummary, useI18n } from '@barev/web';
import type { ReactNode } from 'react';
export const toneClass = (c: string) => `bc-tone-${CATEGORY_TONE[c as CategoryCode] ?? 'white'}`;
export function PageHead({ title, back = true, right }: { title: ReactNode; back?: boolean; right?: ReactNode }) {
const { t } = useI18n();
return (
<div className="page-head">
{back && (
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
<Icon name="back" />
</button>
)}
<h1 className="bc-h2" style={{ flex: 1 }}>
{title}
</h1>
{right}
</div>
);
}
export interface OrderLike {
id: string;
number: number;
serviceCode: string;
category: string;
flow: string;
status: string;
scheduledAt: Date;
params: Record<string, unknown>;
}
/** Подпись параметров: «Генеральная · 2 комнаты» */
export function useParamsSummary() {
const { t } = useI18n();
return (serviceCode: string, params: Record<string, unknown>) => paramsSummary(t, serviceCode, params);
}
/** Строка заявки как в превью «Мои заявки» макета */
export function OrderRow({ o }: { o: OrderLike }) {
const { t, dt } = useI18n();
const summary = useParamsSummary();
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>
);
}
export type Level = 'BAREV' | 'BAREV_PLUS' | 'ELITE';
export const LEVEL_NAME: Record<Level, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
/** Цвет плашки уровня: как карты на сайте — Barev+ розовая, Elite жёлтая */
export const LEVEL_TONE: Record<Level, { bg: string; ink: string }> = {
BAREV: { bg: 'var(--bc-surface)', ink: 'var(--bc-ink)' },
BAREV_PLUS: { bg: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
ELITE: { bg: 'var(--bc-perks)', ink: 'var(--bc-plum)' },
};
/** Скидка клиента на направление по его уровню; у Elite без своей скидки — скидка Barev+ */
export function levelDiscount(level: Level, c: { barevPlusDiscountPct: number; eliteDiscountPct: number }) {
if (level === 'ELITE') return Math.max(c.eliteDiscountPct, c.barevPlusDiscountPct);
return level === 'BAREV_PLUS' ? c.barevPlusDiscountPct : 0;
}
export function LevelBadge({ level }: { level: Level }) {
const tone = LEVEL_TONE[level];
return (
<a href="#/membership" style={{ textDecoration: 'none' }}>
<span className="bc-badge" style={{ background: tone.bg, color: tone.ink, padding: '8px 14px', fontSize: 13 }}>
{LEVEL_NAME[level]}
</span>
</a>
);
}
export type RelocationNames = Record<string, { name: Record<string, string>; hint: Record<string, string> | null }>;
/** Название шага или документа: своё у клиента (title) или из общего списка на языке клиента */
export function itemName(names: RelocationNames | undefined, kind: 'STEP' | 'DOC', item: { code: string; title: string | null }, locale: string) {
if (item.title) return item.title;
const n = names?.[`${kind}:${item.code}`]?.name;
return n?.[locale] || n?.ru || item.code;
}
export function itemHint(names: RelocationNames | undefined, kind: 'STEP' | 'DOC', item: { code: string; title: string | null }, locale: string) {
if (item.title) return null;
const h = names?.[`${kind}:${item.code}`]?.hint;
return h?.[locale] || h?.ru || null;
}
+19
View File
@@ -0,0 +1,19 @@
import '@barev/ui/styles.css';
import './app.css';
import { ApiProvider, getInitData, initTelegram, ToastProvider } from '@barev/web';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';
initTelegram();
const initData = getInitData();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ApiProvider headers={() => ({ authorization: `tma ${initData}` })}>
<ToastProvider>
<App hasInitData={!!initData} />
</ToastProvider>
</ApiProvider>
</StrictMode>,
);
+17
View File
@@ -0,0 +1,17 @@
import QRCode from 'qrcode';
import { useEffect, useState } from 'react';
/** QR как SVG — чёткий на любом экране, без canvas */
export function Qr({ value, size = 200, label }: { value: string; size?: number; label: string }) {
const [svg, setSvg] = useState('');
useEffect(() => {
let alive = true;
void QRCode.toString(value, { type: 'svg', margin: 1, errorCorrectionLevel: 'M', color: { dark: '#4A1028', light: '#ffffff' } }).then((s) => {
if (alive) setSvg(s);
});
return () => {
alive = false;
};
}, [value]);
return <div role="img" aria-label={label} style={{ width: size, height: size }} dangerouslySetInnerHTML={{ __html: svg }} />;
}
@@ -0,0 +1,43 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Flower } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { LanguagePicker } from './Profile.tsx';
const LANDING = import.meta.env.VITE_LANDING_URL ?? 'http://localhost:3000';
/** ONB-3: без согласия на обработку ПДн дальше не пускаем */
export function Consent() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const consent = useMutation(
trpc.client.consent.mutationOptions({
onSuccess: (gift) => {
if (gift) toast(t('client.consent.giftToast'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<div className="app app--plain">
<Card tone="pink" style={{ padding: 28 }}>
<div className="bc-stack" style={{ gap: 18 }}>
<Flower size={64} rotate={-10}>
B
</Flower>
<h1 className="bc-h2">{t('client.consent.title')}</h1>
<p style={{ margin: 0 }}>{t('client.consent.text')}</p>
<a href={`${LANDING}/${locale}/privacy`} target="_blank" rel="noreferrer" style={{ fontWeight: 600 }}>
{t('client.consent.policyLink')}
</a>
<Button block onClick={() => consent.mutate()} disabled={consent.isPending}>
{t('client.consent.accept')}
</Button>
</div>
</Card>
<LanguagePicker />
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { errorText, haptic, openTelegram, useI18n, useToast, useTRPC } from '@barev/web';
import { itemHint, itemName, PageHead } from '../lib.tsx';
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
export function Docs() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const me = useQuery(trpc.client.me.queryOptions());
const plan = useQuery(trpc.client.relocation.get.queryOptions());
const toggle = useMutation(
trpc.client.relocation.toggleDoc.mutationOptions({
onMutate: () => haptic.tap(),
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.client.relocation.get.queryKey() }),
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!plan.data)
return (
<Center>
<Spinner />
</Center>
);
const lead = plan.data.lead;
return (
<main className="app">
<PageHead title={t('client.docs.title')} back={false} />
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.docs.sub')}
</p>
{!lead && (
<Card tone="perks" tight>
<a href="#/" className="bc-small" style={{ fontWeight: 600, color: 'inherit' }}>
{t('client.docs.needPlan')}
</a>
</Card>
)}
<Card style={{ padding: 8 }}>
<ul className="docs">
{(lead?.docs ?? []).map((d) => {
const hint = itemHint(plan.data.names, 'DOC', d, locale);
return (
<li key={d.id}>
<button className="doc" aria-pressed={d.checked} disabled={toggle.isPending} onClick={() => toggle.mutate({ docId: d.id, checked: !d.checked })}>
<span className="doc__box" aria-hidden="true">
{d.checked && <Icon name="check" size={16} strokeWidth={3} />}
</span>
<span>
<b>{itemName(plan.data.names, 'DOC', d, locale)}</b>
{hint && <span className="bc-small bc-muted">{hint}</span>}
</span>
</button>
</li>
);
})}
</ul>
</Card>
{me.data && (
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => openTelegram(me.data.conciergeUrl)}>
<Icon name="chat" size={18} />
{t('client.docs.ask')}
</button>
)}
</main>
);
}
+103
View File
@@ -0,0 +1,103 @@
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';
/** Главная для тех, кто уже живёт в Армении: заказы, быстрые кнопки, направления со скидкой по уровню */
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 level = (me.data?.membership.level ?? 'BAREV') as Level;
const plus = level !== 'BAREV';
const lastDone = orders.data?.history.find((o) => o.status !== 'CANCELLED');
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>
</div>
<LevelBadge level={level} />
</header>
{plus && me.data?.membership.endsAt && (
<Card tone="perks" tight>
<div className="bc-row">
<Icon name="gift" />
<div className="bc-small" style={{ fontWeight: 600 }}>
{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })}
</div>
</div>
</Card>
)}
{!plus && (
<a href="#/membership" className="upsell">
<b>{t('client.home.upsellTitle', { pct: me.data?.plans.eliteMaxPct ?? 0 })}</b>
<span className="bc-small">{t('client.home.upsellText')}</span>
</a>
)}
<Card style={{ padding: 16 }}>
<div className="bc-between" style={{ padding: '0 4px 8px' }}>
<h2 className="bc-h3">{t('client.home.myOrders')}</h2>
<a href="#/orders" className="bc-small bc-muted">
{t('client.home.allOrders')}
</a>
</div>
{orders.isPending ? (
<Spinner />
) : orders.data && orders.data.active.length > 0 ? (
<div className="bc-stack" style={{ gap: 8 }}>
{orders.data.active.slice(0, 4).map((o) => (
<OrderRow key={o.id} o={o} />
))}
</div>
) : (
<div className="bc-stack" style={{ padding: '4px 4px 6px' }}>
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.home.noOrders')}
</p>
</div>
)}
</Card>
<div className="quick">
<Chip onClick={() => navigate('/order/cleaning')}>
<Icon name="home" size={18} />
{t('services.cleaning.name')}
</Chip>
{lastDone && (
<Chip onClick={() => navigate(`/order/${lastDone.serviceCode}?repeat=${lastDone.id}`)}>
<Icon name="repeat" size={18} />
{t('client.home.repeatLast')}
</Chip>
)}
{!me.data?.agencyName && (
<Chip onClick={() => navigate('/profile?code=1')}>
<Icon name="gift" size={18} />
{t('client.home.enterCode')}
</Chip>
)}
</div>
<div className="tiles">
{(catalog.data?.categories ?? []).map((c) => (
<a key={c.code} href={c.code === 'PERKS' ? '#/perks' : `#/services?c=${c.code}`} className={`tile ${toneClass(c.code)}`}>
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
<div className="tile__title">{t(`categories.${c.code}.name`)}</div>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={24} />
</div>
<div className="bc-small">
{levelDiscount(level, c) > 0 ? t('client.services.yourDiscount', { pct: levelDiscount(level, c) }) : t(`categories.${c.code}.short`)}
</div>
</a>
))}
</div>
</main>
);
}
@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Icon, Segmented } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LEVEL_NAME, levelDiscount, PageHead, type Level } from '../lib.tsx';
const PLANS = ['BAREV', 'BAREV_PLUS', 'ELITE'] as const;
/**
* Тарифы клуба как карты на сайте: Barev, Barev+, Barev Elite.
* Онлайн-оплаты пока нет — «Оформить» отправляет заявку оператору, он включает подписку.
*/
export function Membership() {
const { t, amd, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const me = useQuery(trpc.client.me.queryOptions());
const catalog = useQuery(trpc.client.catalog.queryOptions());
const open = useQuery(trpc.client.requests.open.queryOptions());
const current = (me.data?.membership.level ?? 'BAREV') as Level;
const [plan, setPlan] = useState<Level>(current === 'BAREV' ? 'BAREV_PLUS' : current);
const request = useMutation(
trpc.client.requests.create.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.membership.requested'));
void qc.invalidateQueries({ queryKey: trpc.client.requests.open.queryKey() });
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const plans = me.data?.plans;
const price = plan === 'BAREV' ? t('landing.club.free') : t('plans.plus.price', { price: amd(plan === 'ELITE' ? (plans?.elitePrice ?? 0) : (plans?.plusPrice ?? 0)) });
const pending = open.data?.find((r) => r.kind === 'SUBSCRIPTION' && r.plan === plan);
const id = plan === 'BAREV' ? 'free' : plan === 'BAREV_PLUS' ? 'plus' : 'elite';
const pct = plan === 'ELITE' ? plans?.eliteMaxPct : plans?.plusMaxPct;
const discounts = (catalog.data?.categories ?? []).filter((c) => c.code !== 'PERKS' && levelDiscount(plan, c) > 0);
return (
<main className="app app--plain">
<PageHead title={t('client.membership.title')} />
<Segmented value={plan} onChange={setPlan} options={PLANS.map((p) => ({ value: p, label: LEVEL_NAME[p] }))} />
<div className="club" data-plan={id}>
<span className="club__top">
<span>Barev Club</span>
<span className="club__chip" aria-hidden="true" />
</span>
<span className="club__name">{LEVEL_NAME[plan]}</span>
<span className="club__price">{price}</span>
</div>
<ul className="club-list">
{[1, 2, 3].map((n) => (
<li key={n}>
<Icon name="check" size={18} strokeWidth={2.6} />
{t(`landing.club.${id}F${n}`, { pct: pct ?? 0 })}
</li>
))}
{discounts.map((c) => (
<li key={c.code}>
<Icon name="check" size={18} strokeWidth={2.6} />
{t('plans.plus.discount', { pct: levelDiscount(plan, c), category: t(`categories.${c.code}.name`) })}
</li>
))}
</ul>
{plan === current ? (
<div className="club-state">
<b>{t('client.membership.current')}</b>
{current !== 'BAREV' && me.data?.membership.endsAt && <span>{t('client.home.levelUntil', { plan: LEVEL_NAME[current], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long' }) })}</span>}
</div>
) : plan === 'BAREV' ? null : pending ? (
<div className="club-state">
<b>{t('client.membership.pendingTitle')}</b>
<span>{t('client.membership.pendingText')}</span>
</div>
) : (
<Button onClick={() => request.mutate({ kind: 'SUBSCRIPTION', plan: plan as 'BAREV_PLUS' | 'ELITE' })} disabled={request.isPending}>
{t('client.membership.request', { plan: LEVEL_NAME[plan] })}
</Button>
)}
{plan !== 'BAREV' && plan !== current && !pending && <p className="bc-small bc-muted" style={{ margin: 0, textAlign: 'center' }}>{t('client.membership.requestHint')}</p>}
</main>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query';
import { Card, Center, Icon, Spinner } from '@barev/ui';
import { openTelegram, useI18n, useTRPC } from '@barev/web';
import { itemName, LevelBadge, type Level } from '../lib.tsx';
import { MoveForm } from './MoveForm.tsx';
const DAY = 24 * 3600_000;
/**
* Главная для тех, кто переезжает: план по шагам со статусами от оператора.
* Плана ещё нет — конструктор пакета (тот же, что в квизе на сайте).
*/
export function Move() {
const { t, dt, locale } = useI18n();
const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions());
const plan = useQuery({ ...trpc.client.relocation.get.queryOptions(), refetchInterval: 30_000 });
if (!plan.data || !me.data)
return (
<Center>
<Spinner />
</Center>
);
const lead = plan.data.lead;
if (!lead || !lead.package) return <MoveForm />;
const steps = lead.steps.filter((s) => s.status !== 'SKIPPED');
const done = steps.filter((s) => s.status === 'DONE').length;
const docsDone = lead.docs.filter((d) => d.checked).length;
const days = lead.moveDate ? Math.ceil((new Date(lead.moveDate).getTime() - Date.now()) / DAY) : null;
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>
</div>
<LevelBadge level={me.data.membership.level as Level} />
</header>
<section className="move-hero">
<div className="move-hero__label">{t('client.move.title')}</div>
<div className="move-hero__when">
{lead.moveWhen === 'date' && lead.moveDate
? dt(lead.moveDate, { day: 'numeric', month: 'long', timeZone: 'UTC' })
: t(`landing.quiz.when_${lead.moveWhen ?? 'later'}`)}
</div>
{days !== null && days > 0 && <div className="move-hero__days">{t('client.move.daysLeft', { count: days })}</div>}
<div className="move-hero__bar" role="progressbar" aria-valuemin={0} aria-valuemax={steps.length} aria-valuenow={done} aria-label={t('client.move.progress', { done, total: steps.length })}>
<span style={{ width: `${steps.length ? (done / steps.length) * 100 : 0}%` }} />
</div>
<div className="move-hero__progress">{t('client.move.progress', { done, total: steps.length })}</div>
</section>
<Card style={{ padding: '18px 18px 8px' }}>
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
{t(`landing.quiz.package_${lead.package}`)}
</h2>
{steps.length === 0 ? (
<p className="bc-muted" style={{ margin: '0 0 12px' }}>
{t('client.move.noSteps')}
</p>
) : (
<ol className="move-steps">
{steps.map((s) => (
<li key={s.id} className="move-step" data-status={s.status}>
<span className="move-step__dot" aria-hidden="true">
{s.status === 'DONE' && <Icon name="check" size={13} strokeWidth={3} />}
</span>
<div className="move-step__body">
<div className="move-step__title">{itemName(plan.data.names, 'STEP', s, locale)}</div>
{s.note && <div className="move-step__note">{s.note}</div>}
</div>
<span className="move-step__status">{t(`client.move.status.${s.status}`)}</span>
</li>
))}
</ol>
)}
</Card>
<div className="move-actions">
<a href="#/docs" className="move-action bc-tone-docs">
<Icon name="docs" size={24} />
<b>{t('client.docs.title')}</b>
<span className="bc-small">{t('client.docs.progress', { done: docsDone, total: lead.docs.length })}</span>
</a>
<button className="move-action bc-tone-learn" onClick={() => openTelegram(me.data.conciergeUrl)}>
<Icon name="chat" size={24} />
<b>{t('client.concierge.title')}</b>
<span className="bc-small">{t('client.concierge.short')}</span>
</button>
</div>
<Card tight className="bc-stack" style={{ gap: 6 }}>
<div className="bc-small" style={{ fontWeight: 600 }}>
{t('client.move.afterTitle')}
</div>
<div className="bc-small bc-muted">{t('client.move.afterText')}</div>
</Card>
</main>
);
}
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
const WHEN = ['month', 'quarter', 'later', 'date'] as const;
const PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const;
const YES_NO = ['flat', 'registration', 'socialCard', 'bankCard', 'residence'] as const;
type When = (typeof WHEN)[number];
type Pkg = (typeof PACKAGES)[number];
/** Конструктор переезда в приложении: когда, какой пакет, что нужно. После отправки — план по шагам */
export function MoveForm() {
const { t, amd } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const me = useQuery(trpc.client.me.queryOptions());
const [when, setWhen] = useState<When | null>(null);
const [date, setDate] = useState('');
const [pkg, setPkg] = useState<Pkg | null>(null);
const [airport, setAirport] = useState<'none' | 'standard' | 'premium'>('standard');
const [stay, setStay] = useState<'none' | 'flat' | 'hotel'>('hotel');
const [budgetFrom, setBudgetFrom] = useState('');
const [budgetTo, setBudgetTo] = useState('');
const [yes, setYes] = useState<Record<(typeof YES_NO)[number], boolean>>({ flat: true, registration: true, socialCard: true, bankCard: false, residence: false });
const [comment, setComment] = useState('');
const [error, setError] = useState<string | null>(null);
const create = useMutation(
trpc.client.relocation.create.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.move.created'));
void qc.invalidateQueries();
window.scrollTo({ top: 0 });
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
function submit() {
if (!when || (when === 'date' && !date)) return setError(t('client.move.errWhen'));
if (!pkg) return setError(t('client.move.errPackage'));
const any = airport !== 'none' || stay !== 'none' || YES_NO.some((k) => yes[k]);
if (pkg === 'CUSTOM' && !any) return setError(t('client.move.errEmpty'));
setError(null);
const num = (v: string) => (v ? Number(v) : undefined);
create.mutate({
moveWhen: when,
moveDate: when === 'date' ? date : undefined,
package: pkg,
options: pkg === 'CUSTOM' ? { airport, stay, stayBudgetFrom: stay === 'none' ? undefined : num(budgetFrom), stayBudgetTo: stay === 'none' ? undefined : num(budgetTo), ...yes } : undefined,
comment: comment.trim() || undefined,
});
}
const seg = <V extends string>(value: V, options: readonly V[], label: (v: V) => string, onChange: (v: V) => void) => (
<div className="mf-seg" role="radiogroup">
{options.map((o) => (
<button key={o} type="button" role="radio" aria-checked={value === o} onClick={() => onChange(o)}>
{label(o)}
</button>
))}
</div>
);
return (
<main className="app">
<section className="move-hero">
<div className="move-hero__label">{me.data?.firstName ? t('client.move.formHello', { name: me.data.firstName }) : t('client.move.title')}</div>
<div className="move-hero__when">{t('client.move.formTitle')}</div>
<div className="move-hero__progress">{t('client.move.formSub')}</div>
</section>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.whenTitle')}</h2>
<div className="mf-choices">
{WHEN.map((w) => (
<button key={w} type="button" className="mf-choice" aria-pressed={when === w} onClick={() => setWhen(w)}>
{t(`landing.quiz.when_${w}`)}
</button>
))}
</div>
{when === 'date' && (
<Field label={t('landing.quiz.date')}>
<input className="bc-input" type="date" min={new Date().toISOString().slice(0, 10)} value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
)}
</Card>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.packageTitle')}</h2>
{PACKAGES.map((p) => (
<button key={p} type="button" className="mf-package" aria-pressed={pkg === p} onClick={() => setPkg(p)}>
<span className="bc-between" style={{ alignItems: 'baseline', gap: 10 }}>
<b>{t(`landing.quiz.package_${p}`)}</b>
{p === 'CONSULT' && me.data && <span className="mf-price">{amd(me.data.plans.consultation)}</span>}
{p === 'TURNKEY' && <span className="mf-price">{t('landing.quiz.package_TURNKEY_price')}</span>}
</span>
<span className="bc-small">{t(`landing.quiz.package_${p}_text`)}</span>
</button>
))}
</Card>
{pkg === 'CUSTOM' && (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.customTitle')}</h2>
<div className="mf-row">
<span>{t('landing.quiz.airport')}</span>
{seg(airport, ['none', 'standard', 'premium'] as const, (v) => t(`landing.quiz.airport_${v}`), setAirport)}
</div>
<div className="mf-row">
<span>{t('landing.quiz.stay')}</span>
{seg(stay, ['none', 'flat', 'hotel'] as const, (v) => t(`landing.quiz.stay_${v}`), setStay)}
</div>
{stay !== 'none' && (
<div className="mf-budget">
<Field label={`${t('landing.quiz.budget')} · ${t('landing.quiz.budgetFrom')}`}>
<input className="bc-input" inputMode="numeric" value={budgetFrom} placeholder="20 000" onChange={(e) => setBudgetFrom(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
<Field label={t('landing.quiz.budgetTo')}>
<input className="bc-input" inputMode="numeric" value={budgetTo} placeholder="40 000" onChange={(e) => setBudgetTo(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
</div>
)}
{YES_NO.map((k) => (
<div key={k} className="mf-row">
<span>{t(`landing.quiz.${k}`)}</span>
{seg(yes[k] ? 'yes' : 'no', ['no', 'yes'] as const, (v) => t(`landing.quiz.${v}`), (v) => setYes({ ...yes, [k]: v === 'yes' }))}
</div>
))}
</Card>
)}
<Card className="bc-stack">
<Field label={t('client.move.comment')}>
<textarea className="bc-input" style={{ minHeight: 80 }} maxLength={1000} value={comment} placeholder={t('client.move.commentHint')} onChange={(e) => setComment(e.target.value)} />
</Field>
{error && (
<p role="alert" style={{ margin: 0, color: 'var(--bc-danger)', fontWeight: 600 }}>
{error}
</p>
)}
<Button onClick={submit} disabled={create.isPending}>
{create.isPending ? t('landing.quiz.sending') : t('client.move.submit')}
</Button>
</Card>
</main>
);
}
@@ -0,0 +1,265 @@
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';
export function OrderDetail({ id }: { id: string }) {
const { t, amd, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const summary = useParamsSummary();
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);
const cancel = useMutation(
trpc.client.orders.cancel.mutationOptions({
onSuccess: (r) => {
haptic.ok();
toast(r.fee > 0 ? t('client.detail.cancelledWithFee', { fee: amd(r.fee) }) : t('client.detail.cancelled'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
if (!order.data)
return (
<Center>
<Spinner />
</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');
async function onCancel() {
const p = preview.data?.policy;
if (!p) return;
const text = p.kind === 'free' ? t('client.detail.confirmCancelFree') : p.kind === 'late' ? t('client.detail.confirmCancelLate', { fee: amd(p.fee), pct: p.feePct }) : '';
if (await confirmDialog(text)) cancel.mutate({ id });
}
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>
</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 }}>
<Icon name="clock" size={18} />
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</div>
{o.addressText && (
<div className="bc-row">
<Icon name="pin" size={18} />
{o.districtCode ? `${t(`districts.${o.districtCode}`)}, ` : ''}
{o.addressText}
</div>
)}
{o.providerName && (
<div className="bc-row">
<Icon name="user" size={18} />
{t(`client.detail.providerLabel.${o.flow}`)}: {o.providerName}
{arrived && o.status === 'EN_ROUTE' && <span className="bc-badge">{t('client.detail.arrived')}</span>}
</div>
)}
</div>
</Card>
<Card>
<h2 className="bc-h3" style={{ marginBottom: 12 }}>
{t('client.detail.progress')}
</h2>
<Steps items={steps} />
</Card>
{o.photos.length > 0 && <PhotoReport photos={o.photos} />}
<Card className="price-lines">
<div className="bc-between">
<span>{t('client.order.price')}</span>
<span>{amd(o.basePrice)}</span>
</div>
{o.discountAmount > 0 && (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.order.discount', { pct: o.discountPct })}</span>
<span>−{amd(o.discountAmount)}</span>
</div>
)}
<div className="bc-between total">
<span>{t('client.order.total')}</span>
<span>{amd(o.totalPrice)}</span>
</div>
{o.cancelFee > 0 && (
<div className="bc-between">
<span>{t('client.detail.cancelFee')}</span>
<span>{amd(o.cancelFee)}</span>
</div>
)}
<div className="bc-small bc-muted">
{t(`payment.${o.paymentStatus}`)}
{o.paymentMethod ? ` · ${o.paymentMethod.name[locale] || o.paymentMethod.name.ru}` : ''}
</div>
</Card>
{o.note && (
<Card tight>
<div className="bc-small bc-muted">{t('client.order.note')}</div>
<div>{o.note}</div>
</Card>
)}
<div className="bc-stack" style={{ gap: 8 }}>
{o.active && preview.data?.canReschedule && (
<Button variant="light" block onClick={() => setReschedule(true)}>
{t('client.detail.reschedule')}
</Button>
)}
{o.active && preview.data?.cancellable && preview.data.policy.kind !== 'operator_only' && (
<Button variant="danger" block onClick={onCancel} disabled={cancel.isPending}>
{t('client.detail.cancel')}
</Button>
)}
{o.active && preview.data && (!preview.data.cancellable || preview.data.policy.kind === 'operator_only' || !preview.data.canReschedule) && (
<p className="bc-small bc-muted" style={{ margin: 0, textAlign: 'center' }}>
{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} />
</main>
);
}
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>
<div className="photos">
{photos.map((p) => (
<Photo key={p.fileId} fileId={p.fileId} alt={t(`checklist.${p.code}`)} trpc={trpc} />
))}
</div>
</Card>
);
}
function Photo({ fileId, alt, trpc }: { fileId: string; alt: string; trpc: ReturnType<typeof useTRPC> }) {
const url = useQuery({ ...trpc.client.fileUrl.queryOptions({ fileId }), staleTime: 4 * 60_000 });
return url.data ? (
<a href={url.data.url} target="_blank" rel="noreferrer">
<img src={url.data.url} alt={alt} loading="lazy" />
</a>
) : (
<div style={{ aspectRatio: 1, borderRadius: 16, background: 'var(--bc-bg)' }} />
);
}
/** ORD-6: перенос — выбор нового слота */
function RescheduleSheet({ open, onClose, order }: { open: boolean; onClose: () => void; order: { id: string; serviceCode: string; params: Record<string, unknown> } }) {
const { t, dt } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const days = useMemo(() => nextDays(14), []);
const [day, setDay] = useState(days[1] ?? days[0]!);
const [slot, setSlot] = useState<Date | null>(null);
const slots = useQuery({
...trpc.client.slots.queryOptions({ serviceCode: order.serviceCode, params: order.params as Record<string, string | number | boolean>, dateKey: day, excludeOrderId: order.id }),
enabled: open,
});
const mutate = useMutation(
trpc.client.orders.reschedule.mutationOptions({
onSuccess: () => {
toast(t('client.detail.rescheduled'));
void qc.invalidateQueries();
onClose();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<Sheet open={open} onClose={onClose} label={t('client.detail.reschedule')}>
<div className="bc-stack">
<div className="bc-between">
<h2 className="bc-h2">{t('client.detail.reschedule')}</h2>
<button className="back" onClick={onClose} aria-label={t('common.close')}>
<Icon name="close" />
</button>
</div>
<div className="days">
{days.map((d) => (
<button
key={d}
type="button"
className="day"
aria-pressed={d === day}
onClick={() => {
setDay(d);
setSlot(null);
}}
>
<span className="bc-small">{dt(dateKeyToDate(d), { weekday: 'short' })}</span>
<span className="day__num">{dt(dateKeyToDate(d), { day: 'numeric' })}</span>
</button>
))}
</div>
{slots.isPending ? (
<Spinner />
) : slots.data?.length ? (
<div className="slots">
{slots.data.map((s) => (
<button key={s.start.toISOString()} type="button" className="slot" aria-pressed={slot?.getTime() === s.start.getTime()} onClick={() => setSlot(s.start)}>
{dt(s.start, { hour: '2-digit', minute: '2-digit' })}
</button>
))}
</div>
) : (
<p className="bc-muted">{t('client.order.noSlots')}</p>
)}
<Button block disabled={!slot || mutate.isPending} onClick={() => slot && mutate.mutate({ id: order.id, scheduledAt: slot })}>
{t('client.detail.rescheduleConfirm')}
</Button>
</div>
</Sheet>
);
}
@@ -0,0 +1,416 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ParamField, ParamValues } from '@barev/core';
import { Button, Card, Center, Chip, Field, Icon, Spinner, Stepper } from '@barev/ui';
import { dateKeyToDate, errorText, haptic, navigate, nextDays, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import { PageHead } from '../lib.tsx';
/** ORD-2…5: параметры → дата и время → адрес → итог со скидкой → подтверждение */
export function OrderForm({ code }: { code: string }) {
const { t, amd, dt, locale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const { query } = useLocation();
const repeatId = query.get('repeat');
const service = useQuery(trpc.client.service.queryOptions({ code }));
const me = useQuery(trpc.client.me.queryOptions());
const districts = useQuery(trpc.client.districts.queryOptions());
const methods = useQuery(trpc.client.paymentMethods.queryOptions());
const repeat = useQuery({ ...trpc.client.orders.repeat.queryOptions({ id: repeatId ?? '' }), enabled: !!repeatId });
const [params, setParams] = useState<ParamValues | null>(null);
const [day, setDay] = useState<string | null>(null);
const [slot, setSlot] = useState<Date | null>(null);
const [districtId, setDistrictId] = useState('');
const [address, setAddress] = useState('');
const [note, setNote] = useState('');
const [methodId, setMethodId] = useState('');
const [touched, setTouched] = useState(false);
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
useEffect(() => {
if (service.data && !params) setParams(service.data.defaults);
}, [service.data, params]);
useEffect(() => {
if (!repeat.data) return;
setParams((p) => ({ ...(p ?? {}), ...(repeat.data.params as ParamValues) }));
setAddress(repeat.data.addressText ?? '');
setDistrictId(repeat.data.districtId ?? '');
setNote(repeat.data.note ?? '');
}, [repeat.data]);
useEffect(() => {
if (methods.data?.[0] && !methodId) setMethodId(methods.data[0].id);
}, [methods.data, methodId]);
const days = useMemo(() => nextDays(service.data?.horizonDays ?? 14), [service.data?.horizonDays]);
useEffect(() => {
if (!day && days[0]) setDay(days[0]);
}, [days, day]);
const quote = useQuery({
...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {} }),
enabled: !!params,
placeholderData: keepPreviousData,
});
const slots = useQuery({
...trpc.client.slots.queryOptions({ serviceCode: code, params: params ?? {}, dateKey: day ?? '' }),
enabled: !!params && !!day,
});
// смена параметров меняет длительность — выбранное время могло стать недоступным
useEffect(() => {
if (slot && slots.data && !slots.data.some((s) => s.start.getTime() === slot.getTime())) setSlot(null);
}, [slots.data, slot]);
const create = useMutation(
trpc.client.createOrder.mutationOptions({
onSuccess: (r) => {
// заказ уже создан: сначала переходим к нему, остальное — не критично
navigate(`/orders/${r.id}`, { replace: true });
toast(t('client.order.created', { number: r.number }));
void qc.invalidateQueries();
haptic.ok();
},
onError: (e) => {
haptic.error();
toast(errorText(t, e), 'error');
void slots.refetch();
},
}),
);
if (service.isPending || !params)
return (
<Center>
<Spinner />
</Center>
);
if (!service.data?.active) {
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
<Card>
<p style={{ margin: 0 }}>{t('client.order.unavailable')}</p>
</Card>
</main>
);
}
const s = service.data;
const set = (key: string, v: string | number | boolean) => setParams({ ...params, [key]: v });
const needsAddress = !s.isOnline;
const missing = {
slot: !slot,
district: needsAddress && !districtId,
address: needsAddress && address.trim().length < 3,
};
const valid = !missing.slot && !missing.district && !missing.address;
function submit() {
setTouched(true);
if (!valid || !slot) return;
create.mutate({
serviceCode: code,
params: params!,
scheduledAt: slot,
addressText: needsAddress ? address.trim() : undefined,
districtId: needsAddress ? districtId : undefined,
note: note.trim() || undefined,
paymentMethodId: methodId || undefined,
repeatOfId: repeatId ?? undefined,
});
}
const q = quote.data;
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
<p className="bc-muted" style={{ margin: '-4px 0 0' }}>
{t(`services.${code}.description`)}
</p>
{/* параметры услуги */}
<Card className="bc-stack" style={{ gap: 16 }}>
{s.paramsSchema.fields
.filter((f) => f.type !== 'extras')
.map((f) => (
<ParamInput key={f.key} serviceCode={code} field={f} value={params[f.key]} onChange={(v) => set(f.key, v)} prices={f.key === 'cleaningType' ? s.variantPrices : undefined} />
))}
{q && (
<div className="bc-row bc-small bc-muted">
<Icon name="clock" size={16} />
{t('client.order.duration', { hours: (q.durationMinutes / 60).toLocaleString(locale, { maximumFractionDigits: 1 }) })}
</div>
)}
</Card>
{/* доп. услуги — клиент сам выбирает, что сделать (модель Qlean) */}
{s.paramsSchema.fields.map((f) =>
f.type === 'extras' ? (
<ExtrasPicker
key={f.key}
items={f.items}
prices={s.extrasPrices}
value={(params[f.key] as Record<string, number> | undefined) ?? {}}
onChange={(v) => setParams({ ...params, [f.key]: v })}
/>
) : null,
)}
{/* ORD-3: дата и время */}
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.order.when')}</h2>
<div className="days" role="listbox" aria-label={t('client.order.date')}>
{days.map((d) => {
const date = dateKeyToDate(d);
return (
<button
key={d}
type="button"
className="day"
aria-pressed={d === day}
onClick={() => {
setDay(d);
setSlot(null);
}}
>
<span className="bc-small">{dt(date, { weekday: 'short' })}</span>
<span className="day__num">{dt(date, { day: 'numeric' })}</span>
</button>
);
})}
</div>
{slots.isPending ? (
<Spinner />
) : slots.data && slots.data.length > 0 ? (
<div className="slots">
{slots.data.map((sl) => (
<button key={sl.start.toISOString()} type="button" className="slot" aria-pressed={slot?.getTime() === sl.start.getTime()} onClick={() => setSlot(sl.start)}>
{dt(sl.start, { hour: '2-digit', minute: '2-digit' })}
</button>
))}
</div>
) : (
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.order.noSlots')}
</p>
)}
{touched && missing.slot && <div className="bc-error">{t('client.order.pickSlot')}</div>}
</Card>
{/* адрес */}
{needsAddress && (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.order.address')}</h2>
<Field label={t('client.order.district')} htmlFor="district" error={touched && missing.district ? t('client.order.pickDistrict') : undefined}>
<select id="district" className="bc-input" value={districtId} onChange={(e) => setDistrictId(e.target.value)}>
<option value="">{t('client.order.districtPlaceholder')}</option>
{districts.data?.map((d) => (
<option key={d.id} value={d.id}>
{t(`districts.${d.code}`)}
</option>
))}
</select>
</Field>
<Field label={t('client.order.addressLabel')} htmlFor="address" error={touched && missing.address ? t('client.order.enterAddress') : undefined}>
<input id="address" className="bc-input" value={address} maxLength={300} placeholder={t('client.order.addressPlaceholder')} onChange={(e) => setAddress(e.target.value)} autoComplete="street-address" />
</Field>
<Field label={t('client.order.note')} htmlFor="note">
<textarea id="note" className="bc-input" value={note} maxLength={1000} placeholder={t('client.order.notePlaceholder')} onChange={(e) => setNote(e.target.value)} />
</Field>
</Card>
)}
{/* PAY-1: способ оплаты, оплата отмечается вручную */}
{methods.data && methods.data.length > 0 && (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.order.payment')}</h2>
<div className="bc-chips">
{methods.data.map((m) => (
<Chip key={m.id} pressed={m.id === methodId} onClick={() => setMethodId(m.id)}>
{(m.name as Record<string, string>)[locale] || (m.name as Record<string, string>).ru}
</Chip>
))}
</div>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.order.paymentHint')}
</p>
</Card>
)}
{/* ORD-4: Стоимость / Скидка Barev+ / Итого — разбивка */}
<Card className="bc-stack">
{q ? (
<div className="price-lines">
{q.extras.length > 0 && (
<>
<div className="bc-between">
<span>{t(`services.${code}.name`)}</span>
<span>{amd(q.servicePrice)}</span>
</div>
{q.extras.map((l) => (
<div key={l.code} className="bc-between bc-small">
<span>
{t(`extras.${l.code}.name`)}
{l.qty > 1 ? ` × ${l.qty}` : ''}
</span>
<span>{amd(l.amount)}</span>
</div>
))}
<hr className="bc-divider" />
</>
)}
<div className="bc-between">
<span>{t('client.order.price')}</span>
<span>{amd(q.basePrice)}</span>
</div>
{q.discountAmount > 0 ? (
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
<span>{t('client.order.discount', { pct: q.discountPct })}</span>
<span>−{amd(q.discountAmount)}</span>
</div>
) : (
s.categoryDiscountPct > 0 && (
<a href="#/membership" className="bc-small" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
{t('client.order.plusHint', { pct: s.categoryDiscountPct })}
</a>
)
)}
</div>
) : (
<Spinner />
)}
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.order.cancelPolicy', { hours: me.data?.freeCancelHours ?? 24, pct: s.lateCancelFeePct })}
</p>
</Card>
{/* закреплённый низ: только итог, время и кнопка — не закрывает форму на телефоне */}
<Card className="bc-stack sticky-cta" style={{ boxShadow: 'var(--bc-shadow)', gap: 10 }}>
<div className="bc-between">
<span className="bc-num" style={{ fontSize: 22 }}>
{t('client.order.total')}
</span>
<span className="bc-num" style={{ fontSize: 22 }}>
{q ? amd(q.totalPrice) : '…'}
</span>
</div>
{slot && (
<div className="bc-small bc-muted" style={{ marginTop: -6 }}>
{dt(slot, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
</div>
)}
<Button block onClick={submit} disabled={create.isPending}>
{t('client.order.confirm')}
</Button>
</Card>
</main>
);
}
function ParamInput({
serviceCode,
field,
value,
onChange,
prices,
}: {
serviceCode: string;
field: ParamField;
value: unknown;
onChange: (v: string | number | boolean) => void;
prices?: Record<string, number>;
}) {
if (field.type === 'extras') return null;
const { t, amd } = useI18n();
const label = t(`params.${serviceCode}.${field.key}.label`);
switch (field.type) {
case 'select':
return (
<div className="bc-field">
<span className="bc-label">{label}</span>
<div className="option-grid" role="radiogroup" aria-label={label}>
{field.options.map((o) => (
<button key={o} type="button" className="option" role="radio" aria-checked={value === o} aria-pressed={value === o} onClick={() => onChange(o)}>
<span style={{ fontWeight: 700 }}>{t(`params.${serviceCode}.${field.key}.${o}`)}</span>
<span className="bc-small bc-muted">
{prices?.[o] !== undefined ? t('common.from', { price: amd(prices[o]!) }) : t(`params.${serviceCode}.${field.key}.${o}_hint`)}
</span>
</button>
))}
</div>
</div>
);
case 'int':
return (
<div className="bc-between">
<span style={{ fontWeight: 600 }}>{label}</span>
<Stepper value={Number(value ?? field.min)} min={field.min} max={field.max} onChange={onChange} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
</div>
);
case 'text':
return (
<Field label={label}>
<textarea className="bc-input" value={String(value ?? '')} maxLength={field.maxLength} onChange={(e) => onChange(e.target.value)} />
</Field>
);
case 'bool':
return (
<Chip pressed={value === true} onClick={() => onChange(value !== true)}>
{label}
</Chip>
);
}
}
/** Доп. услуги: «за окно», «за час»… — счётчик; разовые услуги — переключатель */
function ExtrasPicker({
items,
prices,
value,
onChange,
}: {
items: { code: string; max: number }[];
prices: Record<string, { price: number; unit: string }>;
value: Record<string, number>;
onChange: (v: Record<string, number>) => void;
}) {
const { t, amd } = useI18n();
const set = (code: string, qty: number) => {
const next = { ...value };
if (qty > 0) next[code] = qty;
else delete next[code];
onChange(next);
};
return (
<Card className="bc-stack" style={{ gap: 4 }}>
<h2 className="bc-h3" style={{ marginBottom: 6 }}>
{t('client.order.extras')}
</h2>
{items.map((it) => {
const p = prices[it.code];
if (!p) return null;
const qty = value[it.code] ?? 0;
return (
<div key={it.code} className="extra-row">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{t(`extras.${it.code}.name`)}</div>
<div className="bc-small bc-muted">
{amd(p.price)}
{p.unit !== 'service' ? ` ${t(`extras.${it.code}.per`)}` : ''}
</div>
</div>
{it.max === 1 ? (
<button type="button" className="extra-toggle" aria-pressed={qty > 0} onClick={() => set(it.code, qty > 0 ? 0 : 1)}>
{qty > 0 ? <Icon name="check" size={18} strokeWidth={2.6} /> : t('client.order.add')}
</button>
) : (
<Stepper value={qty} min={0} max={it.max} onChange={(n) => set(it.code, n)} decLabel={t('common.decrease')} incLabel={t('common.increase')} />
)}
</div>
);
})}
</Card>
);
}
@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import { Center, LinkButton, Segmented, Spinner } from '@barev/ui';
import { useI18n, useTRPC } from '@barev/web';
import { useState } from 'react';
import { OrderRow, PageHead } from '../lib.tsx';
export function Orders() {
const { t } = useI18n();
const trpc = useTRPC();
const orders = useQuery({ ...trpc.client.orders.list.queryOptions(), refetchInterval: 20_000 });
const [tab, setTab] = useState<'active' | 'history'>('active');
if (!orders.data)
return (
<Center>
<Spinner />
</Center>
);
const list = orders.data[tab];
return (
<main className="app">
<PageHead title={t('client.orders.title')} />
<Segmented
value={tab}
onChange={setTab}
options={[
{ value: 'active', label: `${t('client.orders.active')} · ${orders.data.active.length}` },
{ value: 'history', label: t('client.orders.history') },
]}
/>
{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>
) : (
<div className="bc-stack" style={{ gap: 8 }}>
{list.map((o) => (
<OrderRow key={o.id} o={o} />
))}
</div>
)}
</main>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { Card } 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 { Qr } from '../qr.tsx';
/**
* Клубная карта с живым QR (меняется каждую минуту — скриншот не сработает) и партнёры клуба:
* что положено на твоём уровне и сколько осталось по лимиту.
*/
export function Perks() {
const { t, locale } = useI18n();
const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions());
const qr = useQuery({ ...trpc.client.cardQr.queryOptions(), refetchInterval: 20_000, refetchIntervalInBackground: false });
const partners = useQuery(trpc.client.partners.queryOptions());
const level = (me.data?.membership.level ?? 'BAREV') as Level;
const tone = LEVEL_TONE[level];
const left = useSecondsLeft(qr.data?.expiresAt);
const name = (l: Record<string, string>) => l[locale] || l.ru || '';
return (
<main className="app">
<PageHead title={t('client.perks.title')} back={false} />
<section className="card-qr" style={{ background: level === 'BAREV' ? 'var(--bc-plum)' : tone.bg, color: level === 'BAREV' ? '#fff' : tone.ink }}>
<div className="card-qr__top">
<span>Barev Club · {LEVEL_NAME[level]}</span>
<b>{me.data?.firstName}</b>
</div>
<div className="card-qr__code">{qr.data ? <Qr value={qr.data.url} size={208} label={t('client.partners.qrLabel')} /> : <div style={{ width: 208, height: 208 }} />}</div>
<div className="card-qr__meta">
<span className="bc-num">{me.data?.cardCode}</span>
<span>{t('client.partners.qrRefresh', { seconds: left })}</span>
</div>
</section>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.partners.howTo')}
</p>
{partners.data?.length === 0 ? (
<Card tone="perks" className="bc-stack">
<h2 className="bc-h3">{t('client.perks.soonTitle')}</h2>
<p style={{ margin: 0 }}>{t('client.perks.soonText')}</p>
</Card>
) : (
<ul className="partners">
{partners.data?.map((p) => (
<li key={p.id} className="partner">
<span className="partner__cat">{t(`client.partners.cat.${p.category}`)}</span>
<b className="partner__name">{p.name}</b>
{p.address && <span className="bc-small bc-muted">{p.address}</span>}
<ul className="offers">
{(p.offers.length ? p.offers : [{ id: 'main', title: p.offer, eligible: true, remaining: null, minLevel: null, limit: 'NONE' as const, limitCount: 1 }]).map((o) => (
<li key={o.id} className="offer" data-eligible={o.eligible} data-used={o.remaining === 0}>
<span>{name(o.title)}</span>
<small>
{!o.eligible
? t('client.partners.needLevel', { level: LEVEL_NAME[(o.minLevel ?? 'BAREV') as Level] })
: o.remaining === null
? o.minLevel
? LEVEL_NAME[o.minLevel as Level]
: t('client.partners.allMembers')
: o.remaining > 0
? t(`client.partners.limit_${o.limit}`, { count: o.limitCount })
: t(`client.partners.used_${o.limit}`)}
</small>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</main>
);
}
function useSecondsLeft(expiresAt?: Date) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
return expiresAt ? Math.max(0, Math.ceil((new Date(expiresAt).getTime() - now) / 1000)) : 60;
}
+147
View File
@@ -0,0 +1,147 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
import { Button, Card, Chip, Field, Icon, Segmented } from '@barev/ui';
import { errorText, haptic, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { LEVEL_NAME, LevelBadge, PageHead, type Level } from '../lib.tsx';
export function Profile() {
const { t, dt } = useI18n();
const trpc = useTRPC();
const me = useQuery(trpc.client.me.queryOptions());
const { query } = useLocation();
const level = (me.data?.membership.level ?? 'BAREV') as Level;
const plus = level !== 'BAREV';
return (
<main className="app">
<PageHead title={t('client.profile.title')} back={false} />
<Card className="bc-stack">
<div className="bc-between">
<div>
<div className="bc-h3">{me.data?.firstName}</div>
<div className="bc-small bc-muted">{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}</div>
</div>
<LevelBadge level={level} />
</div>
{plus && me.data?.membership.endsAt && <div className="bc-small">{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long', year: 'numeric' }) })}</div>}
<a href="#/membership" className="bc-row" style={{ fontWeight: 600, color: 'var(--bc-accent)', textDecoration: 'none' }}>
{t('client.profile.membership')} <Icon name="arrow" size={16} />
</a>
</Card>
<SegmentSwitch current={me.data?.segment ?? null} />
{!me.data?.agencyName && <AgencyCode autoFocus={query.get('code') === '1'} />}
<LanguagePicker />
<Card tight>
<a href="#/orders" className="bc-between" style={{ textDecoration: 'none', minHeight: 44 }}>
<span style={{ fontWeight: 600 }}>{t('client.orders.title')}</span>
<Icon name="arrow" size={18} />
</a>
</Card>
</main>
);
}
/** ONB-6: ручной ввод кода агентства */
function AgencyCode({ autoFocus }: { autoFocus?: boolean }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [code, setCode] = useState('');
const apply = useMutation(
trpc.client.applyAgencyCode.mutationOptions({
onSuccess: (r) => {
haptic.ok();
toast(r.gift ? t('client.profile.codeGift', { agency: r.agencyName }) : t('client.profile.codeAccepted', { agency: r.agencyName }));
void qc.invalidateQueries();
},
onError: (e) => {
haptic.error();
toast(errorText(t, e), 'error');
},
}),
);
return (
<Card tone="home" className="bc-stack">
<h2 className="bc-h3">{t('client.profile.codeTitle')}</h2>
<p className="bc-small" style={{ margin: 0 }}>
{t('client.profile.codeText')}
</p>
<form
className="bc-row"
onSubmit={(e) => {
e.preventDefault();
if (code.trim()) apply.mutate({ code: code.trim() });
}}
>
<Field>
<input className="bc-input" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} placeholder={t('client.profile.codePlaceholder')} autoFocus={autoFocus} maxLength={32} autoCapitalize="characters" aria-label={t('client.profile.codeTitle')} />
</Field>
<Button type="submit" disabled={apply.isPending || code.trim().length < 2}>
{t('common.apply')}
</Button>
</form>
</Card>
);
}
export function LanguagePicker() {
const { t, locale, setLocale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const save = useMutation(trpc.client.setLocale.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
const pick = (l: Locale) => {
setLocale(l);
save.mutate({ locale: l });
};
return (
<Card className="bc-stack">
<h2 className="bc-h3 bc-row">
<Icon name="globe" size={20} />
{t('client.profile.language')}
</h2>
<div className="bc-chips">
{LOCALES.map((l) => (
<Chip key={l} pressed={l === locale} onClick={() => pick(l)} lang={l}>
{LOCALE_NAMES[l]}
</Chip>
))}
</div>
</Card>
);
}
/** Сценарий приложения: переезжаю / уже живу — меняет главную и меню */
function SegmentSwitch({ current }: { current: 'MOVING' | 'LIVING' | null }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const set = useMutation(
trpc.client.setSegment.mutationOptions({
onSuccess: () => {
haptic.ok();
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.segment.profileTitle')}</h2>
<Segmented
value={current ?? 'LIVING'}
onChange={(segment) => set.mutate({ segment })}
options={[
{ value: 'MOVING', label: t('client.segment.moving') },
{ value: 'LIVING', label: t('client.segment.living') },
]}
/>
</Card>
);
}
@@ -0,0 +1,46 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@barev/ui';
import { errorText, haptic, navigate, useI18n, useToast, useTRPC } from '@barev/web';
/** Первый вход: кто перед нами — переезжает или уже живёт в Армении. Сменить можно в профиле */
export function Segment() {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const set = useMutation(
trpc.client.setSegment.mutationOptions({
onSuccess: () => {
haptic.ok();
navigate('/', { replace: true });
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<main className="app app--plain seg">
<div className="seg__word" aria-hidden="true">
Barev
</div>
<h1 className="seg__title">{t('client.segment.title')}</h1>
<p className="seg__sub">{t('client.segment.sub')}</p>
<div className="seg__choices">
<button className="seg__choice" disabled={set.isPending} onClick={() => set.mutate({ segment: 'MOVING' })}>
<Icon name="globe" size={26} />
<span>
<b>{t('client.segment.moving')}</b>
<span>{t('client.segment.movingText')}</span>
</span>
</button>
<button className="seg__choice" disabled={set.isPending} onClick={() => set.mutate({ segment: 'LIVING' })}>
<Icon name="home" size={26} />
<span>
<b>{t('client.segment.living')}</b>
<span>{t('client.segment.livingText')}</span>
</span>
</button>
</div>
</main>
);
}
@@ -0,0 +1,66 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field, Icon } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
/** Услуга без онлайн-записи: «перезвоните мне» с комментарием — оператор оформит заказ сам */
export function ServiceRequest({ code }: { code: string }) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const catalog = useQuery(trpc.client.catalog.queryOptions());
const open = useQuery(trpc.client.requests.open.queryOptions());
const [comment, setComment] = useState('');
const create = useMutation(
trpc.client.requests.create.mutationOptions({
onSuccess: () => {
haptic.ok();
void qc.invalidateQueries({ queryKey: trpc.client.requests.open.queryKey() });
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const category = catalog.data?.categories.find((c) => c.services.some((s) => s.code === code));
const level = (catalog.data?.level ?? 'BAREV') as Level;
const pct = category ? levelDiscount(level, category) : 0;
const sent = create.data ?? open.data?.find((r) => r.kind === 'SERVICE' && r.serviceCode === code);
return (
<main className="app app--plain">
<PageHead title={t(`services.${code}.name`)} />
<Card className={`bc-stack ${category ? toneClass(category.code) : ''}`}>
<p style={{ margin: 0 }}>{t(`services.${code}.description`)}</p>
{pct > 0 && <div style={{ fontWeight: 700 }}>{t('client.request.yourDiscount', { pct })}</div>}
</Card>
{sent ? (
<Card className="bc-stack" style={{ alignItems: 'center', textAlign: 'center' }}>
<span className="req-done" aria-hidden="true">
<Icon name="check" size={28} strokeWidth={2.6} />
</span>
<h2 className="bc-h3">{t('client.request.sentTitle', { number: sent.number })}</h2>
<p className="bc-muted" style={{ margin: 0 }}>
{t('client.request.sentText')}
</p>
<a href="#/services" className="bc-btn bc-btn--light">
{t('client.request.back')}
</a>
</Card>
) : (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.request.title')}</h2>
<p className="bc-small bc-muted" style={{ margin: 0 }}>
{t('client.request.sub')}
</p>
<Field label={t('client.request.comment')}>
<textarea className="bc-input" style={{ minHeight: 100 }} maxLength={1000} value={comment} placeholder={t('client.request.commentHint')} onChange={(e) => setComment(e.target.value)} />
</Field>
<Button onClick={() => create.mutate({ kind: 'SERVICE', serviceCode: code, comment: comment.trim() || undefined })} disabled={create.isPending}>
{t('client.request.submit')}
</Button>
</Card>
)}
</main>
);
}
@@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query';
import { CATEGORY_ICON, Center, Icon, Spinner, type CategoryCode } from '@barev/ui';
import { useI18n, useLocation, useTRPC } from '@barev/web';
import { useEffect } from 'react';
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
/** Каталог: направления со скидкой по уровню клиента; услуги без онлайн-записи — заявка «перезвоните» */
export function Services() {
const { t, amd } = useI18n();
const trpc = useTRPC();
const { query } = useLocation();
const focus = query.get('c');
const catalog = useQuery(trpc.client.catalog.queryOptions());
useEffect(() => {
if (focus && catalog.data) document.getElementById(`cat-${focus}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [focus, catalog.data]);
if (!catalog.data)
return (
<Center>
<Spinner />
</Center>
);
const level = catalog.data.level as Level;
return (
<main className="app">
<PageHead title={t('client.services.title')} back={false} />
{catalog.data.categories
.filter((c) => c.code !== 'PERKS')
.map((c) => (
<section key={c.code} id={`cat-${c.code}`} className={`bc-card ${toneClass(c.code)}`} style={{ padding: 20, scrollMarginTop: 12 }}>
<div className="bc-between" style={{ marginBottom: 12 }}>
<h2 className="bc-h2">{t(`categories.${c.code}.name`)}</h2>
<Icon name={CATEGORY_ICON[c.code as CategoryCode]} size={26} />
</div>
<div className="bc-stack" style={{ gap: 6 }}>
{c.services.map((s) =>
s.active ? (
<a key={s.code} href={`#/order/${s.code}`} className="bc-row-pill">
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
<span className="bc-row">
{t('common.from', { price: amd(s.fromPrice) })}
<Icon name="arrow" size={16} />
</span>
</a>
) : (
<a key={s.code} href={`#/request/${s.code}`} className="bc-row-pill">
<span style={{ fontWeight: 600 }}>{t(`services.${s.code}.name`)}</span>
<span className="bc-row bc-small">
{t('client.services.callback')}
<Icon name="arrow" size={16} />
</span>
</a>
),
)}
</div>
{levelDiscount(level, c) > 0 ? (
<div className="bc-small" style={{ marginTop: 10, fontWeight: 600 }}>
{t('client.services.yourDiscount', { pct: levelDiscount(level, c) })}
</div>
) : (
c.eliteDiscountPct > 0 && (
<a href="#/membership" className="bc-small" style={{ display: 'block', marginTop: 10, fontWeight: 600, color: 'inherit' }}>
{t('client.services.withElite', { pct: c.eliteDiscountPct })}
</a>
)
)}
</section>
))}
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
"include": ["src", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: true,
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
},
build: { target: 'es2022', sourcemap: true },
});