diff --git a/docs/mobile-production/README.md b/docs/mobile-production/README.md new file mode 100644 index 000000000..b685dee54 --- /dev/null +++ b/docs/mobile-production/README.md @@ -0,0 +1,131 @@ + + +# Мобильный frontend-релиз без изменения desktop + +Дата проверки: 10.10.2026. Target: `master`, ветка `release/prod-mobile-responsive-20261010`. + +## Границы релиза + +База — текущий production `11bec95e8dc3bc48d9d4fe491c9b6a0c18968850`. +Его успешный [Deploy to server](https://github.com/PROCOLLAB-github/procollab/actions/runs/37224199351) +проверен перед подготовкой релиза. Адаптив и его исправления перенесены из dev до +`f3df1c8808c8f2fe352eaac70f28c34da0eae828` (PR #401, #403, #405, #406). +Другие dev-функции не включены: это отдельная ветка от production, а не merge всего dev. + +Desktop сохраняет **текущую production-композицию**, включая production-центр backend-уведомлений. +Это отличается от восстановления прежнего dev в #403: production и dev уже содержали разные изменения. +Для `>=1000px` сохранены исходные шаблоны master; адаптивные шаблоны включаются через +`DesktopLayoutService`. Новые геометрия, типографика и tokens ограничены `max-width: 999px`. +У компонентов с `ng-content` сохранён единственный projection slot; CSS-классы мобильного поля даты +действуют только ниже 1000px. Shared Button сохраняет desktop-геометрию и прежнее поведение loading. +На основной странице программы оставлен единый исходный шаблон: это сохраняет timing view queries +и расчёт раскрытия описания; role widget добавляется только для мобильного режима. +Состояние проекта на desktop по-прежнему называется «В программе». + +В релиз входят Office/navigation, Feed/FeedFilter, проекты/InfoCard, участники, программы, +детали проекта/профиля/программы, вакансии, курсы, редакторы, onboarding и shared UI. +Мобильная шапка светлая, название раздела по центру. Сохранены исправления приглашений, кнопок, +подсказок, длинных навыков, полей даты и строк аналитики из mobile smoke-test. + +## API, права и поведение + +Endpoints, payload обязательных полей, роли, permissions, роуты и доменные правила не меняются. +Добавлено только необязательное имя связанной программы в модели/DTO. Production notification center +сохранён, его запросы и обработка не заменены старой реализацией из dev. + +Изменения frontend-поведения явно включены: ожидание загрузки профиля при прямом переходе в проекты +и onboarding, защита от цикла обновления формы, продолжение WS reconnect и его teardown. +WS-сбой скрытых чатов не показывает два глобальных сообщения о потере интернета; HTTP-ошибки +отдельных операций сохраняют прежнюю обработку. Эти исправления не меняют бизнес-правила. + +## Выполненные проверки + +- `npm run build:prod`: exit 0, исходные CSS budgets не повышались. +- `npm run test:ci`: 413 файлов, **2099 тестов**, exit 0. +- После последней правки mobile-классов поля даты: InputComponent, **20 тестов**, exit 0. +- После сохранения единого шаблона программы: MainComponent, **2 теста**, exit 0. +- `npm run lint:ts`: exit 0, 0 errors; 6 прежних warnings об unused eslint-disable. +- Stylelint для 135 изменённых SCSS: exit 0, без отключения правил в конфигурации. +- `npm run format:check`: проверка полного Git index с LF, как на Linux CI. +- Desktop pixel regression: **66/66**, 0 отличающихся RGB-пикселей, включая **1000/1440/1920px**. + Машинный отчёт: [desktop-comparison.json](./desktop-comparison.json). +- Mobile: **320/390/768px**, 16 страниц и 6 дополнительных состояний на каждой ширине; + проверены viewport overflow, центрирование приглашений, интервалы между кнопками, ширина даты, + неперекрывающиеся подсказки, одинаковая типографика полей, единственная рамка поиска, + footer диалога, скрытие пустых контактов и фон строк «Требует внимания». +- WS: два последовательных сбоя и восстановление, HTTP доступен; глобальных WS-toast нет, + reconnect продолжается, онлайн-статусы возвращаются. Проверка с production retry interval 5000ms. + +Desktop gate сравнивает 22 состояния: Feed; проекты; Members; Programs; программа; профиль; +проект; Vacancies; вакансия; Courses; курс; редакторы профиля и проекта; partners/resources; +analytics; диалог приглашения; список приглашений; поля, цели и добавленная цель проекта; +строки attention; выбранный участник приглашения. + +Сравнение сделано в одном Chrome/Windows на одинаковых синтетических данных, времени и viewport. +Сборки доставляются **без изменения CSS/JS**. Нет screenshot masks, tolerance или исключённых областей. +Перед снимками ожидаются загрузка шрифтов/изображений, данные страницы и два rendering frames. +Описание программы заведомо длинное: проверяется раскрытие длинного текста без пограничного +сравнения высоты строки при загрузке шрифта. Проверка программы на 1000px повторена три раза для каждой сборки с проверкой идентичности повторных PNG. Некорректный внешний +avatar-placeholder исключён корректным локальным изображением в обоих fixture-наборах; +runtime-код по этому поводу не изменён. Animations/caret отключены одинаково в обеих сборках. + +## Примеры снимков + +[Feed, 390px](./screenshots/feed-390.png) · [Приглашения](./screenshots/projects-invites-390.png) · +[Поля редактора](./screenshots/project-edit-fields-390.png) · [Диалог](./screenshots/team-invite-selected-390.png) · +[Программа](./screenshots/program-390.png) · [Attention](./screenshots/analytics-attention-390.png). + +Программа на desktop 1440px: [production baseline](./screenshots/program-1440-baseline.png) и +[release](./screenshots/program-1440-verified.png), 0 изменённых пикселей. + +## Воспроизведение browser gate + +Нужны Chrome, Playwright и Python с Pillow/NumPy. API и WebSocket перехватываются harness; +реальные запросы и отправка приглашений не выполняются. `QA_BUILD_ROOT` указывает на production build. +`QA_SCREENS=program` позволяет отдельно повторить одну страницу, но полный gate требует все 22 состояния. + +Сначала соберите baseline из указанного master SHA в отдельном checkout через `npm ci` и +`npm run build:prod`, затем текущую ветку. Скопируйте baseline dist вне текущего dist. +Пример PowerShell (пути к Playwright/Chrome/Python задаются локально): + +```powershell +$env:PLAYWRIGHT_MODULE = 'C:/path/to/node_modules/playwright' +$env:CHROME_PATH = 'C:/Program Files/Google/Chrome/Application/chrome.exe' +$env:QA_BUILD_ROOT = 'C:/path/to/baseline-dist' +node docs/mobile-production/visual.cjs baseline http://127.0.0.1:4461 '1000,1440,1920' .qa-mobile/screenshots +node docs/mobile-production/visual.cjs baseline http://127.0.0.1:4461 '1000,1440,1920' .qa-mobile/screenshots details + +$env:QA_BUILD_ROOT = 'dist/social_platform' +node docs/mobile-production/visual.cjs verified http://127.0.0.1:4462 '320,390,768,1000,1440,1920' .qa-mobile/screenshots +node docs/mobile-production/visual.cjs verified http://127.0.0.1:4462 '320,390,768,1000,1440,1920' .qa-mobile/screenshots details +python docs/mobile-production/compare.py .qa-mobile/screenshots/baseline .qa-mobile/screenshots/verified docs/mobile-production/desktop-comparison.json +node docs/mobile-production/connection.cjs http://127.0.0.1:4462 .qa-mobile/connection +``` + +HTTP-сервер для этих команд не требуется: `preview.cjs` читает build bytes напрямую. +Сохраняйте exit status каждой команды. Недостающий PNG или хотя бы один изменённый пиксель завершает +`compare.py` с ошибкой. Не используйте baseline из dev вместо указанного production SHA. + +## Acceptance criteria + +- [x] Релиз изолирован от прочих изменений dev и основан на текущем prod. +- [x] Desktop >=1000px сохраняет внешний вид: 66 точных pixel comparisons. +- [x] Mobile/tablet 320/390/768px сохраняет адаптив и исправления из smoke-test. +- [x] Production notification center, API contracts и permissions сохранены. +- [x] Tests, lint, stylelint, format и production build проходят. + +## Ограничения и проверка после выкладки + +Это browser regression на synthetic fixtures, не проверка физических устройств или live API. +Реальные iOS/Android, Safari/WebKit, клавиатура устройства, camera/file upload и live-данные +в этой сессии не проверялись. Auth/onboarding охвачены source review/tests/build, но не входят +в 66 desktop-снимков. Варианты всех ролей и hover/focus на всех страницах не охвачены pixel gate. + +После выкладки на реальных iOS/Android: открыть проекты/приглашения, редактор проекта +(дата, подсказки, кнопки, добавление цели), приглашение участника с клавиатурой, программу и analytics; +проверить навигацию, поля, загрузку файла и возврат после временной потери сети. +На desktop: проверить реальные проекты/профиль/программу и уведомления на 1440/1920px. + +Frontend deploy запускает существующий workflow при merge в master. Backend deploy и data migration +не требуются. Откат — revert release commit отдельным PR в master с повторным frontend deploy. +Не откатывать последующие независимые изменения whole-branch reset. diff --git a/docs/mobile-production/browser-harness.cjs b/docs/mobile-production/browser-harness.cjs new file mode 100644 index 000000000..fb634a509 --- /dev/null +++ b/docs/mobile-production/browser-harness.cjs @@ -0,0 +1,121 @@ +/** @format */ + +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright"); +const assert = require("node:assert/strict"); +const fixtures = require("./fixtures.cjs"); +const defaultBase = "http://127.0.0.1:4360"; + +async function launch(width = 390, options = {}) { + const { baseUrl: base = defaultBase, ...contextOptions } = options; + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROME_PATH, + }); + const context = await browser.newContext({ + viewport: { width, height: 844 }, + reducedMotion: "reduce", + ...contextOptions, + }); + await context.addCookies( + ["accessToken", "refreshToken", "devAccessToken", "devRefreshToken"].map(name => ({ + name, + value: "responsive-fixture", + url: base, + })), + ); + await context.addInitScript(() => localStorage.setItem("cookieConsent", "declined")); + const requests = []; + await context.route("**/*", async route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === base) return route.continue(); + if (["dev.procollab.ru", "api.procollab.ru"].includes(url.hostname)) { + requests.push({ + method: request.method(), + path: url.pathname, + query: url.search, + body: request.postData(), + }); + return route.fulfill({ + json: fixtures.response(url.pathname, request.method(), url.searchParams, { + completedLesson: request.frame().url().includes("/results"), + }), + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } + return route.fulfill({ body: "", status: 200 }); + }); + await context.routeWebSocket("**", socket => socket.onMessage(() => {})); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("console", message => { + if (message.type() === "error") errors.push(message.text()); + }); + return { browser, context, page, requests, errors, base }; +} + +async function measure(page) { + return page.evaluate(() => { + const visible = el => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none"; + }; + const overflows = [...document.querySelectorAll("body *")] + .filter(el => { + if (!visible(el)) return false; + const r = el.getBoundingClientRect(); + if (r.left >= -1 && r.right <= innerWidth + 1) return false; + for ( + let parent = el.parentElement; + parent && parent !== document.body; + parent = parent.parentElement + ) { + if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(parent).overflowX)) + return false; + } + return true; + }) + .slice(0, 12) + .map(el => ({ + element: el.tagName.toLowerCase(), + class: el.className, + left: el.getBoundingClientRect().left, + right: el.getBoundingClientRect().right, + })); + const visibleApps = [...document.querySelectorAll("body *")] + .filter(el => el.tagName.startsWith("APP-") && visible(el)) + .map(el => el.tagName.toLowerCase()); + return { + actualPath: location.pathname, + actualQuery: location.search, + viewport: innerWidth, + pageWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + overflows, + // Routed hosts can have no box when their content is plain text or a portal. + rendered: [ + ...new Set( + [...document.querySelectorAll("body *")] + .filter(el => el.tagName.startsWith("APP-")) + .map(el => el.tagName.toLowerCase()), + ), + ], + visibleApps: [...new Set(visibleApps)], + text: document.body.innerText.slice(0, 300), + }; + }); +} + +async function assertViewport(page) { + const result = await measure(page); + assert.equal(result.overflows.length, 0, JSON.stringify(result.overflows)); + assert.ok( + result.pageWidth <= result.viewport + 1, + `Page ${result.pageWidth} > ${result.viewport}`, + ); + return result; +} + +module.exports = { launch, measure, assertViewport, base: defaultBase }; diff --git a/docs/mobile-production/compare.py b/docs/mobile-production/compare.py new file mode 100644 index 000000000..e763b1aea --- /dev/null +++ b/docs/mobile-production/compare.py @@ -0,0 +1,47 @@ +"""Strict RGB desktop regression gate, including scrolled and modal states.""" +import argparse +import json +from pathlib import Path + +import numpy as np +from PIL import Image + +parser = argparse.ArgumentParser() +parser.add_argument("before", type=Path) +parser.add_argument("after", type=Path) +parser.add_argument("output", type=Path) +parser.add_argument("--widths", default="1000,1440,1920") +args = parser.parse_args() +rows = [] +screens = ( + "feed projects members programs program profile project vacancies vacancy courses course " + "profile-edit project-edit partners analytics team-invite projects-invites " + "project-edit-fields project-edit-goals project-edit-new-goal analytics-attention " + "team-invite-selected" +).split() +widths = tuple(int(width) for width in args.widths.split(",")) +if not widths or any(width < 1000 for width in widths): + raise SystemExit("Desktop widths must be >=1000") +files = sorted(args.before / f"{screen}-{width}.png" for screen in screens for width in widths) +for file in files: + before = Image.open(file).convert("RGB") + after = Image.open(args.after / file.name).convert("RGB") + same_size = before.size == after.size + if same_size: + delta = np.max(np.abs(np.asarray(before).astype(np.int16) - np.asarray(after).astype(np.int16)), axis=2) + changed = int(np.count_nonzero(delta)) + maximum = int(delta.max()) + else: + changed = -1 + maximum = -1 + rows.append({"screen": file.stem, "beforeSize": before.size, "afterSize": after.size, + "exactChangedPixels": changed, "maxChannelDelta": maximum, + "passed": same_size and changed == 0}) + if changed != 0: + print(f"FAIL {file.stem}: {changed} changed RGB pixels") +args.output.parent.mkdir(parents=True, exist_ok=True) +args.output.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8") +passed = sum(row["passed"] for row in rows) +print(f"Desktop pixel comparison: {passed}/{len(rows)} exact matches") +if not rows or passed != len(rows): + raise SystemExit(1) diff --git a/docs/mobile-production/connection-result.json b/docs/mobile-production/connection-result.json new file mode 100644 index 000000000..c922ab360 --- /dev/null +++ b/docs/mobile-production/connection-result.json @@ -0,0 +1,10 @@ +{ + "width": 390, + "syntheticWebSocket": true, + "httpAvailable": true, + "noGlobalNotification": true, + "automaticReconnect": true, + "secondOutageSilent": true, + "onlineStatusesAfterReconnect": true, + "passed": true +} diff --git a/docs/mobile-production/connection.cjs b/docs/mobile-production/connection.cjs new file mode 100644 index 000000000..d2ccadb0c --- /dev/null +++ b/docs/mobile-production/connection.cjs @@ -0,0 +1,151 @@ +/** @format */ + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { launch } = require("./browser-harness.cjs"); +require("./mobile-fixtures.cjs"); +const preview = require("./preview.cjs"); + +const baseUrl = process.argv[2]; +const directory = path.resolve(process.argv[3] || ".desktop-regression/mobile-smoke/connection"); +(async () => { + fs.mkdirSync(directory, { recursive: true }); + const harness = await launch(390, { baseUrl, isMobile: true, hasTouch: true }); + await preview(harness.context, baseUrl); + try { + // Управляемые WebSocket события; HTTP API остаётся доступен через fixtures. + await harness.context.addInitScript(() => { + window.qaChatOffline = true; + window.qaChatConnected = false; + window.qaChatSockets = []; + window.qaToastCount = 0; + new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node.nodeType === 1 && + (node.matches(".snackbar__item") || node.querySelector(".snackbar__item")) + ) { + window.qaToastCount++; + } + } + } + }).observe(document, { childList: true, subtree: true }); + window.WebSocket = class { + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + readyState = 0; + constructor() { + window.qaChatSockets.push(this); + setTimeout(() => { + if (this.closed) return; + if (window.qaChatOffline) { + this.readyState = 3; + this.onerror?.(new Event("error")); + } else { + this.readyState = 1; + window.qaChatConnected = true; + this.onopen?.(new Event("open")); + } + }, 20); + } + send() {} + close() { + this.closed = true; + this.readyState = 3; + } + }; + }); + const page = harness.page; + await page.goto(baseUrl + "/office/feed"); + await page.locator("app-open-vacancy").first().waitFor({ state: "attached" }); + const toast = page.locator(".snackbar__item"); + // Проходим порог быстрых retry и ещё две попытки; фон продолжает работать без toast. + await page.waitForFunction(() => window.qaChatSockets.length >= 7, null, { timeout: 90000 }); + assert.equal( + await toast.count(), + 0, + "Hidden chat outage must not create a global notification", + ); + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ + path: path.join(directory, "background-outage-390.png"), + fullPage: true, + }); + await page.evaluate(() => { + window.qaChatOffline = false; + }); + await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); + assert.equal(await toast.count(), 0); + + await page.evaluate(() => { + history.pushState({}, "", "/office/profile/1"); + dispatchEvent(new PopStateEvent("popstate")); + }); + await page.locator("app-profile-left-side").waitFor({ state: "attached" }); + const onlineBadge = page.locator(".info__avatar .avatar__online"); + assert.equal(await onlineBadge.count(), 0); + const emitStatus = type => + page.evaluate(type => { + window.qaChatSockets.at(-1).onmessage( + new MessageEvent("message", { + data: JSON.stringify({ type, content: { user_id: 1 } }), + }), + ); + }, type); + await emitStatus("set_online"); + await onlineBadge.waitFor({ state: "visible" }); + await emitStatus("set_offline"); + await onlineBadge.waitFor({ state: "hidden" }); + + const attempts = await page.evaluate(() => window.qaChatSockets.length); + await page.evaluate(() => { + window.qaChatOffline = true; + window.qaChatConnected = false; + window.qaChatSockets.at(-1).onerror(new Event("error")); + }); + await page.waitForFunction(attempts => window.qaChatSockets.length >= attempts + 6, attempts, { + timeout: 90000, + }); + assert.equal(await toast.count(), 0, "Second outage must also stay silent"); + await page.evaluate(() => { + window.qaChatOffline = false; + }); + await page.waitForFunction(() => window.qaChatConnected, null, { timeout: 10000 }); + await emitStatus("set_online"); + await onlineBadge.waitFor({ state: "visible" }); + assert.equal(await toast.count(), 0); + assert.equal( + await page.evaluate(() => window.qaToastCount), + 0, + "No transient toast during any outage", + ); + assert.deepEqual(harness.errors, []); + fs.writeFileSync( + path.join(directory, "result.json"), + JSON.stringify( + { + width: 390, + syntheticWebSocket: true, + httpAvailable: true, + noGlobalNotification: true, + automaticReconnect: true, + secondOutageSilent: true, + onlineStatusesAfterReconnect: true, + passed: true, + }, + null, + 2, + ) + "\n", + ); + console.log("Connection smoke: all scenarios passed"); + } finally { + await harness.browser.close(); + } +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/docs/mobile-production/desktop-comparison.json b/docs/mobile-production/desktop-comparison.json new file mode 100644 index 000000000..cc3c9bddf --- /dev/null +++ b/docs/mobile-production/desktop-comparison.json @@ -0,0 +1,530 @@ +[ + { + "screen": "analytics-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-attention-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-attention-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "analytics-attention-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "course-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "course-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "course-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "courses-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "courses-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "courses-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "feed-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "feed-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "feed-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "members-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "partners-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "partners-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "partners-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-edit-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-edit-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "profile-edit-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "program-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "program-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "program-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "programs-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "programs-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "programs-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-fields-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-fields-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-fields-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-goals-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-goals-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-goals-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-new-goal-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-new-goal-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "project-edit-new-goal-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-invites-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-invites-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "projects-invites-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-selected-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-selected-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "team-invite-selected-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancies-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancies-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancies-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancy-1000", + "beforeSize": [1000, 844], + "afterSize": [1000, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancy-1440", + "beforeSize": [1440, 844], + "afterSize": [1440, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + }, + { + "screen": "vacancy-1920", + "beforeSize": [1920, 844], + "afterSize": [1920, 844], + "exactChangedPixels": 0, + "maxChannelDelta": 0, + "passed": true + } +] diff --git a/docs/mobile-production/fixtures.cjs b/docs/mobile-production/fixtures.cjs new file mode 100644 index 000000000..01975fc61 --- /dev/null +++ b/docs/mobile-production/fixtures.cjs @@ -0,0 +1,520 @@ +/** @format */ +// Entirely synthetic API data. Never forwards a mutation to a live service. +const long = "ОченьДлинноеНазваниеБезПробелов".repeat(4); +const skill = { id: 1, name: long, category: { id: 1, name: "Hard skills" }, approves: [] }; +const collaborator = { + userId: 1, + firstName: long, + lastName: "Тестовый", + avatar: "", + role: "Руководитель", + skills: [skill], +}; +const project = { + id: 1, + name: long, + description: long, + shortDescription: "Проект для проверки адаптива", + targetAudience: long, + problem: long, + actuality: long, + region: "Москва", + trl: "1", + implementationDeadline: "2027-01-01", + industry: 1, + draft: false, + leader: 1, + leaderInfo: collaborator, + imageAddress: "/assets/images/profile/main.svg", + presentationAddress: "", + cover: null, + coverImageAddress: null, + isDefaultCover: true, + links: ["https://example.test/" + "long".repeat(30)], + numberOfCollaborators: 1, + viewsCount: 0, + isCompany: false, + inviteId: 1, + achievements: [], + collaborators: [collaborator], + vacancies: [], + partners: [], + resources: [], + goals: [], + partnerProgram: null, + partnerProgramsTags: [], +}; +const user = { + id: 1, + email: "long.email.for.responsive.testing@example.test", + firstName: long, + lastName: "Тестовый", + patronymic: "", + aboutMe: long, + birthday: "2000-01-01", + avatar: "", + speciality: "Разработчик", + userType: 1, + city: "Москва", + region: "Москва", + phoneNumber: "+79990000000", + v2Speciality: { id: 1, name: "Разработчик" }, + links: [], + onboardingStage: null, + education: [], + userLanguages: [], + workExperience: [], + achievements: [], + programs: [], + projects: [project], + subscribedProjects: [], + keySkills: [long], + skills: [skill], + skillsIds: [1], + progress: 100, + isOnline: false, + isActive: true, + timeCreated: "2026-10-01", + timeUpdated: "2026-10-01", + verificationDate: "2026-10-01", + verificationNoticeAcknowledgedAt: "2026-10-01", + profileFillPromptAcknowledgedAt: "2026-10-01", + isSubscribed: false, + lastSubscribeDate: "", + subscriptionDateOver: null, + lastSubscriptionType: null, + isAutopayAllowed: false, +}; +const program = { + id: 1, + name: long, + description: long, + shortDescription: "Тестовое мероприятие", + city: "Москва", + tag: "Хакатон", + year: 2026, + links: [], + materials: [], + registrationLink: null, + imageAddress: "/assets/images/profile/main.svg", + coverImageAddress: "", + presentationAddress: "", + advertisementImageAddress: "", + datetimeRegistrationEnds: "2027-01-01", + datetimeStarted: "2026-10-01", + datetimeFinished: "2027-01-01", + datetimeProjectSubmissionEnds: "2027-01-01", + datetimeEvaluationEnds: "2027-01-01", + viewsCount: 1, + likesCount: 0, + isUserLiked: false, + isUserManager: true, + isUserExpert: true, + isUserMember: true, + currentApplication: null, + welcomeAcknowledgedAt: "2026-10-01", + publishProjectsAfterFinish: true, + courseId: 1, + courses: [], +}; +const vacancy = { + id: 1, + role: long, + project: { id: 1, name: long, imageAddress: "", links: [] }, + isActive: true, + requiredSkills: [skill], + description: long, + salary: "100000", + city: "Москва", + workFormat: "удаленная работа", + requiredExperience: "без опыта", + workSchedule: "гибкий график", + specialization: "Разработчик", + canRespond: true, + canManageResponses: true, + datetimeCreated: "2026-10-01", +}; +const course = { + id: 1, + title: long, + description: long, + accessType: "all_users", + status: "published", + avatarUrl: "", + headerCoverUrl: "", + cardCoverUrl: "", + startDate: "2026-01-01", + endDate: "2027-01-01", + dateLabel: "01.01.2026 — 01.01.2027", + isAvailable: true, + partnerProgramId: 1, + progressStatus: "in_progress", + percent: 10, + actionState: "continue", + analyticsStub: null, +}; +const lesson = { + id: 1, + moduleId: 1, + courseId: 1, + title: long, + progressStatus: "in_progress", + percent: 10, + currentTaskId: 1, + moduleOrder: 1, + tasks: [ + { + id: 1, + order: 1, + title: long, + answerTitle: "Ответ", + status: "published", + taskKind: "question", + checkType: "auto", + informationalType: null, + questionType: "text", + answerType: "text", + bodyText: long, + videoUrl: null, + imageUrl: null, + attachmentUrl: null, + isAvailable: true, + isCompleted: false, + options: [], + }, + ], +}; +const structure = { + courseId: 1, + progressStatus: "in_progress", + percent: 10, + modules: [ + { + id: 1, + courseId: 1, + title: long, + order: 1, + avatarUrl: "", + startDate: "2026-01-01", + status: "published", + isAvailable: true, + progressStatus: "in_progress", + percent: 10, + lessons: [{ ...lesson, order: 1, status: "published", isAvailable: true, taskCount: 1 }], + }, + ], +}; +const page = results => ({ count: results.length, results, next: null, previous: null }); +const fields = [ + { + id: 1, + name: "note", + label: "Описание решения", + fieldType: "text", + options: [], + isRequired: false, + helpText: long, + value: long, + }, + { + id: 2, + name: "track", + label: "Направление", + fieldType: "select", + options: [long, "Разработка"], + isRequired: false, + helpText: "Выберите направление", + value: "Разработка", + }, +]; +const linkFields = { + programLinkId: 1, + programId: 1, + projectId: 1, + submitted: false, + isCompetitive: true, + submissionOpen: true, + submissionDeadline: "2027-01-01", + canSubmit: true, + fields, +}; +const ratedProject = { + ...project, + scored: false, + scoredExpertId: null, + ratedExperts: [], + ratedCount: 0, + maxRates: 3, + criterias: [ + { + id: 1, + name: "Качество решения", + description: long, + type: "int", + minValue: 0, + maxValue: 10, + value: null, + expertId: null, + }, + { + id: 2, + name: "Соответствие требованиям", + description: long, + type: "bool", + value: null, + expertId: null, + }, + { id: 3, name: "Комментарий", description: long, type: "str", value: null, expertId: null }, + ], +}; +const vacancyResponse = { + id: 1, + vacancy, + user, + isApproved: null, + status: "pending", + datetimeCreated: "2026-10-01", + whyMe: long, + accompanyingFile: null, +}; +const participant = { ...user, id: 2, firstName: "Кандидат", lastName: long }; +user.avatar = "/assets/images/projects/shared/idea.svg"; +collaborator.avatar = user.avatar; +project.imageAddress = user.avatar; +project.coverImageAddress = "/assets/images/auth/login-img.png"; +program.imageAddress = user.avatar; +const news = { + id: 1, + name: "Новости программы", + imageAddress: user.avatar, + title: long, + text: long, + description: long, + datetimeCreated: "2026-10-01", + timeCreated: "2026-10-01", + project: 1, + author: 1, + files: [], + images: [], + likesCount: 0, + isUserLiked: false, +}; +const total = { total: 1 }; +const regions = { total: 1, items: [{ name: long, count: 1 }] }; +const metrics = { participantsTotal: 1, projectsTotal: 1, notSubmitted: 0, submitted: 1 }; +const overview = { + summary: { + participants: total, + projects: total, + experts: total, + regions, + participantRegions: regions, + }, + participantFunnel: { + registrations: 1, + uniqueParticipants: 1, + withTeam: 1, + projectCreators: 1, + submittedProjectCreators: 1, + }, + solutionFunnel: { created: 1, notSubmitted: 0, submitted: 1, evaluated: 0 }, + evaluationStatus: { + mode: "open", + maxEvaluationsPerProject: null, + assignments: { total: 1, pending: 1, evaluated: 0 }, + projects: { submitted: 1, awaitingEvaluation: 1, partiallyEvaluated: 0, evaluated: 0 }, + }, + attention: { + participantsWithoutTeam: 0, + projectsAwaitingEvaluation: 1, + projectsNotSubmitted: { applicable: true, total: 0 }, + delayedExperts: { applicable: false, total: 0, items: [], thresholdHours: 24 }, + }, + activity: [], + cases: { + configured: true, + submissionApplicable: true, + items: [{ name: long, ...metrics }], + withoutCase: { participantsTotal: 0, projectsTotal: 0, notSubmitted: 0, submitted: 0 }, + }, +}; +function response(path, method = "GET", query = new URLSearchParams(), state = {}) { + const assignment = { + assignmentId: 1, + expert: { + expertId: 1, + userId: 1, + firstName: user.firstName, + lastName: user.lastName, + fullName: long, + avatar: user.avatar, + }, + project: { id: 1, name: long }, + status: "completed", + assignedAt: "2026-10-01", + projectSubmitted: true, + projectSubmittedAt: "2026-10-01", + waitingSince: null, + waitingSeconds: null, + }; + if (/\/manager-overview\/assignments\/1\/scores\//.test(path)) + return { + ...assignment, + scores: [ + { + criterionId: 1, + name: long, + description: long, + type: "int", + minValue: 0, + maxValue: 10, + value: "8", + isScored: true, + }, + ], + }; + if (/\/manager-overview\/assignments\//.test(path)) return [assignment]; + if (/\/manager-overview\/projects-awaiting-evaluation\//.test(path)) + return { + ...page([ + { + programProjectId: 1, + project: { id: 1, name: long }, + leader: { userId: 1, fullName: long, avatar: user.avatar }, + submittedAt: "2026-10-01", + status: "partially_evaluated", + reason: "partially_evaluated", + reasonLabel: "Частично оценено", + assignmentsTotal: 1, + assignmentsCompleted: 0, + }, + ]), + mode: "open", + }; + if (/\/programs\/1\/projects\//.test(path) && query.get("view") === "case_analytics") + return { + ...page([ + { + programProjectId: 1, + project: { id: 1, name: long, region: "Москва", presentationAddress: null }, + case: { kind: "selected", name: long }, + leader: { userId: 1, fullName: long }, + teamSize: 1, + linkedAt: "2026-10-01", + submitted: true, + submittedAt: "2026-10-01", + }, + ]), + selection: { scope: "selected", caseName: long }, + casesConfigured: true, + submissionApplicable: true, + caseMetrics: metrics, + }; + if (/\/invites\/$/.test(path)) + return method === "GET" + ? [] + : { + id: 1, + user: participant, + project, + role: "Разработчик", + isAccepted: null, + datetimeCreated: "2026-10-01", + }; + if (/\/vacancies\/responses\/1\/(accept|decline)\//.test(path)) + return { ...vacancyResponse, isApproved: path.includes("accept") }; + if (/\/files\/$/.test(path)) + return { url: "https://example.test/ОченьДлинноеНазваниеФайлаДляПроверкиАдаптива.pdf" }; + if (/\/courses\/tasks\/1\/answer\/$/.test(path)) + return { + answerId: 1, + status: "submitted", + isCorrect: true, + canContinue: true, + nextTaskId: null, + submittedAt: "2026-10-01", + }; + if (/\/rate-project\/rate\/1/.test(path)) + return { ...ratedProject, scored: true, scoredExpertId: 1 }; + if (/\/rate-project\/1/.test(path)) return page([ratedProject]); + if (/\/programs\/1\/projects\/apply\/$/.test(path)) + return method === "GET" + ? { programId: 1, canSubmit: true, submissionDeadline: "2027-01-01", programFields: fields } + : { projectId: 1, programLinkId: 1 }; + if (/\/programs\/partner-program-projects\/1\/fields\//.test(path)) return linkFields; + if (/\/programs\/partner-program-projects\/1\/submit\//.test(path)) + return { ...project, submitted: true }; + if (/\/projects\/1\/responses\//.test(path)) return [vacancyResponse]; + if (/\/vacancies\/1\/responses\//.test(path)) + return method === "GET" ? [vacancyResponse] : vacancyResponse; + if (/\/api\/token\//.test(path)) + return { access: "responsive-fixture", refresh: "responsive-fixture" }; + if (/\/auth\/users\/(current|1)\/$/.test(path)) return user; + if (/\/auth\/users\/(types|roles)\//.test(path)) + return [ + [1, "Участник"], + [2, "Эксперт"], + [3, "Менеджер"], + ]; + if (/\/auth\/users\/projects/.test(path)) return page([project]); + if (/\/auth\/users\/1\/subscribed_projects\//.test(path)) return page([project]); + if (/\/auth\/public-users\/stats/.test(path)) + return { total: 1, cities: 1, regions: 1, students: 1, skills: 1, userTypes: [] }; + if (/\/auth\/public-users\/$/.test(path)) return page([participant]); + if (/\/auth\/public-users\/1\/$/.test(path)) return user; + if (/\/industries\//.test(path)) return [{ id: 1, name: "Информационные технологии" }]; + if (/\/core\/skills\/inline/.test(path)) return page([skill]); + if (/\/core\/skills\/nested/.test(path)) return [{ id: 1, name: "Hard skills", skills: [skill] }]; + if (/specializations/.test(path)) return [{ id: 1, name: "Разработчик" }]; + if (/\/projects\/count\//.test(path)) + return { all: 1, my: 1, subs: 1, myLeader: 1, myInProgram: 0, mySubmitted: 0 }; + if (/\/projects\/1\/$/.test(path)) return project; + if (/\/projects\/$/.test(path)) return method === "GET" ? page([project]) : project; + if (/\/programs\/1\/manager-overview\/$/.test(path)) return overview; + if (/\/programs\/1\/$/.test(path)) return program; + if (/\/programs\/1\/analytics-widget\/$/.test(path)) + return { + programId: 1, + isCompetitive: true, + role: "organizer", + organizer: { + participants: 1, + projects: 1, + submittedSolutions: 0, + participantsWithoutProject: 0, + }, + }; + if (/\/programs\/$/.test(path)) return page([program]); + if (/\/programs\/1\/projects\//.test(path)) return page([project]); + if (/\/programs\/1\/filters\//.test(path)) return []; + if (/\/vacancies\/responses\/self/.test(path)) return page([vacancyResponse]); + if (/\/vacancies\/1\/$/.test(path)) return vacancy; + if (/\/vacancies\/$/.test(path)) return page([vacancy]); + if (/\/courses\/lessons\/1\/$/.test(path)) + return state.completedLesson + ? { + ...lesson, + progressStatus: "completed", + percent: 100, + currentTaskId: null, + tasks: lesson.tasks.map(task => ({ ...task, isCompleted: true })), + } + : lesson; + if (/\/courses\/1\/structure\/$/.test(path)) return structure; + if (/\/courses\/1\/$/.test(path)) return course; + if (/\/courses\/$/.test(path)) return [course]; + if (/\/schema\//.test(path)) return { dataSchema: {} }; + if (/\/news\/1\/$/.test(path)) return news; + if (/\/news\//.test(path)) return page([news]); + if (/\/feed\//.test(path)) return page([]); + if (method !== "GET") return { ...project, ...user, id: 1 }; + return []; +} +module.exports = { response, project, program, user, vacancy, overview, lesson, long }; + +// Production Angular использует project-analytics; payload совпадает с overview fixtures. +const originalProductionResponse = module.exports.response; +module.exports.response = (pathname, method, query, state) => + /\/programs\/1\/project-analytics\/$/.test(pathname) + ? module.exports.overview + : originalProductionResponse(pathname, method, query, state); diff --git a/docs/mobile-production/mobile-fixtures.cjs b/docs/mobile-production/mobile-fixtures.cjs new file mode 100644 index 000000000..7e011d680 --- /dev/null +++ b/docs/mobile-production/mobile-fixtures.cjs @@ -0,0 +1,47 @@ +/** @format */ + +const fixtures = require("./fixtures.cjs"); +fixtures.user.firstName = "Алексей"; +fixtures.user.lastName = "Куделько"; +fixtures.project.name = "Городские проекты"; +fixtures.project.shortDescription = "Сервис для студенческих команд"; +fixtures.project.region = "Ростовская область"; +fixtures.project.partnerProgram = { + id: 1, + programId: 1, + programLinkId: 1, + name: "Акселератор студенческих проектов", + isSubmitted: false, + canSubmit: true, + programFields: [], + programFieldValues: [], +}; +fixtures.program.description = + "Программа помогает студенческим командам развивать проекты и работать с экспертами. Участники изучают материалы, готовят решения и получают обратную связь. ".repeat( + 6, + ); +fixtures.program.name = "Кейс-чемпионат студенческих проектов"; +fixtures.vacancy.project.imageAddress = fixtures.user.avatar; +fixtures.vacancy.project.name = fixtures.project.name; +fixtures.vacancy.role = "Дизайнер интерфейсов"; +fixtures.vacancy.description = "Помогите создавать понятные интерфейсы для студенческих команд."; +fixtures.vacancy.requiredSkills = [ + "Мотивированность", + "Глубинное интервью", + "Проектирование пользовательского опыта", + "Figma", +].map((name, id) => ({ id: id + 1, name, category: { id: 1, name: "Soft skills" }, approves: [] })); +const response = fixtures.response; +fixtures.response = (pathname, method, query, state) => + /\/feed\/$/.test(pathname) + ? { + count: 1, + next: null, + previous: null, + counts: { all: 88, project: 56, vacancy: 1, news: 31, partnerprogram: 0, education: 0 }, + results: [ + { typeModel: "vacancy", content: fixtures.vacancy, publishedAt: "2026-09-29T12:00:00Z" }, + ], + } + : response(pathname, method, query, state); +module.exports = fixtures; diff --git a/docs/mobile-production/preview.cjs b/docs/mobile-production/preview.cjs new file mode 100644 index 000000000..edcd7079d --- /dev/null +++ b/docs/mobile-production/preview.cjs @@ -0,0 +1,33 @@ +/** @format */ + +const fs = require("node:fs"); +const path = require("node:path"); + +// Optional direct delivery of unmodified build bytes avoids local HTTP/proxy failures. +module.exports = async function preview(context, baseUrl) { + if (!process.env.QA_BUILD_ROOT) return; + let root = path.resolve(process.env.QA_BUILD_ROOT); + if (fs.existsSync(path.join(root, "browser/index.html"))) root = path.join(root, "browser"); + const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".json": "application/json", + }; + await context.route(baseUrl + "/**", async route => { + const url = new URL(route.request().url()); + let file = path.resolve(root, "." + decodeURIComponent(url.pathname)); + if (!file.startsWith(root + path.sep)) return route.abort(); + if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) + file = path.join(root, "index.html"); + await route.fulfill({ + path: file, + contentType: types[path.extname(file)] || "application/octet-stream", + }); + }); +}; diff --git a/docs/mobile-production/screenshots/analytics-attention-390.png b/docs/mobile-production/screenshots/analytics-attention-390.png new file mode 100644 index 000000000..75b534143 Binary files /dev/null and b/docs/mobile-production/screenshots/analytics-attention-390.png differ diff --git a/docs/mobile-production/screenshots/feed-390.png b/docs/mobile-production/screenshots/feed-390.png new file mode 100644 index 000000000..0273586ac Binary files /dev/null and b/docs/mobile-production/screenshots/feed-390.png differ diff --git a/docs/mobile-production/screenshots/program-1440-baseline.png b/docs/mobile-production/screenshots/program-1440-baseline.png new file mode 100644 index 000000000..7aa53dda0 Binary files /dev/null and b/docs/mobile-production/screenshots/program-1440-baseline.png differ diff --git a/docs/mobile-production/screenshots/program-1440-verified.png b/docs/mobile-production/screenshots/program-1440-verified.png new file mode 100644 index 000000000..7aa53dda0 Binary files /dev/null and b/docs/mobile-production/screenshots/program-1440-verified.png differ diff --git a/docs/mobile-production/screenshots/program-390.png b/docs/mobile-production/screenshots/program-390.png new file mode 100644 index 000000000..f7342055a Binary files /dev/null and b/docs/mobile-production/screenshots/program-390.png differ diff --git a/docs/mobile-production/screenshots/project-edit-fields-390.png b/docs/mobile-production/screenshots/project-edit-fields-390.png new file mode 100644 index 000000000..ed2498226 Binary files /dev/null and b/docs/mobile-production/screenshots/project-edit-fields-390.png differ diff --git a/docs/mobile-production/screenshots/projects-invites-390.png b/docs/mobile-production/screenshots/projects-invites-390.png new file mode 100644 index 000000000..a3e29fe11 Binary files /dev/null and b/docs/mobile-production/screenshots/projects-invites-390.png differ diff --git a/docs/mobile-production/screenshots/team-invite-selected-390.png b/docs/mobile-production/screenshots/team-invite-selected-390.png new file mode 100644 index 000000000..626b3ce3f Binary files /dev/null and b/docs/mobile-production/screenshots/team-invite-selected-390.png differ diff --git a/docs/mobile-production/visual.cjs b/docs/mobile-production/visual.cjs new file mode 100644 index 000000000..ccaa32416 --- /dev/null +++ b/docs/mobile-production/visual.cjs @@ -0,0 +1,395 @@ +/** @format */ + +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const { launch, measure, assertViewport } = require("./browser-harness.cjs"); +const fixtures = require("./mobile-fixtures.cjs"); +const preview = require("./preview.cjs"); + +// Только синтетические данные: harness не отправляет запросы в live API. +const originalResponse = fixtures.response; +fixtures.overview.attention.participantsWithoutTeam = 6; +fixtures.overview.attention.projectsNotSubmitted.total = 1; +fixtures.response = (endpoint, method, query, state) => { + if (/\/invites\/$/.test(endpoint) && method === "GET" && !query.has("project")) { + return [ + { + id: 1, + user: fixtures.user, + project: fixtures.project, + role: "Разработчик", + isAccepted: null, + datetimeCreated: "2026-10-01", + }, + ]; + } + return originalResponse(endpoint, method, query, state); +}; + +const [phase, baseUrl, widthsArgument = "320,390,768,1440,1920", directoryArgument] = + process.argv.slice(2); +const output = path.resolve(directoryArgument || ".desktop-regression/mobile-smoke/screenshots"); +const widths = widthsArgument.split(",").map(Number); +const details = process.argv[6] === "details"; +const selectedScreens = process.env.QA_SCREENS?.split(","); +const checkMobile = phase !== "baseline"; +const screens = [ + ["feed", "/office/feed", "app-open-vacancy"], + ["projects", "/office/projects/dashboard", "app-info-card"], + ["members", "/office/members", "app-member-card"], + ["programs", "/office/program/all", "app-program-card"], + ["program", "/office/program/1", "app-main"], + ["profile", "/office/profile/1", "app-profile-left-side"], + ["project", "/office/projects/1", "app-projects-left-side"], + ["vacancies", "/office/vacancies/all", "app-project-vacancy-card"], + ["vacancy", "/office/vacancies/1", "app-vacancies-detail"], + ["courses", "/office/courses/all", "app-course"], + ["course", "/office/courses/1", "app-course-detail"], + ["profile-edit", "/office/profile/edit?editingStep=main", "app-profile-main-step"], + [ + "project-edit", + "/office/projects/1/edit?editingStep=main&programLinkId=1", + "app-project-main-step", + ], + [ + "partners", + "/office/projects/1/edit?editingStep=contacts&programLinkId=1", + "app-project-partner-resources-step", + ], + ["analytics", "/office/program/1/analytics", ".attention__list"], + [ + "team-invite", + "/office/projects/1/edit?editingStep=team&programLinkId=1", + "app-project-team-step", + ], +]; + +async function settle(page) { + await page.waitForLoadState("networkidle"); + await page.evaluate(() => { + for (const image of document.images) image.loading = "eager"; + }); + await page.waitForLoadState("networkidle"); + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all([...document.fonts].map(font => font.load().catch(() => {}))); + await document.fonts.ready; + }); + await page.waitForFunction(() => [...document.images].every(image => image.complete)); + await page.evaluate(() => + Promise.all([...document.images].map(image => image.decode().catch(() => {}))), + ); + await page.addStyleTag({ + content: + "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;}", + }); + await page.evaluate(() => { + window.scrollTo(0, 0); + for (const element of document.querySelectorAll("*")) { + if (["auto", "scroll"].includes(getComputedStyle(element).overflowY)) element.scrollTop = 0; + } + }); + await page.evaluate(async () => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await document.fonts.ready; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} + +async function inspect(page, name) { + return page.evaluate(name => { + const box = element => { + const r = element.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width }; + }; + const buttons = selector => [...document.querySelectorAll(selector)].map(box); + if (name === "projects") { + const list = document.querySelector(".page__invites--list"); + const card = list?.querySelector("app-info-card"); + return { list: list && box(list), card: card && box(card) }; + } + if (name === "project-edit") { + const region = document.querySelector(".region-select__control input"); + const industry = document.querySelector("#industry .field__input"); + const date = document.querySelector("#implementationDeadline .field__input"); + const hint = document.querySelector("#implementationDeadline .field__tooltip-wrapper"); + const avatar = document.querySelector(".project__image .control"); + const avatarHint = document.querySelector(".project__image .control__tooltip-wrapper"); + return { + actions: buttons(".project__save button"), + regionFont: getComputedStyle(region).fontSize, + industryFont: getComputedStyle(industry).fontSize, + date: box(date), + dateHint: box(hint), + datePadding: parseFloat(getComputedStyle(date).paddingRight), + avatar: box(avatar), + avatarHint: box(avatarHint), + goals: buttons( + ".project__options > app-button button, .project__link-options > app-button button", + ), + }; + } + if (name === "partners") + return { actions: buttons("app-project-partner-resources-step app-button button") }; + if (name === "program") + return { + emptyContacts: document.querySelectorAll(".info__contacts:has(button:disabled)").length, + }; + if (name === "analytics") + return { + backgrounds: [...document.querySelectorAll(".attention__action")].map( + button => getComputedStyle(button).backgroundColor, + ), + }; + if (name === "team-invite") { + const field = document.querySelector(".invite-search input"); + const footer = document.querySelector(".invite-actions"); + return { + inputBorder: getComputedStyle(field).borderTopWidth, + inputShadow: getComputedStyle(field).boxShadow, + inputOutline: getComputedStyle(field).outlineStyle, + actions: buttons(".invite-actions button"), + footer: box(footer), + }; + } + return {}; + }, name); +} + +function verify(name, layout) { + const gap = actions => { + for (let i = 1; i < actions.length; i++) + assert.ok( + actions[i].top - actions[i - 1].bottom >= 7, + `${name}: missing vertical button gap`, + ); + }; + if (name === "projects") + assert.ok( + Math.abs(layout.list.left + layout.list.right - (layout.card.left + layout.card.right)) < 2, + "Invitation card must be centered", + ); + if (name === "project-edit") { + gap(layout.actions); + gap(layout.goals); + assert.equal(layout.regionFont, layout.industryFont, "Region and select typography must match"); + assert.ok(layout.date.width > 150, "Date must fit full value"); + assert.ok( + layout.dateHint.left >= layout.date.right - layout.datePadding, + "Date hint overlaps text", + ); + assert.ok(layout.avatarHint.left > layout.avatar.right, "Avatar hint must be outside logo"); + } + if (name === "partners") gap(layout.actions); + if (name === "program") assert.equal(layout.emptyContacts, 0, "Empty contacts must be hidden"); + if (name === "analytics") + assert.ok( + layout.backgrounds.every(color => color === "rgba(0, 0, 0, 0)"), + "Attention rows must not resemble progress bars", + ); + if (name === "team-invite") { + assert.equal(layout.inputBorder, "0px", "Search must have one outer border"); + assert.equal(layout.inputShadow, "none"); + assert.equal(layout.inputOutline, "none", "Focus belongs to outer search field"); + gap(layout.actions); + for (const button of layout.actions) + assert.ok( + Math.abs(button.width - layout.footer.width) < 2, + "Dialog buttons must fill footer", + ); + } +} + +(async () => { + fs.mkdirSync(path.join(output, phase), { recursive: true }); + if (phase === "contacts") { + fixtures.program.links = ["https://t.me/procollab_fixture"]; + const harness = await launch(390, { baseUrl, isMobile: true, hasTouch: true }); + await preview(harness.context, baseUrl); + try { + const page = harness.page; + await page.goto(baseUrl + "/office/program/1"); + const contacts = page.locator(".info__contacts button"); + await contacts.waitFor({ state: "visible" }); + assert.equal(await contacts.isEnabled(), true); + await contacts.click(); + await page + .locator('.modal a[href="https://t.me/procollab_fixture"]') + .waitFor({ state: "visible" }); + await assertViewport(page); + assert.deepEqual(harness.errors, []); + console.log("Program contacts: existing link opens in mobile modal"); + } finally { + await harness.browser.close(); + } + return; + } + const rows = []; + const repeatedShots = new Map(); + for (const width of widths) { + const harness = await launch(width, { baseUrl, isMobile: width < 750, hasTouch: width < 750 }); + await preview(harness.context, baseUrl); + const page = harness.page; + const fixClock = () => { + const NativeDate = Date; + window.Date = class extends NativeDate { + constructor(...args) { + super(...(args.length ? args : [1791288000000])); + } + static now() { + return 1791288000000; + } + }; + }; + try { + await page.goto(baseUrl + "/office/feed"); + await page + .locator("app-open-vacancy") + .first() + .waitFor({ state: "attached" }) + .catch(async error => { + console.error( + JSON.stringify( + { + phase, + baseUrl, + width, + details, + url: page.url(), + errors: harness.errors, + requests: harness.requests, + html: (await page.content()).slice(-1600), + }, + null, + 2, + ), + ); + throw error; + }); + await page.evaluate(fixClock); + await settle(page); + for (const [name, route, selector] of screens) { + if (selectedScreens && !selectedScreens.includes(name)) continue; + if (details && !["projects", "project-edit", "analytics", "team-invite"].includes(name)) + continue; + harness.errors.length = 0; + if (name !== "feed") + await page.evaluate(route => { + history.pushState({}, "", route); + dispatchEvent(new PopStateEvent("popstate")); + }, route); + await page + .locator(selector) + .first() + .waitFor({ state: "attached" }) + .catch(async error => { + console.error( + JSON.stringify( + { + name, + width, + text: await page.locator("body").innerText(), + errors: harness.errors, + requests: harness.requests.slice(-15), + }, + null, + 2, + ), + ); + throw error; + }); + if (name === "program" && width > 1000) + await page.locator("app-program-role-widget").waitFor({ state: "attached" }); + await settle(page); + if (name === "team-invite") { + await page.locator(".invite__submit button").click(); + await page.locator(".project-invite[role=dialog]").waitFor({ state: "visible" }); + await settle(page); + } + const viewport = + checkMobile && width < 1000 ? await assertViewport(page) : await measure(page); + const layout = await inspect(page, name); + if (checkMobile && width < 1000) { + try { + verify(name, layout); + } catch (error) { + console.error(JSON.stringify({ name, width, layout })); + throw error; + } + } + assert.deepEqual(harness.errors, [], `${name} ${width} console errors`); + const pixels = await page.screenshot({ + path: path.join(output, phase, `${name}-${width}.png`), + fullPage: true, + }); + const shotKey = `${name}-${width}`; + if (repeatedShots.has(shotKey)) + assert.deepEqual( + pixels, + repeatedShots.get(shotKey), + `${shotKey}: unstable repeated screenshot`, + ); + repeatedShots.set(shotKey, pixels); + rows.push({ name, width, layout, viewport, passed: true }); + console.log(`${phase}: ${name} ${width}`); + if (details) { + const screenshot = async (suffix, selector) => { + await page + .locator(selector) + .first() + .evaluate(element => element.scrollIntoView({ block: "center" })); + await page.screenshot({ + path: path.join(output, phase, `${suffix}-${width}.png`), + fullPage: true, + }); + console.log(`${phase}: ${suffix} ${width}`); + }; + if (name === "projects") await screenshot("projects-invites", ".page__invites--list"); + if (name === "project-edit") { + await screenshot("project-edit-fields", "#implementationDeadline"); + await screenshot("project-edit-goals", ".project__options"); + await page.locator(".project__options > app-button button").click(); + await screenshot("project-edit-new-goal", ".project__links--wrapper"); + if (checkMobile && width < 1000) { + await assertViewport(page); + const date = page.locator(".project__links input").nth(1); + const box = await date.boundingBox(); + assert.ok(box.width > 150, "New goal date must fit its value"); + } + } + if (name === "analytics") await screenshot("analytics-attention", ".attention__list"); + if (name === "team-invite") { + await page.locator("#team-invite-search").fill("Кандидат"); + await page.locator(".invite-row[role=radio]").first().click(); + await page.locator(".invite-role input").fill("Разработчик"); + assert.equal( + await page + .getByRole("button", { name: "Отправить приглашение", exact: true }) + .isEnabled(), + true, + ); + await page.screenshot({ + path: path.join(output, phase, `team-invite-selected-${width}.png`), + fullPage: true, + }); + console.log(`${phase}: team-invite-selected ${width}`); + } + } + if (name === "team-invite") await page.getByLabel("Закрыть приглашение").click(); + } + } finally { + await harness.browser.close(); + } + } + fs.writeFileSync( + path.join( + output, + `${phase}${selectedScreens ? "-" + selectedScreens.join("-") : ""}${details ? "-details" : ""}.json`, + ), + JSON.stringify(rows, null, 2) + "\n", + ); +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/core/src/lib/services/websockets/websocket.service.spec.ts b/projects/core/src/lib/services/websockets/websocket.service.spec.ts index 72f5ef351..bd3e371f7 100644 --- a/projects/core/src/lib/services/websockets/websocket.service.spec.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from "@angular/core/testing"; import { WebsocketService } from "./websocket.service"; import { TokenService } from "../tokens/token.service"; +import { environment } from "@environment"; describe("WebsocketService", () => { let service: WebsocketService; @@ -30,4 +31,90 @@ describe("WebsocketService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + + describe("восстановление соединения", () => { + let sockets: any[]; + + beforeEach(() => { + vi.useFakeTimers(); + sockets = []; + vi.stubGlobal( + "WebSocket", + class { + static OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(() => { + this.readyState = 3; + }); + constructor() { + sockets.push(this); + } + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("продолжает retry, сообщает один период недоступности и фиксирует восстановление", () => { + const states: string[] = []; + service.connectionStatus$.subscribe(state => states.push(state)); + const connection = service.connect("/chat/").subscribe(); + for (let i = 1; i <= environment.websocketReconnectionMaxAttempts + 2; i++) { + sockets.at(-1).onerror(); + vi.advanceTimersByTime( + i >= environment.websocketReconnectionMaxAttempts + ? Math.max(environment.websocketReconnectionInterval, 5000) + : environment.websocketReconnectionInterval, + ); + } + expect(states).toEqual(["idle", "unavailable"]); + sockets.at(-1).readyState = 1; + sockets.at(-1).onopen(); + expect(states).toEqual(["idle", "unavailable", "connected"]); + expect(service.isConnected).toBe(true); + connection.unsubscribe(); + expect(sockets.at(-1).close).toHaveBeenCalled(); + }); + + it("сохраняет буфер сообщений и отправляет его один раз после подключения", () => { + const connection = service.connect("/chat/").subscribe(); + service.send("new_message", { message: "Тест" }); + const socket = sockets[0]; + socket.readyState = 1; + socket.onopen(); + expect(socket.send).toHaveBeenCalledExactlyOnceWith( + JSON.stringify({ type: "new_message", content: { message: "Тест" } }), + ); + connection.unsubscribe(); + }); + + it("явное закрытие не запускает новую попытку подключения", () => { + const connection = service.connect("/chat/").subscribe(); + sockets[0].onopen(); + service.close(); + expect(sockets[0].onclose).toBeNull(); + vi.advanceTimersByTime(10000); + expect(sockets).toHaveLength(1); + expect(service.isConnected).toBe(false); + connection.unsubscribe(); + }); + + it("закрытие во время retry отменяет отложенное переподключение", () => { + const completed = vi.fn(); + service.connect("/chat/").subscribe({ complete: completed }); + sockets[0].onerror(); + service.close(); + vi.advanceTimersByTime(10000); + expect(sockets).toHaveLength(1); + expect(completed).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/projects/core/src/lib/services/websockets/websocket.service.ts b/projects/core/src/lib/services/websockets/websocket.service.ts index 469315edb..cadd9ef1f 100644 --- a/projects/core/src/lib/services/websockets/websocket.service.ts +++ b/projects/core/src/lib/services/websockets/websocket.service.ts @@ -1,6 +1,17 @@ /** @format */ import { inject, Injectable } from "@angular/core"; -import { filter, map, Observable, Observer, retry, Subject, timer } from "rxjs"; +import { + BehaviorSubject, + distinctUntilChanged, + filter, + map, + Observable, + Observer, + retry, + Subject, + takeUntil, + timer, +} from "rxjs"; import { environment } from "@environment"; import snakecaseKeys from "snakecase-keys"; import camelcaseKeys from "camelcase-keys"; @@ -20,10 +31,18 @@ export class WebsocketService { private socket: WebSocket | null = null; /** Subject для обработки входящих сообщений */ private messages$ = new Subject(); + private readonly closed$ = new Subject(); /** Subject потери соединения (после исчерпания retry) — приватный, наружу только Observable */ private readonly _connectionLost$ = new Subject(); /** Сигнал потери WS-соединения. Потребитель отвечает за UX (toast/banner/etc). */ public readonly connectionLost$ = this._connectionLost$.asObservable(); + private readonly connectionStatusSubject = new BehaviorSubject< + "idle" | "connected" | "unavailable" + >("idle"); + /** Статус именно чата, а не доступности HTTP API или интернета в целом. */ + public readonly connectionStatus$ = this.connectionStatusSubject + .asObservable() + .pipe(distinctUntilChanged()); /** Буфер исходящих сообщений, накопленных пока сокет не OPEN — флашится в onopen. */ private outboundQueue: string[] = []; @@ -50,9 +69,11 @@ export class WebsocketService { const tokenAccess = tokens?.access ? tokens.access : ""; this.socket = new WebSocket(environment.websocketUrl + path, ["Bearer", tokenAccess]); + const socket = this.socket; this.socket.onopen = () => { this.isConnected = true; + this.connectionStatusSubject.next("connected"); // Отправляем всё, что накопилось пока сокет был CONNECTING. const flush = this.outboundQueue; this.outboundQueue = []; @@ -75,6 +96,18 @@ export class WebsocketService { this.socket.onmessage = message => { this.messages$.next(message); }; + + return () => { + socket.onopen = null; + socket.onerror = null; + socket.onclose = null; + socket.onmessage = null; + socket.close(); + if (this.socket === socket) { + this.socket = null; + this.isConnected = false; + } + }; }).pipe( // Resilient reconnect: НЕ сдаёмся насовсем по исчерпании попыток. // Первые maxAttempts — частые попытки (быстрое восстановление после idle-close сервера); @@ -83,6 +116,7 @@ export class WebsocketService { retry({ delay: (_error, retryCount) => { if (retryCount >= environment.websocketReconnectionMaxAttempts) { + this.connectionStatusSubject.next("unavailable"); this._connectionLost$.next(); return timer(Math.max(environment.websocketReconnectionInterval, 5000)); } @@ -90,6 +124,7 @@ export class WebsocketService { }, resetOnSuccess: true, }), + takeUntil(this.closed$), ); } @@ -141,10 +176,14 @@ export class WebsocketService { * Очищает ссылку на socket и сбрасывает флаг подключения */ public close(): void { + this.closed$.next(); if (this.socket) { + this.socket.onclose = null; + this.socket.onerror = null; this.socket.close(); this.socket = null; this.isConnected = false; } + this.connectionStatusSubject.next("idle"); } } diff --git a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts b/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts deleted file mode 100644 index 1c5adcc6b..000000000 --- a/projects/social_platform/src/app/api/connection-status/connection-status-toast.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** @format */ - -import { inject, Injectable } from "@angular/core"; -import { WebsocketService } from "@corelib"; -import { SnackbarService } from "@domain/shared/snackbar.service"; - -/** Слушает потерю WebSocket-соединения и показывает пользователю toast. */ -@Injectable({ providedIn: "root" }) -export class ConnectionStatusToastService { - private readonly websocketService = inject(WebsocketService); - private readonly snackbarService = inject(SnackbarService); - - constructor() { - this.websocketService.connectionLost$.subscribe(() => - this.snackbarService.error("Соединение с сервером потеряно. Проверьте интернет."), - ); - } -} diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.spec.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.spec.ts index 4b98c2ad0..bcb87268e 100644 --- a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.spec.ts +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.spec.ts @@ -4,7 +4,7 @@ import { signal } from "@angular/core"; import { FormArray, FormBuilder } from "@angular/forms"; import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; -import { of } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { ValidationService } from "@corelib"; import { OnboardingStageZeroInfoService } from "./onboarding-stage-zero-info.service"; import { OnboardingService } from "../../onboarding.service"; @@ -18,6 +18,17 @@ import { fail, ok } from "@domain/shared/result.type"; import { initial } from "@domain/shared/async-state"; describe("OnboardingStageZeroInfoService", () => { + const currentProfile = { id: 42 } as User; + let draft: BehaviorSubject>; + let onboardingService: { + formValue$: ReturnType>["asObservable"]>; + setFormValue: ReturnType; + }; + let profileInfoService: { + profile: ReturnType>; + applyProfileUpdated: ReturnType; + ensureProfileLoaded: ReturnType; + }; let service: OnboardingStageZeroInfoService; let profile: ReturnType>; let updateProfile: ReturnType; @@ -31,6 +42,8 @@ describe("OnboardingStageZeroInfoService", () => { userLanguages: FormArray; applySubmitModalError: ReturnType; applySkipRegistrationModalError: ReturnType; + applySetProfile: ReturnType; + applyInitStageZero: ReturnType; }; let onboardingUI: { stageSubmitting$: ReturnType; @@ -56,6 +69,12 @@ describe("OnboardingStageZeroInfoService", () => { updateProfile = vi.fn(); updateOnboardingStage = vi.fn(); applyProfileUpdated = vi.fn(); + draft = new BehaviorSubject>({}); + onboardingService = { + formValue$: draft.asObservable(), + setFormValue: vi.fn(value => draft.next(value)), + }; + profileInfoService = { profile, applyProfileUpdated, ensureProfileLoaded: vi.fn() }; stageZeroUI = { stageForm, achievements, @@ -64,6 +83,8 @@ describe("OnboardingStageZeroInfoService", () => { userLanguages, applySubmitModalError: vi.fn(), applySkipRegistrationModalError: vi.fn(), + applySetProfile: vi.fn(), + applyInitStageZero: vi.fn(), }; onboardingUI = { stageSubmitting$: signal(initial()), @@ -77,13 +98,13 @@ describe("OnboardingStageZeroInfoService", () => { { provide: ValidationService, useValue: { getFormValidation: vi.fn(() => true) } }, { provide: OnboardingService, - useValue: { formValue$: of({}), setFormValue: vi.fn() }, + useValue: onboardingService, }, { provide: OnboardingUIInfoService, useValue: onboardingUI }, { provide: OnboardingStageZeroUIInfoService, useValue: stageZeroUI }, { provide: ProfileInfoService, - useValue: { profile, applyProfileUpdated }, + useValue: profileInfoService, }, { provide: UpdateProfileUseCase, useValue: { execute: updateProfile } }, { @@ -95,6 +116,41 @@ describe("OnboardingStageZeroInfoService", () => { service = TestBed.inject(OnboardingStageZeroInfoService); }); + it("ждет профиль при прямом входе и сохраняет введенный черновик", () => { + profile.set(null); + draft.next({ city: "Город из черновика", education: [] }); + service.initializationStageZero(); + TestBed.flushEffects(); + expect(profileInfoService.ensureProfileLoaded).toHaveBeenCalledOnce(); + expect(stageZeroUI.applySetProfile).not.toHaveBeenCalled(); + + const loadedProfile = { + ...currentProfile, + personal: { avatar: "saved-avatar.png", city: "Москва" }, + relations: { + education: [{ organizationName: "Вуз" }], + workExperience: [], + userLanguages: [], + achievements: [], + }, + } as unknown as User; + profile.set(loadedProfile); + TestBed.flushEffects(); + expect(stageZeroUI.applySetProfile).toHaveBeenCalledExactlyOnceWith(loadedProfile); + expect(draft.value).toMatchObject({ + avatar: "saved-avatar.png", + city: "Город из черновика", + education: [], + }); + + profile.set({ + ...loadedProfile, + personal: { ...loadedProfile.personal, city: "Другой город" }, + }); + TestBed.flushEffects(); + expect(onboardingService.setFormValue).toHaveBeenCalledOnce(); + }); + it("onSubmit передает profile.id отдельно от данных формы", () => { updateProfile.mockReturnValue(of(ok({ id: 42 } as User))); updateOnboardingStage.mockReturnValue(of(ok({ id: 42 } as User))); diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts index ebb514d2d..3a89f7e45 100644 --- a/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/onboarding-stage-zero-info.service.ts @@ -1,7 +1,7 @@ /** @format */ -import { DestroyRef, inject, Injectable } from "@angular/core"; -import { concatMap, of } from "rxjs"; +import { DestroyRef, inject, Injectable, Injector } from "@angular/core"; +import { concatMap, filter, of, take } from "rxjs"; import { OnboardingService } from "../../onboarding.service"; import { ValidationService } from "@corelib"; import { Router } from "@angular/router"; @@ -13,7 +13,7 @@ import { AppRoutes } from "@api/paths/app-routes"; import { UpdateProfileUseCase } from "@api/auth/use-cases/update-profile.use-case"; import { UpdateOnboardingStageUseCase } from "@api/auth/use-cases/update-onboarding-stage.use-case"; import { ProfileInfoService } from "@api/profile/facades/profile-info.service"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { INVALID_PROFILE_ID_MESSAGE, isValidProfileId } from "@domain/auth/profile-id"; /** Координирует первый шаг онбординга: профильные поля, FormArray, сохранение этапа. */ @@ -22,6 +22,7 @@ export class OnboardingStageZeroInfoService { private readonly router = inject(Router); private readonly validationService = inject(ValidationService); private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); private readonly onboardingService = inject(OnboardingService); private readonly onboardingUIInfoService = inject(OnboardingUIInfoService); @@ -42,9 +43,26 @@ export class OnboardingStageZeroInfoService { private readonly skipSubmitting = this.onboardingUIInfoService.skipSubmitting$; initializationStageZero(): void { - if (this.profile()) { - this.onboardingStageZeroUIInfoService.applySetProfile(this.profile()!); - } + this.profileInfoService.ensureProfileLoaded(); + toObservable(this.profile, { injector: this.injector }) + .pipe( + filter((profile): profile is User => profile !== null), + take(1), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(profile => { + this.onboardingStageZeroUIInfoService.applySetProfile(profile); + this.onboardingService.formValue$.pipe(take(1)).subscribe(draft => { + this.onboardingService.setFormValue({ + avatar: draft.avatar ?? profile.personal.avatar, + city: draft.city ?? profile.personal.city, + education: draft.education ?? profile.relations.education, + workExperience: draft.workExperience ?? profile.relations.workExperience, + userLanguages: draft.userLanguages ?? profile.relations.userLanguages, + achievements: draft.achievements ?? profile.relations.achievements, + }); + }); + }); this.onboardingService.formValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fv => { this.onboardingStageZeroUIInfoService.applyInitStageZero(fv); diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.spec.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.spec.ts new file mode 100644 index 000000000..e68589e6d --- /dev/null +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.spec.ts @@ -0,0 +1,26 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject } from "rxjs"; +import { UserInput } from "@domain/auth/user.model"; +import { OnboardingStageOneUIInfoService } from "./onboarding-stage-one-ui-info.service"; + +describe("OnboardingStageOneUIInfoService draft synchronization", () => { + it("restores the draft silently and publishes a user change only once", () => { + TestBed.configureTestingModule({ providers: [OnboardingStageOneUIInfoService] }); + const ui = TestBed.inject(OnboardingStageOneUIInfoService); + const draft = new BehaviorSubject({ speciality: "Frontend" }); + const publish = vi.fn((value: UserInput) => { + if (publish.mock.calls.length <= 5) draft.next(value); + }); + const draftSubscription = draft.subscribe(value => ui.applyInitSpeciality(value)); + const formSubscription = ui.stageForm.valueChanges.subscribe(publish); + ui.applyInitFormValues(draft.value); + expect(publish).not.toHaveBeenCalled(); + ui.stageForm.patchValue({ speciality: "Backend" }); + expect(publish).toHaveBeenCalledOnce(); + expect(draft.value.speciality).toBe("Backend"); + formSubscription.unsubscribe(); + draftSubscription.unsubscribe(); + }); +}); diff --git a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts index 96c28cc1a..08a0cc4fd 100644 --- a/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts +++ b/projects/social_platform/src/app/api/onboarding/facades/stages/ui/onboarding-stage-one-ui-info.service.ts @@ -33,12 +33,10 @@ export class OnboardingStageOneUIInfoService { } applyInitFormValues(fv: UserInput): void { - this.stageForm.patchValue({ - speciality: fv.speciality, - }); + this.stageForm.patchValue({ speciality: fv.speciality }, { emitEvent: false }); } applyInitSpeciality(fv: UserInput): void { - this.stageForm.patchValue({ speciality: fv.speciality }); + this.stageForm.patchValue({ speciality: fv.speciality }, { emitEvent: false }); } } diff --git a/projects/social_platform/src/app/app.component.spec.ts b/projects/social_platform/src/app/app.component.spec.ts index b825b1092..af8534127 100644 --- a/projects/social_platform/src/app/app.component.spec.ts +++ b/projects/social_platform/src/app/app.component.spec.ts @@ -3,13 +3,18 @@ import { TestBed } from "@angular/core/testing"; import { AppComponent } from "./app.component"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; -import { of } from "rxjs"; -import { TokenService } from "@corelib"; +import { BehaviorSubject, of } from "rxjs"; +import { TokenService, WebsocketService } from "@corelib"; import { LoadingService } from "@api/shared/loading.service"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { Snack } from "@domain/shared/snack.model"; import { beforeEach, describe, expect, it, vi } from "vitest"; describe("AppComponent", () => { + let connectionStatus: BehaviorSubject<"idle" | "connected" | "unavailable">; + beforeEach(async () => { + connectionStatus = new BehaviorSubject<"idle" | "connected" | "unavailable">("connected"); const authPortSpy = { fetchProfile: of({} as any), fetchUserRoles: of([]), @@ -30,6 +35,10 @@ describe("AppComponent", () => { { provide: AuthRepositoryPort, useValue: authPortSpy }, { provide: TokenService, useValue: tokenSpy }, { provide: LoadingService, useValue: loadingSpy }, + { + provide: WebsocketService, + useValue: { connectionStatus$: connectionStatus.asObservable() }, + }, ], }).compileComponents(); }); @@ -39,4 +48,19 @@ describe("AppComponent", () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); + + it("не уведомляет о фоне скрытых чатов и сохраняет остальные сообщения ошибок", () => { + const snackbar = TestBed.inject(SnackbarService); + const emitted: Snack[] = []; + snackbar.snacks.subscribe(snack => emitted.push(snack)); + TestBed.createComponent(AppComponent); + + connectionStatus.next("unavailable"); + connectionStatus.next("connected"); + connectionStatus.next("unavailable"); + expect(emitted).toEqual([]); + + snackbar.error("Не удалось загрузить программу", { timeout: 0 }); + expect(emitted.map(snack => snack.text)).toEqual(["Не удалось загрузить программу"]); + }); }); diff --git a/projects/social_platform/src/app/app.component.ts b/projects/social_platform/src/app/app.component.ts index 318d4bc14..046290a4d 100644 --- a/projects/social_platform/src/app/app.component.ts +++ b/projects/social_platform/src/app/app.component.ts @@ -20,7 +20,6 @@ import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; import { LoadingService } from "@api/shared/loading.service"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AppRoutes } from "@api/paths/app-routes"; -import { ConnectionStatusToastService } from "@api/connection-status/connection-status-toast.service"; /** * Корневой компонент приложения @@ -44,10 +43,7 @@ export class AppComponent implements OnInit { private tokenService: TokenService, private router: Router, private loadingService: LoadingService, - ) { - // Инстанциируем listener для toast'а при потере WS-соединения. - inject(ConnectionStatusToastService); - } + ) {} ngOnInit(): void { const showLoaderEvents = this.router.events.pipe( @@ -89,10 +85,30 @@ export class AppComponent implements OnInit { const loadEvent = fromEvent(window, "load"); const resizeEvent = fromEvent(window, "resize").pipe(throttleTime(500)); + // visualViewport shrinks when the mobile keyboard opens; overlays must follow it. + const updateViewport = () => { + const viewport = window.visualViewport; + document.documentElement.style.setProperty( + "--visual-viewport-height", + `${viewport?.height ?? window.innerHeight}px`, + ); + document.documentElement.style.setProperty( + "--visual-viewport-top", + `${viewport?.offsetTop ?? 0}px`, + ); + }; + updateViewport(); + if (window.visualViewport) { + merge(fromEvent(window.visualViewport, "resize"), fromEvent(window.visualViewport, "scroll")) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(updateViewport); + } + merge(loadEvent, resizeEvent) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { document.documentElement.style.setProperty("--app-height", `${window.innerHeight}px`); + updateViewport(); }); } diff --git a/projects/social_platform/src/app/domain/project/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts index b08a904eb..3a1124123 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -14,6 +14,8 @@ import { Resource } from "./resource.model"; export class PartnerProgramInfo { id!: number; + /** Имя из list/detail API; optional для совместимости со старым сервером. */ + name?: string; programLinkId!: number; programId!: number; isSubmitted!: boolean; diff --git a/projects/social_platform/src/app/domain/shared/snack.model.ts b/projects/social_platform/src/app/domain/shared/snack.model.ts index 1477ad623..2fcc8e36c 100644 --- a/projects/social_platform/src/app/domain/shared/snack.model.ts +++ b/projects/social_platform/src/app/domain/shared/snack.model.ts @@ -6,4 +6,5 @@ export class Snack { text!: string; timeout!: number; type!: "error" | "success" | "info"; + dismissible?: boolean; } diff --git a/projects/social_platform/src/app/domain/shared/snackbar.service.ts b/projects/social_platform/src/app/domain/shared/snackbar.service.ts index 88c87f9f6..ea27e596a 100644 --- a/projects/social_platform/src/app/domain/shared/snackbar.service.ts +++ b/projects/social_platform/src/app/domain/shared/snackbar.service.ts @@ -11,6 +11,8 @@ import { nanoid } from "nanoid"; }) export class SnackbarService { private readonly snacks$ = new Subject(); + private readonly dismissedSubject = new Subject(); + readonly dismissed$ = this.dismissedSubject.asObservable(); snacks = this.snacks$.asObservable().pipe(distinctUntilChanged()); @@ -18,8 +20,23 @@ export class SnackbarService { this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "success" }); } - error(text: string, options: { timeout: number } = { timeout: 5000 }): void { - this.snacks$.next({ id: nanoid(), text, timeout: options.timeout, type: "error" }); + error( + text: string, + options: { timeout: number; dismissible?: boolean } = { timeout: 5000 }, + ): string { + const id = nanoid(); + this.snacks$.next({ + id, + text, + timeout: options.timeout, + type: "error", + dismissible: options.dismissible, + }); + return id; + } + + dismiss(id: string): void { + this.dismissedSubject.next(id); } info(text: string, options: { timeout: number } = { timeout: 5000 }): void { diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts index 137444505..438c44706 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project-program.dto.ts @@ -2,6 +2,7 @@ export interface PartnerProgramInfoDto { id: number; + name?: string; programLinkId: number; programId: number; isSubmitted: boolean; diff --git a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts index 4113524cc..23d03d8ea 100644 --- a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.spec.ts @@ -69,6 +69,10 @@ describe("ChatRealtimeRepository", () => { expect(adapter.connect).toHaveBeenCalledExactlyOnceWith(); }); + it("не создаёт второе уведомление об обрыве из транспортного repository", () => { + expect(snackbarService.error).not.toHaveBeenCalled(); + }); + it("делегирует send/edit/delete/read/startTyping в WS adapter", () => { const sendPayload = {} as never; const editPayload = {} as never; diff --git a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts index 754f6809d..a78ab8618 100644 --- a/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/chat/chat-realtime.repository.ts @@ -13,25 +13,12 @@ import { } from "@domain/chat/chat.model"; import { plainToInstance } from "class-transformer"; import { filter, map } from "rxjs"; -import { WebsocketService } from "@core/public-api"; -import { SnackbarService } from "@domain/shared/snackbar.service"; import { mapChatMessage } from "./chat.repository"; /** ChatRealtimePort поверх WebSocket-адаптера: команды проксируются в сокет, входящие события типизируются. */ @Injectable({ providedIn: "root" }) export class ChatRealtimeRepository implements ChatRealtimePort { private readonly chatWsAdapter = inject(ChatWsAdapter); - private readonly websocketService = inject(WebsocketService); - private readonly snackbarService = inject(SnackbarService); - - constructor() { - this.websocketService.connectionLost$.subscribe({ - next: () => { - this.snackbarService.error("Соединение потеряно"); - }, - }); - } - connect() { return this.chatWsAdapter.connect(); } diff --git a/projects/social_platform/src/app/ui/pages/auth/auth.component.scss b/projects/social_platform/src/app/ui/pages/auth/auth.component.scss index 08453bf94..b235d38f6 100644 --- a/projects/social_platform/src/app/ui/pages/auth/auth.component.scss +++ b/projects/social_platform/src/app/ui/pages/auth/auth.component.scss @@ -1,5 +1,4 @@ /** @format */ - @use "styles/responsive"; .page { @@ -25,3 +24,20 @@ height: 100%; } } + +// Адаптив из dev применяется только ниже desktop breakpoint. +@media screen and (max-width: 999px) { + @include responsive.apply-below-desktop { + .page { + height: auto; + min-height: 100dvh; + padding: 0 var(--page-gutter); + } + + .page__body { + justify-content: flex-start; + height: auto; + min-height: 100dvh; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts index 5b96be168..f471968b8 100644 --- a/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/confirm-password-reset/confirm-password-reset.component.ts @@ -3,13 +3,14 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; import { AsyncPipe } from "@angular/common"; import { AuthPasswordService } from "@api/auth/facades/auth-password.service"; +import { AuthUIInfoService } from "@api/auth/facades/ui/auth-ui-info.service"; /** Страница подтверждения отправки письма для сброса пароля. */ @Component({ selector: "app-confirm-password-reset", templateUrl: "./confirm-password-reset.component.html", styleUrl: "./confirm-password-reset.component.scss", - providers: [AuthPasswordService], + providers: [AuthPasswordService, AuthUIInfoService], imports: [AsyncPipe], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html index d645335ef..b74c7f6e2 100644 --- a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.html @@ -1,41 +1,87 @@ - -
- -
-
-
-

Мы отправили ссылку подтверждения

-

- Для продолжения регистрации и заполнения резюме подтверди свою почту, для этого мы - отправили тебе письмо со ссылкой подтверждения -

-
- @if (counter() > 0) { -
- Письмо отправлено, отправить еще раз через {{ counter() }} -
- } @else { -
-

Не получил письмо? Проверь папку спам или

- + +@if (releaseDesktop()) { +
+ +
+
+
+

Мы отправили ссылку подтверждения

+

+ Для продолжения регистрации и заполнения резюме подтверди свою почту, для этого мы + отправили тебе письмо со ссылкой подтверждения +

- } + @if (counter() > 0) { +
+ Письмо отправлено, отправить еще раз через {{ counter() }} +
+ } @else { +
+

Не получил письмо? Проверь папку спам или

+ +
+ } +
+ email
+
+} @else { +
email -
-
+
+
+
+

Мы отправили ссылку подтверждения

+

+ Для продолжения регистрации и заполнения резюме подтверди свою почту, для этого мы + отправили тебе письмо со ссылкой подтверждения +

+
+ @if (counter() > 0) { +
+ Письмо отправлено, отправить еще раз через {{ counter() }} +
+ } @else { +
+

Не получил письмо? Проверь папку спам или

+ +
+ } +
+ email +
+ +} diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss index c79e6ee7a..d212ab59e 100644 --- a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.scss @@ -1,5 +1,4 @@ /** @format */ - @use "styles/responsive"; @use "styles/typography"; @@ -51,3 +50,21 @@ color: var(--dark-grey); } } + +// Адаптив из dev применяется только ниже desktop breakpoint. +@media screen and (max-width: 999px) { + @include responsive.apply-below-desktop { + .verification__img { + position: static; + width: 100%; + min-width: 0; + max-height: 40dvh; + object-fit: contain; + } + + .verification__action { + flex-wrap: wrap; + gap: 12px; + } + } +} diff --git a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts index 4a08a1c5b..104904ea8 100644 --- a/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts +++ b/projects/social_platform/src/app/ui/pages/auth/email-verification/email-verification.component.ts @@ -1,5 +1,10 @@ /** @format */ +import { inject as injectReleaseLayout } from "@angular/core"; +import { DesktopLayoutService as ReleaseDesktopLayoutService } from "../../../../../../../ui/src/lib/services/desktop-layout.service"; + +import { ButtonDirective } from "@uilib"; + import { ChangeDetectionStrategy, Component, inject, OnInit } from "@angular/core"; import { IconComponent } from "@ui/primitives"; import { AuthEmailService } from "@api/auth/facades/auth-email.service"; @@ -11,10 +16,12 @@ import { CommonModule } from "@angular/common"; templateUrl: "./email-verification.component.html", styleUrl: "./email-verification.component.scss", providers: [AuthEmailService], - imports: [CommonModule, IconComponent], + imports: [ButtonDirective, CommonModule, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmailVerificationComponent implements OnInit { + protected readonly releaseDesktop = injectReleaseLayout(ReleaseDesktopLayoutService).desktop; + private readonly authEmailService = inject(AuthEmailService); protected readonly counter = this.authEmailService.counter; diff --git a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html index 89005389e..5f1ec5236 100644 --- a/projects/social_platform/src/app/ui/pages/auth/login/login.component.html +++ b/projects/social_platform/src/app/ui/pages/auth/login/login.component.html @@ -1,175 +1,355 @@ + +@if (releaseDesktop()) { +