Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
Reference in new issue
Block a user