From 3489e0b8162059634f484bbdf545af3044c20ab0 Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Sun, 25 Oct 2026 22:55:29 +0400 Subject: [PATCH] Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra Co-Authored-By: Claude Opus 5.5 (1M context) --- .dockerignore | 11 + .env.example | 5 + .gitignore | 6 + apps/admin/index.html | 16 + apps/admin/package.json | 30 + apps/admin/src/App.tsx | 147 + apps/admin/src/admin.css | 374 +++ apps/admin/src/components/OrderDrawer.tsx | 282 ++ apps/admin/src/components/form.tsx | 87 + apps/admin/src/main.tsx | 16 + apps/admin/src/pages/Agencies.tsx | 119 + apps/admin/src/pages/Clients.tsx | 89 + apps/admin/src/pages/Companies.tsx | 126 + apps/admin/src/pages/Concierge.tsx | 145 + apps/admin/src/pages/Kanban.tsx | 148 + apps/admin/src/pages/Leads.tsx | 337 +++ apps/admin/src/pages/Login.tsx | 93 + apps/admin/src/pages/Partners.tsx | 259 ++ apps/admin/src/pages/Presets.tsx | 207 ++ apps/admin/src/pages/Providers.tsx | 227 ++ apps/admin/src/pages/Requests.tsx | 118 + apps/admin/src/pages/Services.tsx | 231 ++ apps/admin/src/pages/Settings.tsx | 176 ++ apps/admin/src/pages/Telegram.tsx | 72 + apps/admin/src/pages/Templates.tsx | 134 + apps/admin/src/pages/WebRequests.tsx | 154 ++ apps/admin/tsconfig.json | 5 + apps/admin/vite.config.ts | 13 + apps/api/package.json | 40 + apps/api/scripts/dev-backoffice-demo.ts | 205 ++ apps/api/scripts/dev-demo.ts | 103 + apps/api/scripts/dev-initdata.ts | 37 + apps/api/src/auth/admin.ts | 58 + apps/api/src/auth/telegram.ts | 65 + apps/api/src/auth/totp.ts | 63 + apps/api/src/env.ts | 49 + apps/api/src/infra.ts | 53 + apps/api/src/router.ts | 16 + apps/api/src/routers/admin.ts | 572 ++++ apps/api/src/routers/backoffice/auth.ts | 103 + apps/api/src/routers/backoffice/clients.ts | 364 +++ apps/api/src/routers/backoffice/dashboard.ts | 157 ++ apps/api/src/routers/backoffice/index.ts | 13 + apps/api/src/routers/client.ts | 235 ++ apps/api/src/routers/pro.ts | 112 + apps/api/src/routers/public.ts | 155 ++ apps/api/src/server.ts | 96 + apps/api/src/trpc.ts | 169 ++ apps/api/test/auth.test.ts | 53 + apps/api/tsconfig.json | 1 + apps/backoffice/index.html | 18 + apps/backoffice/package.json | 29 + apps/backoffice/src/App.tsx | 201 ++ apps/backoffice/src/components/kit.tsx | 144 + apps/backoffice/src/i18n.tsx | 976 +++++++ apps/backoffice/src/main.tsx | 19 + apps/backoffice/src/pages/ClientCard.tsx | 403 +++ apps/backoffice/src/pages/Clients.tsx | 384 +++ apps/backoffice/src/pages/Dashboard.tsx | 423 +++ apps/backoffice/src/pages/Login.tsx | 125 + apps/backoffice/src/pages/Soon.tsx | 23 + apps/backoffice/src/pages/SubDialog.tsx | 217 ++ apps/backoffice/src/pages/Team.tsx | 185 ++ apps/backoffice/src/styles.css | 1418 ++++++++++ apps/backoffice/tsconfig.json | 5 + apps/backoffice/vite.config.ts | 13 + apps/bot-client/package.json | 22 + apps/bot-client/src/index.ts | 192 ++ apps/bot-client/tsconfig.json | 1 + apps/bot-pro/package.json | 22 + apps/bot-pro/src/index.ts | 264 ++ apps/bot-pro/tsconfig.json | 1 + .../app/(redirect)/apartments/page.tsx | 8 + apps/landing/app/(redirect)/clean/page.tsx | 6 + apps/landing/app/(redirect)/flower/page.tsx | 8 + apps/landing/app/(redirect)/land1/page.tsx | 6 + apps/landing/app/(redirect)/land2/page.tsx | 6 + apps/landing/app/(redirect)/land3/page.tsx | 6 + apps/landing/app/(redirect)/land4/page.tsx | 6 + apps/landing/app/(redirect)/land5/page.tsx | 6 + apps/landing/app/(redirect)/land6/page.tsx | 6 + apps/landing/app/(redirect)/layout.tsx | 10 + apps/landing/app/(redirect)/live/page.tsx | 6 + apps/landing/app/(redirect)/page.tsx | 6 + .../app/[locale]/airport/opengraph-image.tsx | 14 + apps/landing/app/[locale]/airport/page.tsx | 24 + apps/landing/app/[locale]/apartments/page.tsx | 76 + .../app/[locale]/clean/opengraph-image.tsx | 15 + apps/landing/app/[locale]/clean/page.tsx | 123 + apps/landing/app/[locale]/flower/page.tsx | 186 ++ apps/landing/app/[locale]/land0/page.tsx | 289 ++ apps/landing/app/[locale]/land1/page.tsx | 143 + apps/landing/app/[locale]/land2/page.tsx | 11 + apps/landing/app/[locale]/land3/page.tsx | 111 + apps/landing/app/[locale]/land4/page.tsx | 136 + apps/landing/app/[locale]/land5/page.tsx | 67 + apps/landing/app/[locale]/land6/page.tsx | 130 + apps/landing/app/[locale]/layout.tsx | 56 + .../app/[locale]/live/opengraph-image.tsx | 14 + apps/landing/app/[locale]/live/page.tsx | 122 + apps/landing/app/[locale]/opengraph-image.tsx | 14 + apps/landing/app/[locale]/page.tsx | 144 + apps/landing/app/[locale]/pricing/page.tsx | 117 + apps/landing/app/[locale]/privacy/page.tsx | 22 + apps/landing/app/[locale]/refund/page.tsx | 22 + .../residence-permit/opengraph-image.tsx | 14 + .../app/[locale]/residence-permit/page.tsx | 24 + .../[locale]/social-card/opengraph-image.tsx | 14 + .../landing/app/[locale]/social-card/page.tsx | 24 + apps/landing/app/[locale]/terms/page.tsx | 22 + .../app/api/flats/photo/[id]/[index]/route.ts | 21 + apps/landing/app/api/flats/request/route.ts | 50 + apps/landing/app/apple-icon.tsx | 18 + apps/landing/app/clean.css | 696 +++++ apps/landing/app/favicon.ico | Bin 0 -> 5388 bytes apps/landing/app/flats.css | 467 ++++ apps/landing/app/flower.css | 363 +++ apps/landing/app/home.css | 746 +++++ apps/landing/app/icon.svg | 1 + apps/landing/app/landing.css | 635 +++++ apps/landing/app/robots.ts | 31 + apps/landing/app/service.css | 282 ++ apps/landing/app/sitemap.ts | 15 + apps/landing/app/variants.css | 1623 +++++++++++ apps/landing/assets/NotoSansArmenian-Bold.ttf | Bin 0 -> 48412 bytes apps/landing/assets/Rubik-ExtraBold.ttf | Bin 0 -> 175524 bytes apps/landing/assets/flowers/l.jpg | Bin 0 -> 146213 bytes apps/landing/assets/flowers/m.jpg | Bin 0 -> 96758 bytes apps/landing/assets/flowers/s.jpg | Bin 0 -> 78922 bytes apps/landing/components/clean.tsx | 446 +++ apps/landing/components/client.tsx | 117 + apps/landing/components/flats.tsx | 425 +++ apps/landing/components/flowers.tsx | 151 + apps/landing/components/gift-code.tsx | 9 + apps/landing/components/home.tsx | 110 + apps/landing/components/legal.tsx | 107 + apps/landing/components/quiz.tsx | 459 ++++ apps/landing/components/rich-text.tsx | 26 + apps/landing/components/savings.tsx | 86 + apps/landing/components/seo.tsx | 80 + apps/landing/components/service-page.tsx | 214 ++ apps/landing/components/variant.tsx | 39 + apps/landing/lib/data.ts | 105 + apps/landing/lib/flats.ts | 47 + apps/landing/lib/og.tsx | 44 + apps/landing/lib/redirect.ts | 19 + apps/landing/lib/seo.ts | 23 + apps/landing/lib/services.ts | 79 + apps/landing/next-env.d.ts | 7 + apps/landing/next.config.ts | 16 + apps/landing/package.json | 24 + apps/landing/tsconfig.json | 13 + apps/landing/tsconfig.tsbuildinfo | 1 + apps/miniapp-client/index.html | 17 + apps/miniapp-client/package.json | 30 + apps/miniapp-client/src/App.tsx | 149 + apps/miniapp-client/src/app.css | 781 ++++++ apps/miniapp-client/src/lib.tsx | 95 + apps/miniapp-client/src/main.tsx | 19 + apps/miniapp-client/src/qr.tsx | 17 + apps/miniapp-client/src/screens/Consent.tsx | 43 + apps/miniapp-client/src/screens/Docs.tsx | 70 + apps/miniapp-client/src/screens/Home.tsx | 103 + .../miniapp-client/src/screens/Membership.tsx | 88 + apps/miniapp-client/src/screens/Move.tsx | 104 + apps/miniapp-client/src/screens/MoveForm.tsx | 151 + .../src/screens/OrderDetail.tsx | 265 ++ apps/miniapp-client/src/screens/OrderForm.tsx | 416 +++ apps/miniapp-client/src/screens/Orders.tsx | 44 + apps/miniapp-client/src/screens/Perks.tsx | 85 + apps/miniapp-client/src/screens/Profile.tsx | 147 + apps/miniapp-client/src/screens/Segment.tsx | 46 + .../src/screens/ServiceRequest.tsx | 66 + apps/miniapp-client/src/screens/Services.tsx | 73 + apps/miniapp-client/tsconfig.json | 5 + apps/miniapp-client/vite.config.ts | 13 + apps/miniapp-pro/index.html | 17 + apps/miniapp-pro/package.json | 30 + apps/miniapp-pro/src/App.tsx | 95 + apps/miniapp-pro/src/app.css | 205 ++ apps/miniapp-pro/src/auth.ts | 5 + apps/miniapp-pro/src/lib.tsx | 65 + apps/miniapp-pro/src/main.tsx | 19 + apps/miniapp-pro/src/qr.tsx | 17 + apps/miniapp-pro/src/screens/Company.tsx | 134 + apps/miniapp-pro/src/screens/History.tsx | 87 + apps/miniapp-pro/src/screens/Job.tsx | 207 ++ apps/miniapp-pro/src/screens/Partner.tsx | 211 ++ apps/miniapp-pro/src/screens/Today.tsx | 167 ++ apps/miniapp-pro/tsconfig.json | 5 + apps/miniapp-pro/vite.config.ts | 13 + apps/runtime/index.ts | 10 + apps/runtime/package.json | 8 + apps/worker/package.json | 22 + apps/worker/src/index.ts | 307 +++ apps/worker/tsconfig.json | 1 + docs/backoffice/SPEC.md | 95 + docs/design/logos/1-bubble-logo-dark.svg | 3 + docs/design/logos/1-bubble-logo.svg | 3 + docs/design/logos/1-bubble-symbol.svg | 3 + docs/design/logos/2-key-home-logo-dark.svg | 4 + docs/design/logos/2-key-home-logo.svg | 4 + docs/design/logos/2-key-home-symbol.svg | 4 + docs/design/logos/3-apricot-logo-dark.svg | 5 + docs/design/logos/3-apricot-logo.svg | 5 + docs/design/logos/3-apricot-symbol.svg | 5 + docs/design/logos/4-ararat-logo-dark.svg | 5 + docs/design/logos/4-ararat-logo.svg | 5 + docs/design/logos/4-ararat-symbol.svg | 5 + docs/design/logos/5-pomegranate-logo-dark.svg | 4 + docs/design/logos/5-pomegranate-logo.svg | 4 + docs/design/logos/5-pomegranate-symbol.svg | 4 + docs/design/logos/6-monogram-logo-dark.svg | 3 + docs/design/logos/6-monogram-logo.svg | 3 + docs/design/logos/6-monogram-symbol.svg | 3 + docs/design/logos/index.html | 202 ++ docs/design/logos/overview.png | Bin 0 -> 202173 bytes docs/design/wordmarks/index.html | 132 + .../wordmarks/png/1-rubik-avatar-640.png | Bin 0 -> 8777 bytes docs/design/wordmarks/png/1-rubik-dark.png | Bin 0 -> 32473 bytes docs/design/wordmarks/png/1-rubik-light.png | Bin 0 -> 32447 bytes .../wordmarks/png/2-fraunces-avatar-640.png | Bin 0 -> 11766 bytes docs/design/wordmarks/png/2-fraunces-dark.png | Bin 0 -> 39403 bytes .../design/wordmarks/png/2-fraunces-light.png | Bin 0 -> 39176 bytes .../wordmarks/png/3-unbounded-avatar-640.png | Bin 0 -> 14272 bytes .../design/wordmarks/png/3-unbounded-dark.png | Bin 0 -> 32051 bytes .../wordmarks/png/3-unbounded-light.png | Bin 0 -> 31872 bytes .../wordmarks/png/4-shrikhand-avatar-640.png | Bin 0 -> 13926 bytes .../design/wordmarks/png/4-shrikhand-dark.png | Bin 0 -> 57244 bytes .../wordmarks/png/4-shrikhand-light.png | Bin 0 -> 65016 bytes .../png/5-big-shoulders-avatar-640.png | Bin 0 -> 11822 bytes .../wordmarks/png/5-big-shoulders-dark.png | Bin 0 -> 31514 bytes .../wordmarks/png/5-big-shoulders-light.png | Bin 0 -> 30825 bytes .../wordmarks/png/6-fredoka-avatar-640.png | Bin 0 -> 13559 bytes docs/design/wordmarks/png/6-fredoka-dark.png | Bin 0 -> 35255 bytes docs/design/wordmarks/png/6-fredoka-light.png | Bin 0 -> 34088 bytes docs/design/wordmarks/render.html | 86 + flowers/L.jpg | Bin 0 -> 725261 bytes flowers/M.jpg | Bin 0 -> 617941 bytes flowers/S.jpg | Bin 0 -> 577734 bytes flowers/all.jpg | Bin 0 -> 710753 bytes infra/prod/Dockerfile | 37 + infra/prod/deploy.sh | 62 + infra/prod/docker-compose.yml | 105 + infra/prod/env.example | 45 + infra/prod/nginx.conf | 85 + infra/prod/seaweed-s3.json.template | 9 + package.json | 2 +- packages/core/src/catalog.ts | 42 +- packages/core/src/domain.test.ts | 86 +- packages/core/src/index.ts | 1 + packages/core/src/membership.ts | 22 +- packages/core/src/params.ts | 37 +- packages/core/src/payout.ts | 60 + packages/core/src/pricing.ts | 103 +- packages/core/src/settings.ts | 28 +- packages/core/src/slots.ts | 17 + packages/db/package.json | 11 +- packages/db/prisma.config.ts | 5 +- .../migration.sql | 22 + .../20261018205549_web_requests/migration.sql | 54 + .../20261019104126_quiz_leads/migration.sql | 38 + .../migration.sql | 106 + .../migration.sql | 94 + .../migration.sql | 81 + .../migration.sql | 64 + packages/db/prisma/schema.prisma | 412 ++- packages/db/src/scripts/create-admin.ts | 28 + packages/db/src/scripts/reset-2fa.ts | 26 + packages/db/src/scripts/sync-catalog.ts | 117 + packages/db/src/seed/index.ts | 12 +- packages/i18n/locales/en.json | 2443 +++++++++++++++++ packages/i18n/locales/es.json | 2443 +++++++++++++++++ packages/i18n/locales/hy.json | 2443 +++++++++++++++++ packages/i18n/locales/ru.json | 2443 +++++++++++++++++ packages/i18n/locales/zh.json | 2443 +++++++++++++++++ packages/i18n/package.json | 22 + packages/i18n/review.json | 1 + packages/i18n/scripts/check.ts | 62 + packages/i18n/scripts/dynamic-keys.ts | 37 + packages/i18n/scripts/export.ts | 31 + packages/i18n/scripts/import.ts | 45 + packages/i18n/scripts/lib.ts | 79 + packages/i18n/src/index.ts | 112 + packages/i18n/tsconfig.json | 1 + packages/services/package.json | 10 +- packages/services/src/catalog.ts | 5 +- packages/services/src/clientRequests.ts | 71 + packages/services/src/concierge.ts | 87 + packages/services/src/context.ts | 25 +- packages/services/src/env.ts | 13 + packages/services/src/index.ts | 7 + packages/services/src/leads.ts | 129 + packages/services/src/orders.ts | 3 +- packages/services/src/partners.ts | 223 ++ packages/services/src/payouts.ts | 65 + packages/services/src/providers.ts | 25 +- packages/services/src/queue.ts | 21 + packages/services/src/relocation.ts | 209 ++ packages/services/src/telegram.ts | 58 + packages/services/src/webRequests.ts | 162 ++ packages/services/test/flows.test.ts | 327 ++- packages/services/test/helpers.ts | 9 + packages/ui/package.json | 14 + packages/ui/src/index.tsx | 211 ++ packages/ui/src/styles.css | 598 ++++ packages/ui/tsconfig.json | 1 + packages/web/package.json | 28 + packages/web/src/dates.ts | 16 + packages/web/src/i18n.tsx | 58 + packages/web/src/index.ts | 7 + packages/web/src/params.ts | 19 + packages/web/src/router.tsx | 55 + packages/web/src/telegram.ts | 168 ++ packages/web/src/toast.tsx | 23 + packages/web/src/trpc.tsx | 51 + packages/web/tsconfig.json | 1 + pnpm-lock.yaml | 2408 +++++++++++++++- pnpm-workspace.yaml | 2 + 319 files changed, 45483 insertions(+), 121 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/admin/index.html create mode 100644 apps/admin/package.json create mode 100644 apps/admin/src/App.tsx create mode 100644 apps/admin/src/admin.css create mode 100644 apps/admin/src/components/OrderDrawer.tsx create mode 100644 apps/admin/src/components/form.tsx create mode 100644 apps/admin/src/main.tsx create mode 100644 apps/admin/src/pages/Agencies.tsx create mode 100644 apps/admin/src/pages/Clients.tsx create mode 100644 apps/admin/src/pages/Companies.tsx create mode 100644 apps/admin/src/pages/Concierge.tsx create mode 100644 apps/admin/src/pages/Kanban.tsx create mode 100644 apps/admin/src/pages/Leads.tsx create mode 100644 apps/admin/src/pages/Login.tsx create mode 100644 apps/admin/src/pages/Partners.tsx create mode 100644 apps/admin/src/pages/Presets.tsx create mode 100644 apps/admin/src/pages/Providers.tsx create mode 100644 apps/admin/src/pages/Requests.tsx create mode 100644 apps/admin/src/pages/Services.tsx create mode 100644 apps/admin/src/pages/Settings.tsx create mode 100644 apps/admin/src/pages/Telegram.tsx create mode 100644 apps/admin/src/pages/Templates.tsx create mode 100644 apps/admin/src/pages/WebRequests.tsx create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/scripts/dev-backoffice-demo.ts create mode 100644 apps/api/scripts/dev-demo.ts create mode 100644 apps/api/scripts/dev-initdata.ts create mode 100644 apps/api/src/auth/admin.ts create mode 100644 apps/api/src/auth/telegram.ts create mode 100644 apps/api/src/auth/totp.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/infra.ts create mode 100644 apps/api/src/router.ts create mode 100644 apps/api/src/routers/admin.ts create mode 100644 apps/api/src/routers/backoffice/auth.ts create mode 100644 apps/api/src/routers/backoffice/clients.ts create mode 100644 apps/api/src/routers/backoffice/dashboard.ts create mode 100644 apps/api/src/routers/backoffice/index.ts create mode 100644 apps/api/src/routers/client.ts create mode 100644 apps/api/src/routers/pro.ts create mode 100644 apps/api/src/routers/public.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/trpc.ts create mode 100644 apps/api/test/auth.test.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/backoffice/index.html create mode 100644 apps/backoffice/package.json create mode 100644 apps/backoffice/src/App.tsx create mode 100644 apps/backoffice/src/components/kit.tsx create mode 100644 apps/backoffice/src/i18n.tsx create mode 100644 apps/backoffice/src/main.tsx create mode 100644 apps/backoffice/src/pages/ClientCard.tsx create mode 100644 apps/backoffice/src/pages/Clients.tsx create mode 100644 apps/backoffice/src/pages/Dashboard.tsx create mode 100644 apps/backoffice/src/pages/Login.tsx create mode 100644 apps/backoffice/src/pages/Soon.tsx create mode 100644 apps/backoffice/src/pages/SubDialog.tsx create mode 100644 apps/backoffice/src/pages/Team.tsx create mode 100644 apps/backoffice/src/styles.css create mode 100644 apps/backoffice/tsconfig.json create mode 100644 apps/backoffice/vite.config.ts create mode 100644 apps/bot-client/package.json create mode 100644 apps/bot-client/src/index.ts create mode 100644 apps/bot-client/tsconfig.json create mode 100644 apps/bot-pro/package.json create mode 100644 apps/bot-pro/src/index.ts create mode 100644 apps/bot-pro/tsconfig.json create mode 100644 apps/landing/app/(redirect)/apartments/page.tsx create mode 100644 apps/landing/app/(redirect)/clean/page.tsx create mode 100644 apps/landing/app/(redirect)/flower/page.tsx create mode 100644 apps/landing/app/(redirect)/land1/page.tsx create mode 100644 apps/landing/app/(redirect)/land2/page.tsx create mode 100644 apps/landing/app/(redirect)/land3/page.tsx create mode 100644 apps/landing/app/(redirect)/land4/page.tsx create mode 100644 apps/landing/app/(redirect)/land5/page.tsx create mode 100644 apps/landing/app/(redirect)/land6/page.tsx create mode 100644 apps/landing/app/(redirect)/layout.tsx create mode 100644 apps/landing/app/(redirect)/live/page.tsx create mode 100644 apps/landing/app/(redirect)/page.tsx create mode 100644 apps/landing/app/[locale]/airport/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/airport/page.tsx create mode 100644 apps/landing/app/[locale]/apartments/page.tsx create mode 100644 apps/landing/app/[locale]/clean/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/clean/page.tsx create mode 100644 apps/landing/app/[locale]/flower/page.tsx create mode 100644 apps/landing/app/[locale]/land0/page.tsx create mode 100644 apps/landing/app/[locale]/land1/page.tsx create mode 100644 apps/landing/app/[locale]/land2/page.tsx create mode 100644 apps/landing/app/[locale]/land3/page.tsx create mode 100644 apps/landing/app/[locale]/land4/page.tsx create mode 100644 apps/landing/app/[locale]/land5/page.tsx create mode 100644 apps/landing/app/[locale]/land6/page.tsx create mode 100644 apps/landing/app/[locale]/layout.tsx create mode 100644 apps/landing/app/[locale]/live/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/live/page.tsx create mode 100644 apps/landing/app/[locale]/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/page.tsx create mode 100644 apps/landing/app/[locale]/pricing/page.tsx create mode 100644 apps/landing/app/[locale]/privacy/page.tsx create mode 100644 apps/landing/app/[locale]/refund/page.tsx create mode 100644 apps/landing/app/[locale]/residence-permit/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/residence-permit/page.tsx create mode 100644 apps/landing/app/[locale]/social-card/opengraph-image.tsx create mode 100644 apps/landing/app/[locale]/social-card/page.tsx create mode 100644 apps/landing/app/[locale]/terms/page.tsx create mode 100644 apps/landing/app/api/flats/photo/[id]/[index]/route.ts create mode 100644 apps/landing/app/api/flats/request/route.ts create mode 100644 apps/landing/app/apple-icon.tsx create mode 100644 apps/landing/app/clean.css create mode 100644 apps/landing/app/favicon.ico create mode 100644 apps/landing/app/flats.css create mode 100644 apps/landing/app/flower.css create mode 100644 apps/landing/app/home.css create mode 100644 apps/landing/app/icon.svg create mode 100644 apps/landing/app/landing.css create mode 100644 apps/landing/app/robots.ts create mode 100644 apps/landing/app/service.css create mode 100644 apps/landing/app/sitemap.ts create mode 100644 apps/landing/app/variants.css create mode 100644 apps/landing/assets/NotoSansArmenian-Bold.ttf create mode 100644 apps/landing/assets/Rubik-ExtraBold.ttf create mode 100644 apps/landing/assets/flowers/l.jpg create mode 100644 apps/landing/assets/flowers/m.jpg create mode 100644 apps/landing/assets/flowers/s.jpg create mode 100644 apps/landing/components/clean.tsx create mode 100644 apps/landing/components/client.tsx create mode 100644 apps/landing/components/flats.tsx create mode 100644 apps/landing/components/flowers.tsx create mode 100644 apps/landing/components/gift-code.tsx create mode 100644 apps/landing/components/home.tsx create mode 100644 apps/landing/components/legal.tsx create mode 100644 apps/landing/components/quiz.tsx create mode 100644 apps/landing/components/rich-text.tsx create mode 100644 apps/landing/components/savings.tsx create mode 100644 apps/landing/components/seo.tsx create mode 100644 apps/landing/components/service-page.tsx create mode 100644 apps/landing/components/variant.tsx create mode 100644 apps/landing/lib/data.ts create mode 100644 apps/landing/lib/flats.ts create mode 100644 apps/landing/lib/og.tsx create mode 100644 apps/landing/lib/redirect.ts create mode 100644 apps/landing/lib/seo.ts create mode 100644 apps/landing/lib/services.ts create mode 100644 apps/landing/next-env.d.ts create mode 100644 apps/landing/next.config.ts create mode 100644 apps/landing/package.json create mode 100644 apps/landing/tsconfig.json create mode 100644 apps/landing/tsconfig.tsbuildinfo create mode 100644 apps/miniapp-client/index.html create mode 100644 apps/miniapp-client/package.json create mode 100644 apps/miniapp-client/src/App.tsx create mode 100644 apps/miniapp-client/src/app.css create mode 100644 apps/miniapp-client/src/lib.tsx create mode 100644 apps/miniapp-client/src/main.tsx create mode 100644 apps/miniapp-client/src/qr.tsx create mode 100644 apps/miniapp-client/src/screens/Consent.tsx create mode 100644 apps/miniapp-client/src/screens/Docs.tsx create mode 100644 apps/miniapp-client/src/screens/Home.tsx create mode 100644 apps/miniapp-client/src/screens/Membership.tsx create mode 100644 apps/miniapp-client/src/screens/Move.tsx create mode 100644 apps/miniapp-client/src/screens/MoveForm.tsx create mode 100644 apps/miniapp-client/src/screens/OrderDetail.tsx create mode 100644 apps/miniapp-client/src/screens/OrderForm.tsx create mode 100644 apps/miniapp-client/src/screens/Orders.tsx create mode 100644 apps/miniapp-client/src/screens/Perks.tsx create mode 100644 apps/miniapp-client/src/screens/Profile.tsx create mode 100644 apps/miniapp-client/src/screens/Segment.tsx create mode 100644 apps/miniapp-client/src/screens/ServiceRequest.tsx create mode 100644 apps/miniapp-client/src/screens/Services.tsx create mode 100644 apps/miniapp-client/tsconfig.json create mode 100644 apps/miniapp-client/vite.config.ts create mode 100644 apps/miniapp-pro/index.html create mode 100644 apps/miniapp-pro/package.json create mode 100644 apps/miniapp-pro/src/App.tsx create mode 100644 apps/miniapp-pro/src/app.css create mode 100644 apps/miniapp-pro/src/auth.ts create mode 100644 apps/miniapp-pro/src/lib.tsx create mode 100644 apps/miniapp-pro/src/main.tsx create mode 100644 apps/miniapp-pro/src/qr.tsx create mode 100644 apps/miniapp-pro/src/screens/Company.tsx create mode 100644 apps/miniapp-pro/src/screens/History.tsx create mode 100644 apps/miniapp-pro/src/screens/Job.tsx create mode 100644 apps/miniapp-pro/src/screens/Partner.tsx create mode 100644 apps/miniapp-pro/src/screens/Today.tsx create mode 100644 apps/miniapp-pro/tsconfig.json create mode 100644 apps/miniapp-pro/vite.config.ts create mode 100644 apps/runtime/index.ts create mode 100644 apps/runtime/package.json create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 docs/backoffice/SPEC.md create mode 100644 docs/design/logos/1-bubble-logo-dark.svg create mode 100644 docs/design/logos/1-bubble-logo.svg create mode 100644 docs/design/logos/1-bubble-symbol.svg create mode 100644 docs/design/logos/2-key-home-logo-dark.svg create mode 100644 docs/design/logos/2-key-home-logo.svg create mode 100644 docs/design/logos/2-key-home-symbol.svg create mode 100644 docs/design/logos/3-apricot-logo-dark.svg create mode 100644 docs/design/logos/3-apricot-logo.svg create mode 100644 docs/design/logos/3-apricot-symbol.svg create mode 100644 docs/design/logos/4-ararat-logo-dark.svg create mode 100644 docs/design/logos/4-ararat-logo.svg create mode 100644 docs/design/logos/4-ararat-symbol.svg create mode 100644 docs/design/logos/5-pomegranate-logo-dark.svg create mode 100644 docs/design/logos/5-pomegranate-logo.svg create mode 100644 docs/design/logos/5-pomegranate-symbol.svg create mode 100644 docs/design/logos/6-monogram-logo-dark.svg create mode 100644 docs/design/logos/6-monogram-logo.svg create mode 100644 docs/design/logos/6-monogram-symbol.svg create mode 100644 docs/design/logos/index.html create mode 100644 docs/design/logos/overview.png create mode 100644 docs/design/wordmarks/index.html create mode 100644 docs/design/wordmarks/png/1-rubik-avatar-640.png create mode 100644 docs/design/wordmarks/png/1-rubik-dark.png create mode 100644 docs/design/wordmarks/png/1-rubik-light.png create mode 100644 docs/design/wordmarks/png/2-fraunces-avatar-640.png create mode 100644 docs/design/wordmarks/png/2-fraunces-dark.png create mode 100644 docs/design/wordmarks/png/2-fraunces-light.png create mode 100644 docs/design/wordmarks/png/3-unbounded-avatar-640.png create mode 100644 docs/design/wordmarks/png/3-unbounded-dark.png create mode 100644 docs/design/wordmarks/png/3-unbounded-light.png create mode 100644 docs/design/wordmarks/png/4-shrikhand-avatar-640.png create mode 100644 docs/design/wordmarks/png/4-shrikhand-dark.png create mode 100644 docs/design/wordmarks/png/4-shrikhand-light.png create mode 100644 docs/design/wordmarks/png/5-big-shoulders-avatar-640.png create mode 100644 docs/design/wordmarks/png/5-big-shoulders-dark.png create mode 100644 docs/design/wordmarks/png/5-big-shoulders-light.png create mode 100644 docs/design/wordmarks/png/6-fredoka-avatar-640.png create mode 100644 docs/design/wordmarks/png/6-fredoka-dark.png create mode 100644 docs/design/wordmarks/png/6-fredoka-light.png create mode 100644 docs/design/wordmarks/render.html create mode 100644 flowers/L.jpg create mode 100644 flowers/M.jpg create mode 100644 flowers/S.jpg create mode 100644 flowers/all.jpg create mode 100644 infra/prod/Dockerfile create mode 100755 infra/prod/deploy.sh create mode 100644 infra/prod/docker-compose.yml create mode 100644 infra/prod/env.example create mode 100644 infra/prod/nginx.conf create mode 100644 infra/prod/seaweed-s3.json.template create mode 100644 packages/core/src/payout.ts create mode 100644 packages/db/prisma/migrations/20261017195908_payout_presets/migration.sql create mode 100644 packages/db/prisma/migrations/20261018205549_web_requests/migration.sql create mode 100644 packages/db/prisma/migrations/20261019104126_quiz_leads/migration.sql create mode 100644 packages/db/prisma/migrations/20261019112425_app_segments_elite/migration.sql create mode 100644 packages/db/prisma/migrations/20261019130000_relocation_templates_admin_tg/migration.sql create mode 100644 packages/db/prisma/migrations/20261019160000_partner_cabinet/migration.sql create mode 100644 packages/db/prisma/migrations/20261020203356_backoffice_stage1/migration.sql create mode 100644 packages/db/src/scripts/create-admin.ts create mode 100644 packages/db/src/scripts/reset-2fa.ts create mode 100644 packages/db/src/scripts/sync-catalog.ts create mode 100644 packages/i18n/locales/en.json create mode 100644 packages/i18n/locales/es.json create mode 100644 packages/i18n/locales/hy.json create mode 100644 packages/i18n/locales/ru.json create mode 100644 packages/i18n/locales/zh.json create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/review.json create mode 100644 packages/i18n/scripts/check.ts create mode 100644 packages/i18n/scripts/dynamic-keys.ts create mode 100644 packages/i18n/scripts/export.ts create mode 100644 packages/i18n/scripts/import.ts create mode 100644 packages/i18n/scripts/lib.ts create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/services/src/clientRequests.ts create mode 100644 packages/services/src/concierge.ts create mode 100644 packages/services/src/env.ts create mode 100644 packages/services/src/leads.ts create mode 100644 packages/services/src/partners.ts create mode 100644 packages/services/src/payouts.ts create mode 100644 packages/services/src/queue.ts create mode 100644 packages/services/src/relocation.ts create mode 100644 packages/services/src/telegram.ts create mode 100644 packages/services/src/webRequests.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.tsx create mode 100644 packages/ui/src/styles.css create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/web/package.json create mode 100644 packages/web/src/dates.ts create mode 100644 packages/web/src/i18n.tsx create mode 100644 packages/web/src/index.ts create mode 100644 packages/web/src/params.ts create mode 100644 packages/web/src/router.tsx create mode 100644 packages/web/src/telegram.ts create mode 100644 packages/web/src/toast.tsx create mode 100644 packages/web/src/trpc.tsx create mode 100644 packages/web/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd68b95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/.next +**/dist +**/.turbo +**/*.tsbuildinfo +.git +.env +.env.* +docs/design +i18n-review +infra/prod/.env* diff --git a/.env.example b/.env.example index 44a2c2e..6f0401e 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,8 @@ SENTRY_DSN= # В production не используется. DEV_CLIENT_BOT_TOKEN=000000:dev-client-token DEV_PRO_BOT_TOKEN=000000:dev-pro-token + +# Страница /ru/apartments: квартиры AREV из CRM (crm.yandp.tech) и заявки на просмотр. +# Ключ — companies.settings.barevClub.key организации AREV в CRM. Пусто — страница показывает «список недоступен». +CRM_URL=https://crm.yandp.tech +CRM_BAREV_KEY= diff --git a/.gitignore b/.gitignore index b55b9d0..3919003 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ coverage !.env.example .DS_Store packages/db/src/generated + +# личные документы в корне (выписки, паспорта) — не коммитим +/*.pdf + +# рабочие копии веток Claude Code +.claude/worktrees diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..8028955 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,16 @@ + + + + + + + Barev Club · Admin + + + + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..9827f69 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "@barev/admin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5173 --strictPort", + "build": "vite build", + "preview": "vite preview --port 5173", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@barev/core": "workspace:*", + "@barev/i18n": "workspace:*", + "@barev/ui": "workspace:*", + "@barev/web": "workspace:*", + "@tanstack/react-query": "^5.104.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@barev/api": "workspace:*", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0", + "@types/qrcode": "^1.5.5" + } +} diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..e4cc80f --- /dev/null +++ b/apps/admin/src/App.tsx @@ -0,0 +1,147 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { LOCALE_LABELS, LOCALES, type Locale } from '@barev/i18n'; +import { Button, Center, Chip, Icon, Logo, Spinner, type IconName } from '@barev/ui'; +import { I18nProvider, pickLocale, useI18n, useLocation, useTRPC } from '@barev/web'; +import { useState, type ReactNode } from 'react'; +import { Login } from './pages/Login.tsx'; +import { Kanban } from './pages/Kanban.tsx'; +import { Providers } from './pages/Providers.tsx'; +import { Presets } from './pages/Presets.tsx'; +import { Companies } from './pages/Companies.tsx'; +import { Services } from './pages/Services.tsx'; +import { Agencies } from './pages/Agencies.tsx'; +import { Clients } from './pages/Clients.tsx'; +import { Settings } from './pages/Settings.tsx'; +import { WebRequests } from './pages/WebRequests.tsx'; +import { Leads } from './pages/Leads.tsx'; +import { Requests } from './pages/Requests.tsx'; +import { Concierge } from './pages/Concierge.tsx'; +import { Partners } from './pages/Partners.tsx'; +import { Templates } from './pages/Templates.tsx'; +import { Telegram } from './pages/Telegram.tsx'; + +const LOCALE_KEY = 'barev.admin.locale'; +function savedLocale(): Locale { + try { + return pickLocale(localStorage.getItem(LOCALE_KEY), navigator.language); + } catch { + return 'ru'; + } +} + +export function App() { + const [locale] = useState(savedLocale); + return ( + + + + ); +} + +function Gate() { + const trpc = useTRPC(); + const me = useQuery({ ...trpc.admin.auth.me.queryOptions(), retry: false }); + if (me.isPending) + return ( +
+ +
+ ); + if (!me.data || !me.data.verified) return ; + return ; +} + +const NAV: { path: string; key: string; icon: IconName; owner?: boolean }[] = [ + { path: '/', key: 'admin.nav.orders', icon: 'grid' }, + { path: '/leads', key: 'admin.nav.leads', icon: 'globe' }, + { path: '/templates', key: 'admin.nav.templates', icon: 'docs' }, + { path: '/concierge', key: 'admin.nav.concierge', icon: 'chat' }, + { path: '/app-requests', key: 'admin.nav.requests', icon: 'star' }, + { path: '/requests', key: 'admin.nav.webRequests', icon: 'chat' }, + { path: '/clients', key: 'admin.nav.clients', icon: 'user' }, + { path: '/providers', key: 'admin.nav.providers', icon: 'star' }, + { path: '/presets', key: 'admin.nav.presets', icon: 'repeat' }, + { path: '/companies', key: 'admin.nav.companies', icon: 'home' }, + { path: '/services', key: 'admin.nav.services', icon: 'docs' }, + { path: '/agencies', key: 'admin.nav.agencies', icon: 'gift' }, + { path: '/partners', key: 'admin.nav.partners', icon: 'gift' }, + { path: '/telegram', key: 'admin.nav.telegram', icon: 'chat' }, + { path: '/settings', key: 'admin.nav.settings', icon: 'clock', owner: true }, +]; + +function Shell({ role, name }: { role: 'OWNER' | 'OPERATOR'; name: string }) { + const { t, locale, setLocale } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const { path } = useLocation(); + const logout = useMutation(trpc.admin.auth.logout.mutationOptions({ onSuccess: () => qc.invalidateQueries() })); + const isOwner = role === 'OWNER'; + // новые заявки с сайта — счётчик в меню, обновляется сам + const openRequests = useQuery({ ...trpc.admin.webRequests.openCount.queryOptions(), refetchInterval: 30_000 }); + const openLeads = useQuery({ ...trpc.admin.leads.openCount.queryOptions(), refetchInterval: 30_000 }); + const openAppRequests = useQuery({ ...trpc.admin.requests.openCount.queryOptions(), refetchInterval: 30_000 }); + const unreadChats = useQuery({ ...trpc.admin.concierge.unreadCount.queryOptions(), refetchInterval: 15_000 }); + const counts: Record = { '/requests': openRequests.data, '/leads': openLeads.data, '/app-requests': openAppRequests.data, '/concierge': unreadChats.data }; + + let page: ReactNode; + if (path === '/leads') page = ; + else if (path === '/concierge') page = ; + else if (path === '/app-requests') page = ; + else if (path === '/partners') page = ; + else if (path === '/templates') page = ; + else if (path === '/telegram') page = ; + else if (path === '/requests') page = ; + else if (path === '/clients') page = ; + else if (path === '/providers') page = ; + else if (path === '/presets') page = ; + else if (path === '/companies') page = ; + else if (path === '/services') page = ; + else if (path === '/agencies') page = ; + else if (path === '/settings' && isOwner) page = ; + else page = ; + + return ( +
+ +
{page}
+
+ ); +} diff --git a/apps/admin/src/admin.css b/apps/admin/src/admin.css new file mode 100644 index 0000000..0b51c80 --- /dev/null +++ b/apps/admin/src/admin.css @@ -0,0 +1,374 @@ +body { + font-size: 14px; +} +.layout { + display: grid; + grid-template-columns: 232px 1fr; + min-height: 100vh; +} +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 20px 14px; + display: flex; + flex-direction: column; + gap: 4px; + background: var(--bc-surface); + border-right: 1px solid var(--bc-line); +} +.sidebar a { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 14px; + border-radius: 999px; + text-decoration: none; + font-weight: 500; +} +.sidebar a[aria-current='page'] { + background: var(--bc-dark); + color: #fff; +} +/* счётчик новых заявок с сайта */ +.nav-count { + margin-left: auto; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + background: var(--bc-accent); + color: #fff; + font-size: 12px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} +.main { + padding: 24px 28px 48px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.toolbar .bc-input { + width: auto; + min-height: 44px; + padding: 8px 14px; +} +.summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} +.summary .bc-card { + padding: 16px 18px; +} +.summary__value { + font-family: var(--bc-font-head); + font-weight: 800; + font-size: 28px; +} +.kanban { + display: grid; + grid-template-columns: repeat(var(--cols, 4), minmax(240px, 1fr)); + gap: 12px; + align-items: start; + overflow-x: auto; +} +.column { + background: #f3ece1; + border-radius: 24px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 200px; +} +.column__head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 6px 6px; + font-family: var(--bc-font-head); + font-weight: 700; + font-size: 16px; +} +.ocard { + text-align: left; + border: 2px solid transparent; + background: var(--bc-surface); + border-radius: 18px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + width: 100%; +} +.ocard:hover { + border-color: var(--bc-ink); +} +.ocard__stripe { + height: 6px; + width: 36px; + border-radius: 999px; +} +.drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(30, 27, 26, 0.35); + z-index: 60; + display: flex; + justify-content: flex-end; +} +.drawer { + width: min(680px, 100%); + height: 100%; + overflow: auto; + background: var(--bc-bg); + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; +} +table.grid { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--bc-surface); + border-radius: 20px; + overflow: hidden; +} +table.grid th, +table.grid td { + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--bc-line); + vertical-align: top; +} +table.grid th { + font-size: 12px; + font-weight: 600; + color: var(--bc-ink-2); + background: #f8f3eb; +} +table.grid tr:last-child td { + border-bottom: none; +} +table.grid tr.clickable { + cursor: pointer; +} +table.grid tr.clickable:hover td { + background: #fcfaf6; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.form-grid .full { + grid-column: 1 / -1; +} +.schedule { + display: grid; + grid-template-columns: 70px 1fr 1fr 60px; + gap: 6px; + align-items: center; +} +.schedule .bc-input { + min-height: 40px; + padding: 6px 10px; +} +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} +.candidate { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 16px; + background: var(--bc-surface); +} +.history-item { + display: grid; + grid-template-columns: 130px 1fr; + gap: 10px; + font-size: 13px; + padding: 6px 0; + border-bottom: 1px dashed var(--bc-line); +} +.auth-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.auth-card { + width: min(420px, 100%); +} +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + } + .main { + padding: 16px; + } + .form-grid { + grid-template-columns: 1fr; + } +} + +/* шаги плана переезда в карточке заявки */ +.lead-steps { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 8px; +} +.lead-step { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border-radius: 14px; + background: var(--bc-surface); +} + +/* чат консьержа */ +.chat { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 12px; + min-height: 70vh; +} +.chat__list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} +.chat__item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 12px; + border: 1.5px solid transparent; + border-radius: 16px; + background: var(--bc-surface); + font: inherit; + text-align: left; + cursor: pointer; +} +.chat__item[aria-current='true'] { + border-color: var(--bc-plum); +} +.chat__preview { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.chat__body { + display: flex; + flex-direction: column; + border-radius: 20px; + background: var(--bc-surface); + overflow: hidden; +} +.chat__head { + display: flex; + gap: 12px; + align-items: baseline; + padding: 14px 18px; + border-bottom: 1px solid var(--bc-line); +} +.chat__messages { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + overflow-y: auto; + max-height: 60vh; + background: var(--bc-bg); +} +.msg { + max-width: 70%; + padding: 10px 14px; + border-radius: 18px 18px 18px 6px; + background: var(--bc-surface); + white-space: pre-wrap; +} +.msg span { + display: block; + margin-top: 4px; + opacity: 0.6; +} +.msg[data-author='OPERATOR'] { + align-self: flex-end; + border-radius: 18px 18px 6px 18px; + background: var(--bc-plum); + color: #fff; +} +.chat__form { + display: flex; + gap: 8px; + align-items: flex-end; + padding: 12px; + border-top: 1px solid var(--bc-line); +} +.chat__form textarea { + flex: 1; + min-height: 56px; +} +.lead-plan { + display: flex; + flex-direction: column; + gap: 8px; +} +.lead-docs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 4px 12px; +} +.lead-doc { + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; +} +.lead-title { + flex: 1; + font-weight: 600; + cursor: text; +} +.lead-x { + border: 0; + background: none; + font-size: 20px; + line-height: 1; + color: var(--bc-ink-3); + cursor: pointer; +} +.lead-x:hover { + color: var(--bc-danger); +} diff --git a/apps/admin/src/components/OrderDrawer.tsx b/apps/admin/src/components/OrderDrawer.tsx new file mode 100644 index 0000000..4e8fdba --- /dev/null +++ b/apps/admin/src/components/OrderDrawer.tsx @@ -0,0 +1,282 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Badge, Button, Card, Field, Icon, Spinner } from '@barev/ui'; +import { confirmDialog, errorText, paramsExtras, paramsSummary, useI18n, useToast, useTRPC } from '@barev/web'; +import { useEffect, useState } from 'react'; + +/** ADM-3, ADM-4: карточка заказа и назначение исполнителя */ +export function OrderDrawer({ id, onClose }: { id: string; onClose: () => void }) { + const { t, amd, dt, locale } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const order = useQuery({ ...trpc.admin.orders.get.queryOptions({ id }), refetchInterval: 10_000 }); + const methods = useQuery(trpc.admin.dictionaries.paymentMethods.queryOptions()); + const [comment, setComment] = useState(''); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const done = (msg: string) => () => { + toast(msg); + void qc.invalidateQueries(); + }; + const fail = (e: unknown) => toast(errorText(t, e), 'error'); + const transition = useMutation(trpc.admin.orders.transition.mutationOptions({ onSuccess: done(t('admin.order.statusChanged')), onError: fail })); + const cancel = useMutation(trpc.admin.orders.cancel.mutationOptions({ onSuccess: done(t('admin.order.cancelled')), onError: fail })); + const unassign = useMutation(trpc.admin.orders.unassign.mutationOptions({ onSuccess: done(t('admin.order.unassigned')), onError: fail })); + const markPaid = useMutation(trpc.admin.orders.markPaid.mutationOptions({ onSuccess: done(t('admin.order.paid')), onError: fail })); + + const o = order.data; + const pendingOffer = o?.offers.find((f) => f.status === 'OFFERED'); + const canAssign = o && !o.providerId && !pendingOffer && o.status === o.flowSteps[0]; + const isFinal = o && !o.canCancel; + + return ( +
+ +
+ ); +} + +function Candidates({ orderId }: { orderId: string }) { + const { t } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const cands = useQuery(trpc.admin.orders.candidates.queryOptions({ id: orderId })); + const [showAll, setShowAll] = useState(false); + const assign = useMutation( + trpc.admin.orders.assign.mutationOptions({ + onSuccess: () => { + toast(t('admin.order.offered')); + void qc.invalidateQueries(); + }, + onError: (e) => toast(errorText(t, e), 'error'), + }), + ); + if (!cands.data) return ; + const [best, ...rest] = cands.data.ranked; + return ( + +

{t('admin.order.assignTitle')}

+ {best ? ( + + ) : ( +

{t('admin.order.noCandidates')}

+ )} + {best && } + {rest.length > 0 && ( + + )} + {showAll && + rest.map((c) => ( +
+
+
+ {c.name} + {c.kind === 'company' ? ` · ${t('admin.order.company')}` : ''} +
+
+ {c.languages.map((l) => l.toUpperCase()).join(' ')} · {t('admin.order.load', { count: c.load })} + {c.rating ? ` · ★ ${c.rating}` : ''} +
+ +
+ +
+ ))} + {cands.data.busy.length > 0 && ( +
+ {t('admin.order.busy')}: {cands.data.busy.map((b) => b.name).join(', ')} +
+ )} +
+ ); +} + +function Reasons({ reasons }: { reasons: string[] }) { + const { t } = useI18n(); + if (!reasons.length) return null; + return ( +
+ {reasons.map((r) => ( + + {t(`admin.reasons.${r}`)} + + ))} +
+ ); +} + +function FileLink({ fileId }: { fileId: string }) { + const { t } = useI18n(); + const trpc = useTRPC(); + const [enabled, setEnabled] = useState(false); + const url = useQuery({ ...trpc.admin.orders.fileUrl.queryOptions({ fileId }), enabled }); + useEffect(() => { + if (url.data) window.open(url.data.url, '_blank', 'noopener'); + }, [url.data]); + return ( + + ); +} diff --git a/apps/admin/src/components/form.tsx b/apps/admin/src/components/form.tsx new file mode 100644 index 0000000..5be2163 --- /dev/null +++ b/apps/admin/src/components/form.tsx @@ -0,0 +1,87 @@ +import { LOCALES } from '@barev/i18n'; +import { Chip } from '@barev/ui'; +import { useI18n } from '@barev/web'; +import { useEffect, useState, type ReactNode } from 'react'; + +/** Мультивыбор чипами (языки, районы, роли) */ +export function MultiChips({ value, options, onChange, label }: { value: T[]; options: { value: T; label: ReactNode }[]; onChange: (v: T[]) => void; label?: string }) { + return ( +
+ {options.map((o) => { + const on = value.includes(o.value); + return ( + onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])} style={{ minHeight: 36, padding: '4px 12px' }}> + {o.label} + + ); + })} +
+ ); +} + +export const LOCALE_OPTIONS = LOCALES.map((l) => ({ value: l, label: l.toUpperCase() })); + +export type ScheduleRow = { weekday: number; startMinute: number; endMinute: number }; + +const toHm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; +const fromHm = (s: string) => { + const [h, m] = s.split(':').map(Number); + return (h ?? 0) * 60 + (m ?? 0); +}; + +/** Недельный график исполнителя */ +export function ScheduleEditor({ value, onChange }: { value: ScheduleRow[]; onChange: (v: ScheduleRow[]) => void }) { + const { t } = useI18n(); + return ( +
+ {[0, 1, 2, 3, 4, 5, 6].map((wd) => { + const row = value.find((r) => r.weekday === wd); + const update = (patch: Partial) => onChange(value.map((r) => (r.weekday === wd ? { ...r, ...patch } : r))); + return ( +
+ {t(`admin.weekdays.${wd}`)} + update({ startMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.from')} /> + update({ endMinute: fromHm(e.target.value) })} aria-label={t('admin.schedule.to')} /> + onChange(e.target.checked ? [...value, { weekday: wd, startMinute: 540, endMinute: 1200 }] : value.filter((r) => r.weekday !== wd))} + style={{ width: 22, height: 22 }} + /> +
+ ); + })} +
+ ); +} + +/** Локальная копия значения, сбрасывается при смене исходного */ +export function useDraft(source: T | undefined) { + const [draft, setDraft] = useState(source); + useEffect(() => setDraft(source), [source]); + return [draft, setDraft] as const; +} + +export function Copyable({ text }: { text: string }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + return ( + + + {text} + + + + ); +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..261a793 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,16 @@ +import '@barev/ui/styles.css'; +import './admin.css'; +import { ApiProvider, ToastProvider } from '@barev/web'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/apps/admin/src/pages/Agencies.tsx b/apps/admin/src/pages/Agencies.tsx new file mode 100644 index 0000000..b3a6e0a --- /dev/null +++ b/apps/admin/src/pages/Agencies.tsx @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button, Card, Field } from '@barev/ui'; +import { errorText, useI18n, useToast, useTRPC } from '@barev/web'; +import { useState } from 'react'; +import { Copyable } from '../components/form.tsx'; + +interface Draft { + id?: string; + code: string; + name: string; + commissionPct: number; + contact: string; + active: boolean; +} + +/** ADM-5: агентства — код для deep-link/QR и % вознаграждения. Полный отчёт ADM-8 — Этап 2. */ +export function Agencies({ isOwner }: { isOwner: boolean }) { + const { t } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const list = useQuery(trpc.admin.agencies.list.queryOptions()); + const config = useQuery(trpc.admin.dictionaries.config.queryOptions()); + const settings = useQuery(trpc.admin.settings.get.queryOptions()); + const [d, setD] = useState(null); + const onDone = () => { + toast(t('admin.saved')); + void qc.invalidateQueries(); + setD(null); + }; + const create = useMutation(trpc.admin.agencies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') })); + const update = useMutation(trpc.admin.agencies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') })); + + return ( + <> +
+

{t('admin.nav.agencies')}

+ {isOwner && ( + + )} +
+

+ {t('admin.agencies.hint', { months: settings.data?.['agency.attributionMonths'] ?? 12 })} +

+ {d && ( + +
+ + setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '') })} maxLength={32} /> + + + setD({ ...d, name: e.target.value })} /> + + + setD({ ...d, commissionPct: Number(e.target.value) })} /> + + + setD({ ...d, contact: e.target.value })} /> + + +
+
+ + +
+
+ )} + + + + + + + + + + + + {list.data?.map((a) => ( + isOwner && setD({ id: a.id, code: a.code, name: a.name, commissionPct: a.commissionPct, contact: a.contact ?? '', active: a.active })}> + + + + + + + ))} + +
{t('admin.agencies.name')}{t('admin.agencies.code')}{t('admin.agencies.commission')}{t('admin.agencies.leads')}{t('admin.agencies.links')}
+ {a.name} + {a.contact &&
{a.contact}
} +
{a.code}{a.commissionPct}%{a.leads} e.stopPropagation()} className="bc-stack" style={{ gap: 4 }}> + {config.data && ( + <> + {t('admin.agencies.bot')} + + {t('admin.agencies.landing')} + + + )} +
+ + ); +} diff --git a/apps/admin/src/pages/Clients.tsx b/apps/admin/src/pages/Clients.tsx new file mode 100644 index 0000000..d0c9b98 --- /dev/null +++ b/apps/admin/src/pages/Clients.tsx @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Badge, Button } from '@barev/ui'; +import { confirmDialog, errorText, useI18n, useToast, useTRPC } from '@barev/web'; +import { useState } from 'react'; + +/** ADM-6 (базово): клиенты, источник, статус Barev+ */ +export function Clients({ isOwner }: { isOwner: boolean }) { + const { t, dt } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [search, setSearch] = useState(''); + const list = useQuery(trpc.admin.clients.list.queryOptions({ search: search.trim() || undefined })); + const grant = useMutation( + trpc.admin.clients.grantBarevPlus.mutationOptions({ + onSuccess: () => { + toast(t('admin.saved')); + void qc.invalidateQueries(); + }, + onError: (e) => toast(errorText(t, e), 'error'), + }), + ); + return ( + <> +
+

{t('admin.nav.clients')}

+ setSearch(e.target.value)} style={{ width: 280 }} /> +
+ + + + + + + + + + {isOwner && + + + {list.data?.map((c) => ( + + + + + + + + {isOwner && ( + + )} + + ))} + +
{t('admin.clients.name')}{t('admin.clients.language')}{t('admin.clients.source')}{t('admin.clients.membership')}{t('admin.clients.orders')}{t('admin.clients.since')}} +
+ + {c.user.firstName} {c.user.lastName ?? ''} + + {c.user.username &&
@{c.user.username}
} + + {t('admin.concierge.write')} + + {!c.consented && {t('admin.clients.noConsent')}} +
{c.user.locale.toUpperCase()} + {c.agency ? `${c.agency.name} (${c.agency.code})` : t(`admin.clients.source_${c.source}`)} + {c.utm &&
{Object.entries(c.utm as Record).map(([k, v]) => `${k.replace('utm_', '')}=${v}`).join(' ')}
} +
+ {c.barevPlusUntil ? ( + + {c.plan === 'ELITE' ? 'Elite' : 'Barev+'} · {dt(c.barevPlusUntil, { day: 'numeric', month: 'short' })} + + ) : ( + 'Barev' + )} + {c.segment &&
{t(`admin.clients.segment_${c.segment}`)}
} +
{c.orders}{dt(c.createdAt, { dateStyle: 'medium' })} +
+ + +
+
+ + ); +} diff --git a/apps/admin/src/pages/Companies.tsx b/apps/admin/src/pages/Companies.tsx new file mode 100644 index 0000000..ec00a22 --- /dev/null +++ b/apps/admin/src/pages/Companies.tsx @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PROVIDER_ROLES, type ProviderRole } from '@barev/core'; +import { Button, Card, Field } from '@barev/ui'; +import { errorText, useI18n, useToast, useTRPC } from '@barev/web'; +import { useState } from 'react'; +import { Copyable, MultiChips } from '../components/form.tsx'; + +interface Draft { + id?: string; + name: string; + roles: ProviderRole[]; + rateType: 'FIXED' | 'PERCENT'; + rateValue: number; + contactPhone: string; + active: boolean; +} + +/** PRO-7, PRO-8: компании-партнёры, их диспетчеры и договорная ставка */ +export function Companies() { + const { t } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const list = useQuery(trpc.admin.companies.list.queryOptions()); + const config = useQuery(trpc.admin.dictionaries.config.queryOptions()); + const [d, setD] = useState(null); + const onDone = () => { + toast(t('admin.saved')); + void qc.invalidateQueries(); + setD(null); + }; + const create = useMutation(trpc.admin.companies.create.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') })); + const update = useMutation(trpc.admin.companies.update.mutationOptions({ onSuccess: onDone, onError: (e) => toast(errorText(t, e), 'error') })); + const removeDispatcher = useMutation(trpc.admin.companies.removeDispatcher.mutationOptions({ onSuccess: () => qc.invalidateQueries() })); + + return ( + <> +
+

{t('admin.nav.companies')}

+ +
+ {d && ( + +
+ + setD({ ...d, name: e.target.value })} /> + + + setD({ ...d, contactPhone: e.target.value })} /> + +
+ + ({ value: r, label: t(`roles.${r}`) }))} onChange={(roles) => setD({ ...d, roles })} /> + +
+ + + + + setD({ ...d, rateValue: Number(e.target.value) })} /> + + +
+
+ + +
+
+ )} + {list.data?.map((c) => ( + +
+
+

{c.name}

+
+ {c.roles.map((r) => t(`roles.${r}`)).join(', ')} · {c.rateType === 'PERCENT' ? `${c.rateValue}%` : `${c.rateValue} ֏`} +
+
+ +
+
+ {t('admin.companies.employees')}: {c.employees.map((e) => e.name).join(', ') || '—'} +
+
+ {t('admin.companies.dispatchers')}:{' '} + {c.dispatchers.length + ? c.dispatchers.map((x) => ( + + {x.user.firstName ?? x.user.username} + + + )) + : '—'} +
+ {config.data && ( +
+ {t('admin.companies.dispatcherInvite')}: +
+ )} +
+ ))} + + ); +} diff --git a/apps/admin/src/pages/Concierge.tsx b/apps/admin/src/pages/Concierge.tsx new file mode 100644 index 0000000..2c94923 --- /dev/null +++ b/apps/admin/src/pages/Concierge.tsx @@ -0,0 +1,145 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button, Chip } from '@barev/ui'; +import { errorText, navigate, useI18n, useLocation, useToast, useTRPC } from '@barev/web'; +import { useEffect, useRef, useState } from 'react'; + +type Peer = { clientId: string } | { providerId: string }; +const keyOf = (p: Peer) => ('clientId' in p ? `c:${p.clientId}` : `p:${p.providerId}`); + +/** + * Переписка: клиенты пишут клиентскому боту, исполнители — рабочему; оператор отвечает здесь, + * ответ приходит в тот же чат с ботом. Открыть диалог с кем угодно: #/concierge?client=… или ?provider=… + */ +export function Concierge() { + const { t, dt } = useI18n(); + const trpc = useTRPC(); + const { query } = useLocation(); + const list = useQuery({ ...trpc.admin.concierge.list.queryOptions(), refetchInterval: 10_000 }); + const [tab, setTab] = useState<'all' | 'clients' | 'providers'>('all'); + const fromUrl: Peer | null = query.get('client') ? { clientId: query.get('client')! } : query.get('provider') ? { providerId: query.get('provider')! } : null; + const items = (list.data ?? []).filter((c) => (tab === 'all' ? true : tab === 'clients' ? !!c.clientId : !!c.providerId)); + const first = items[0]; + const selected: Peer | null = fromUrl ?? (first ? (first.clientId ? { clientId: first.clientId } : { providerId: first.providerId! }) : null); + + return ( + <> +
+

{t('admin.nav.concierge')}

+
+ {(['all', 'clients', 'providers'] as const).map((x) => ( + setTab(x)}> + {t(`admin.concierge.tab_${x}`)} + + ))} +
+
+ {!selected ? ( +

{t('admin.concierge.empty')}

+ ) : ( +
+
    + {items.map((c) => ( +
  • + +
  • + ))} +
+ +
+ )} + + ); +} + +function Conversation({ peer }: { peer: Peer }) { + const { t, dt } = useI18n(); + const trpc = useTRPC(); + const qc = useQueryClient(); + const toast = useToast(); + const [text, setText] = useState(''); + const end = useRef(null); + const data = useQuery({ ...trpc.admin.concierge.get.queryOptions(peer), refetchInterval: 10_000 }); + const reply = useMutation( + trpc.admin.concierge.reply.mutationOptions({ + onSuccess: () => { + setText(''); + void qc.invalidateQueries(); + }, + onError: (e) => toast(errorText(t, e), 'error'), + }), + ); + const send = () => text.trim() && reply.mutate({ ...peer, text: text.trim() }); + useEffect(() => { + end.current?.scrollIntoView({ block: 'end' }); + }, [data.data?.messages.length]); + // открыли диалог — счётчики непрочитанных обновятся + useEffect(() => { + if (data.data) void qc.invalidateQueries({ queryKey: trpc.admin.concierge.list.queryKey() }); + }, [data.data, qc, trpc]); + const p = data.data?.peer; + + return ( +
+
+ {p?.name} + {p?.username && ( + + @{p.username} + + )} + {p && {t('admin.concierge.replyLang', { lang: p.locale.toUpperCase() })}} +
+
+ {data.data?.messages.length === 0 &&

{t('admin.concierge.startHint')}

} + {data.data?.messages.map((m) => ( +
+
{m.text}
+ {dt(m.createdAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} +
+ ))} +
+
+ {p && !p.reachable ? ( +

+ {t('errors.PROVIDER_NOT_LINKED')} +

+ ) : ( +
{ + e.preventDefault(); + send(); + }} + > +