Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/modal-acknowledgements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!-- @format -->

# Одноразовые пользовательские окна

## Источники состояния

Согласие на cookie относится к браузеру и хранится локально в ключе `cookieConsent`.
Значения `accepted` и `declined` одинаково закрывают баннер при следующих загрузках. Только
`accepted` разрешает загрузку необязательной аналитики.

Остальные одноразовые окна относятся к аккаунту и синхронизируются с backend:

- ожидание верификации — `verificationNoticeAcknowledgedAt` текущего пользователя;
- предложение заполнить профиль — `profileFillPromptAcknowledgedAt` текущего пользователя;
- приветствие программы — `welcomeAcknowledgedAt` членства пользователя в программе.

Frontend не записывает эти состояния в `localStorage`. Загрузка страницы только читает
серверные признаки и не подтверждает показ автоматически. Timestamp появляется после явного
закрытия окна или нажатия его основной кнопки.

## Пользовательские правила

- Ожидание верификации показывается, пока аккаунт не верифицирован и уведомление не подтверждено.
- Предложение заполнить профиль показывается только владельцу неполного профиля.
- Приветствие показывается только участнику конкретной программы и только в её detail-разделе.
- Повторный вызов любого подтверждения безопасен и возвращает уже зафиксированное состояние.
- Подтверждения аккаунта работают одинаково на разных устройствах и не переносятся между
пользователями; приветствие программы дополнительно изолировано по программе.

## Используемые API

- `POST /auth/users/current/acknowledge-verification-notice/`;
- `POST /auth/users/current/acknowledge-profile-fill-prompt/`;
- `POST /programs/<program_id>/acknowledge-welcome/`.

Дублирующее приветствие на главной странице офиса удалено. Единственный сценарий приветствия
остаётся на detail-странице программы.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @format */

import { DestroyRef, inject, Injectable, Injector } from "@angular/core";
import { catchError, EMPTY, filter, tap } from "rxjs";
import { catchError, EMPTY, filter, finalize, tap } from "rxjs";
import { Router } from "@angular/router";
import { OfficeUIInfoService } from "./ui/office-ui-info.service";
import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port";
Expand Down Expand Up @@ -60,8 +60,6 @@ export class OfficeInfoService {
if (!this.router.url.includes("chats")) {
this.chatUnreadState.ensureLoaded();
}

this.officeUIInfoService.applyVerificationModal();
}

private initializationNavItems(): void {
Expand All @@ -78,13 +76,31 @@ export class OfficeInfoService {
.then(() => this.logger.debug("Route changed from OfficeComponent"));
} else if (
profile!.relations.verificationDate === null &&
localStorage.getItem("waitVerificationAccepted") !== "true"
profile!.relations.verificationNoticeAcknowledgedAt === null
) {
this.officeUIInfoService.applyOpenVerificationModal();
}
});
}

onAcknowledgeVerificationNotice(): void {
if (this.officeUIInfoService.verificationAcknowledgementPending()) return;

this.officeUIInfoService.verificationAcknowledgementPending.set(true);
this.authRepository
.acknowledgeVerificationNotice()
.pipe(
finalize(() => this.officeUIInfoService.verificationAcknowledgementPending.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: profile => {
this.profileInfoService.applyProfileUpdated(profile);
this.officeUIInfoService.applyVerificationAcknowledged();
},
});
}

private initializationStatus(): void {
this.connectChatUseCase.execute().pipe(takeUntilDestroyed(this.destroyRef)).subscribe();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,13 @@ import { Injectable, inject, signal } from "@angular/core";
@Injectable()
export class OfficeUIInfoService {
readonly waitVerificationModal = signal<boolean>(false);
readonly waitVerificationAccepted = signal<boolean>(false);
readonly verificationAcknowledgementPending = signal<boolean>(false);
readonly inviteErrorModal = signal<boolean>(false);

readonly navItems = signal<
{ name: string; link: string; icon: string; isExternal?: boolean; isActive?: boolean }[]
>([]);

applyVerificationModal(): void {
// Не показываем модалку, если пользователь уже принял подтверждение
if (localStorage.getItem("waitVerificationAccepted") === "true") {
// eslint-disable-next-line no-useless-return
return;
}
}

applyOpenVerificationModal(): void {
this.waitVerificationModal.set(true);
}
Expand All @@ -29,9 +21,8 @@ export class OfficeUIInfoService {
this.inviteErrorModal.set(true);
}

applyAcceptWaitVerification() {
this.waitVerificationAccepted.set(true);
localStorage.setItem("waitVerificationAccepted", "true");
applyVerificationAcknowledged(): void {
this.waitVerificationModal.set(false);
}

applyCreateNavItems(profileId: number): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class ProfileDetailInfoService {
)
.subscribe({
next: data => {
this.profileDetailUIInfoService.applyInitProfile(data);
this.profileDetailUIInfoService.applyInitProfile(data, this.profile()?.id);
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** @format */

import { ProfileDetailUIInfoService } from "./profile-detail-ui-info.service";
import { User } from "@domain/auth/user.model";

function createUser(id: number, acknowledgedAt: string | null = null): User {
return {
id,
relations: {
progress: 40,
skills: [],
achievements: [],
profileFillPromptAcknowledgedAt: acknowledgedAt,
},
} as User;
}

describe("ProfileDetailUIInfoService", () => {
it("показывает предложение заполнить только собственный неполный профиль", () => {
const service = new ProfileDetailUIInfoService();

service.applyInitProfile({ data: { user: createUser(7) } }, 7);

expect(service.isProfileFill()).toBe(true);
});

it("не показывает предложение в чужом профиле", () => {
const service = new ProfileDetailUIInfoService();

service.applyInitProfile({ data: { user: createUser(7) } }, 8);

expect(service.isProfileFill()).toBe(false);
});

it("не показывает уже подтверждённое предложение на другом устройстве", () => {
const service = new ProfileDetailUIInfoService();

service.applyInitProfile({ data: { user: createUser(7, "2026-08-24T10:00:00Z") } }, 7);

expect(service.isProfileFill()).toBe(false);
});

it("закрывает предложение только после подтверждения", () => {
const service = new ProfileDetailUIInfoService();
service.applyInitProfile({ data: { user: createUser(7) } }, 7);

service.applyProfileFillAcknowledged("2026-08-24T10:00:00Z");

expect(service.isProfileFill()).toBe(false);
expect(service.user()?.relations.profileFillPromptAcknowledgedAt).toBe("2026-08-24T10:00:00Z");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@ export class ProfileDetailUIInfoService {
readonly directions = signal<DirectionItem[]>([]);
readonly isShowModal = signal<boolean>(false);

applyInitProfile(data: any): void {
applyInitProfile(data: any, currentUserId?: number): void {
const userWithProgress = data["data"]["user"] as User;
this.user.set(userWithProgress);
this.initializationDirections(userWithProgress);

if (
userWithProgress?.id !== undefined &&
currentUserId !== undefined &&
userWithProgress?.id === currentUserId &&
userWithProgress.relations.progress! < 100 &&
!this.hasSeenProfileFillModal(userWithProgress.id)
userWithProgress.relations.profileFillPromptAcknowledgedAt === null
) {
this.isProfileFill.set(true);
this.markSeenProfileFillModal(userWithProgress.id);
} else {
this.isProfileFill.set(false);
}
Expand All @@ -55,6 +55,20 @@ export class ProfileDetailUIInfoService {
this.isShowModal.set(true);
}

applyProfileFillAcknowledged(acknowledgedAt: string): void {
this.user.update(user =>
user
? Object.assign(new User(), user, {
relations: {
...user.relations,
profileFillPromptAcknowledgedAt: acknowledgedAt,
},
})
: user,
);
this.isProfileFill.set(false);
}

private initializationDirections(user: User): void {
this.directions.set(
directionItemBuilder(
Expand All @@ -66,24 +80,4 @@ export class ProfileDetailUIInfoService {
)!.filter(item => !Array.isArray(item.about) || item.about.length > 0),
);
}

private getProfileFillSeenKey(userId: number): string {
return `profile_${userId}_fill_modal_seen`;
}

private hasSeenProfileFillModal(userId: number): boolean {
try {
return !!localStorage.getItem(this.getProfileFillSeenKey(userId));
} catch (e) {
return false;
}
}

private markSeenProfileFillModal(userId: number): void {
try {
localStorage.setItem(this.getProfileFillSeenKey(userId), "1");
} catch (e) {
// ignore storage errors
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { DestroyRef, ElementRef, inject, Injectable, signal } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { concatMap, fromEvent, map, of, tap, throttleTime } from "rxjs";
import { concatMap, EMPTY, finalize, fromEvent, map, of, tap, throttleTime } from "rxjs";
import { FeedNews } from "@domain/news/project-news.model";
import { LoadingService } from "@api/shared/loading.service";
import { ExpandService } from "../../../expand/expand.service";
Expand All @@ -16,6 +16,7 @@ import { DeleteNewsUseCase } from "../../use-cases/delete-news.use-case";
import { ToggleLikeUseCase } from "../../use-cases/toggle-like.use-case";
import { EditNewsUseCase } from "../../use-cases/edit-news.use-case";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { AcknowledgeProgramWelcomeUseCase } from "../../use-cases/acknowledge-program-welcome.use-case";

/** Координирует основную вкладку программы: данные резолвера, новости, пагинация. */
@Injectable()
Expand All @@ -35,6 +36,7 @@ export class ProgramDetailMainService {
private readonly deleteNewsUseCase = inject(DeleteNewsUseCase);
private readonly toggleLikeUseCase = inject(ToggleLikeUseCase);
private readonly editNewsUseCase = inject(EditNewsUseCase);
private readonly acknowledgeProgramWelcomeUseCase = inject(AcknowledgeProgramWelcomeUseCase);

private observer?: IntersectionObserver;

Expand Down Expand Up @@ -247,6 +249,24 @@ export class ProgramDetailMainService {
this.loadingService.hide();
}

acknowledgeProgramWelcome() {
if (this.programDetailMainUIInfoService.welcomeAcknowledgementPending()) return EMPTY;

const programId = Number(this.programId());
if (!Number.isInteger(programId) || programId <= 0) return EMPTY;

this.programDetailMainUIInfoService.welcomeAcknowledgementPending.set(true);
return this.acknowledgeProgramWelcomeUseCase.execute(programId).pipe(
tap(result => {
if (!result.ok) return;
this.programDetailMainUIInfoService.applyProgramWelcomeAcknowledged(
result.value.welcomeAcknowledgedAt,
);
}),
finalize(() => this.programDetailMainUIInfoService.welcomeAcknowledgementPending.set(false)),
);
}

private setupNewsObserver(): void {
this.observer?.disconnect();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** @format */

import { ProgramDetailMainUIInfoService } from "./program-detail-main-ui-info.service";
import { Program } from "@domain/program/program.model";

function createProgram(id: number, acknowledgedAt: string | null = null): Program {
return {
...Program.default(),
id,
isUserMember: true,
datetimeRegistrationEnds: "2099-01-01T00:00:00Z",
welcomeAcknowledgedAt: acknowledgedAt,
};
}

describe("ProgramDetailMainUIInfoService", () => {
it("показывает приветствие участнику до явного подтверждения", () => {
const service = new ProgramDetailMainUIInfoService();

service.applyFormatingProgramData(createProgram(1));

expect(service.registeredProgramModal()).toBe(true);
});

it("не показывает подтверждённое приветствие на другом устройстве", () => {
const service = new ProgramDetailMainUIInfoService();

service.applyFormatingProgramData(createProgram(1, "2026-08-24T10:00:00Z"));

expect(service.registeredProgramModal()).toBe(false);
});

it("не переносит подтверждение между программами", () => {
const service = new ProgramDetailMainUIInfoService();
service.applyFormatingProgramData(createProgram(1, "2026-08-24T10:00:00Z"));

service.applyFormatingProgramData(createProgram(2));

expect(service.registeredProgramModal()).toBe(true);
});

it("закрывает приветствие после ответа backend", () => {
const service = new ProgramDetailMainUIInfoService();
service.applyFormatingProgramData(createProgram(1));

service.applyProgramWelcomeAcknowledged("2026-08-24T10:00:00Z");

expect(service.registeredProgramModal()).toBe(false);
expect(service.program()?.welcomeAcknowledgedAt).toBe("2026-08-24T10:00:00Z");
});
});
Loading