diff --git a/public/csv/identity_users_import_template.xlsx b/public/csv/identity_users_import_template.xlsx index 0c9c8a5d..dfd05e8f 100644 Binary files a/public/csv/identity_users_import_template.xlsx and b/public/csv/identity_users_import_template.xlsx differ diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 21670258..35b64a26 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -144,8 +144,6 @@ export const routes: Routes = [ import('./features/organization/organization.module').then( (m) => m.OrganizationModule ), - data: { roles: ['ADMIN'] }, - canActivate: [RoleGuard], }, ], }, diff --git a/src/app/core/interceptors/config/ignoreApiHandleError.ts b/src/app/core/interceptors/config/ignoreApiHandleError.ts index 1213e3ea..b797426a 100644 --- a/src/app/core/interceptors/config/ignoreApiHandleError.ts +++ b/src/app/core/interceptors/config/ignoreApiHandleError.ts @@ -4,6 +4,8 @@ export const IGNORE_ERROR_NOTIFICATION_URLS = [ '/submission/exercise/quiz/', // Bỏ qua tất cả endpoint chứa pattern này '/profile/user/', '/submission/exercise/coding/', + '/notification/', + '/ai/chat/', // Ví dụ pattern regex: /\/submission\/exercise\/quiz\/\d+(\?.*)?$/ // Thêm các endpoint khác nếu cần ]; diff --git a/src/app/core/models/comment.models.ts b/src/app/core/models/comment.models.ts index 1bc5fe5b..c32c0005 100644 --- a/src/app/core/models/comment.models.ts +++ b/src/app/core/models/comment.models.ts @@ -32,6 +32,7 @@ export interface CommentResponse { content: string; replies?: CommentResponse[] | []; // đệ quy user: User; + createdAt: string; } export interface AddCommentResponse { diff --git a/src/app/core/models/post.models.ts b/src/app/core/models/post.models.ts index 938fb2ee..8dd3da33 100644 --- a/src/app/core/models/post.models.ts +++ b/src/app/core/models/post.models.ts @@ -71,7 +71,7 @@ export interface postData { } export type PostType = 'Global' | 'Private' | 'Org'; export interface FileDocument { - file?: File; + file?: File[]; category?: string; // BE mong muốn STRING description?: string; tags?: string[]; // mảng -> sẽ append tags[0], tags[1]... @@ -102,7 +102,7 @@ export interface CreatePostRequest { hashtag?: string; fileDocument?: { - file?: File; + files?: File[]; category?: string; description?: string; tags?: string[]; diff --git a/src/app/core/models/socket-data.model.ts b/src/app/core/models/socket-data.model.ts new file mode 100644 index 00000000..b05034f5 --- /dev/null +++ b/src/app/core/models/socket-data.model.ts @@ -0,0 +1,8 @@ +export interface NotificationEvent { + channel: string; + recipient: string; + templateCode: string; + param: Record; + subject: string; + body: string; +} diff --git a/src/app/core/router-manager/horizontal-menu.ts b/src/app/core/router-manager/horizontal-menu.ts index c0a792d4..c589483c 100644 --- a/src/app/core/router-manager/horizontal-menu.ts +++ b/src/app/core/router-manager/horizontal-menu.ts @@ -53,10 +53,12 @@ export function getNavHorizontalItems(roles: string[]): SidebarItem[] { }, { id: 'organization ', - path: '/organization/orgs-list', + path: roles.includes('ADMIN') + ? '/organization/orgs-list' + : '/organization/org-list-post', label: 'Tổ chức', icon: 'fa-solid fa-building-user', - isVisible: !roles.includes(auth_lv2[0]), + // isVisible: !roles.includes(auth_lv2[0]), }, ]; } diff --git a/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts b/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts index a00787b2..2fb4fb60 100644 --- a/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts +++ b/src/app/core/router-manager/vetical-menu-dynamic/org-vertical-menu.ts @@ -1,12 +1,27 @@ import { SidebarItem } from '../../models/data-handle'; export function sidebarOrgRouter(roles: string[]): SidebarItem[] { + const auth_lv2 = ['ADMIN']; + return [ { id: 'list-orgs', path: '/organization/orgs-list', label: 'Danh sách tổ chức', icon: 'fa-solid fa-tasks', + isVisible: !roles.includes(auth_lv2[0]), + }, + { + id: 'list-posts-org', + path: '/organization/org-list-post', + label: 'Danh sách bài viết', + icon: 'fa-solid fa-newspaper', + }, + { + id: 'list-exercise-org', + path: '/organization/org-list-exercise', + label: 'Danh sách bài tập', + icon: 'fa-solid fa-book', }, ]; } diff --git a/src/app/core/services/api-service/notification-list.service.ts b/src/app/core/services/api-service/notification-list.service.ts index 758cbbb6..84fd8dda 100644 --- a/src/app/core/services/api-service/notification-list.service.ts +++ b/src/app/core/services/api-service/notification-list.service.ts @@ -31,4 +31,10 @@ export class NotificationListService { Ids ); } + + countMyUnread() { + return this.api.get>( + API_CONFIG.ENDPOINTS.GET.GET_COUNT_MY_UNREAD + ); + } } diff --git a/src/app/core/services/api-service/organization.service.ts b/src/app/core/services/api-service/organization.service.ts index 339c5ddd..6bbaaa22 100644 --- a/src/app/core/services/api-service/organization.service.ts +++ b/src/app/core/services/api-service/organization.service.ts @@ -54,7 +54,7 @@ export class OrganizationService { return this.api.patchWithFormData>( API_CONFIG.ENDPOINTS.PATCH.EDIT_ORG(orgId), otherData, - logo + { logo } ); } diff --git a/src/app/core/services/api-service/post.service.ts b/src/app/core/services/api-service/post.service.ts index bc54943e..5085de60 100644 --- a/src/app/core/services/api-service/post.service.ts +++ b/src/app/core/services/api-service/post.service.ts @@ -94,16 +94,19 @@ export class PostService { createPost(data: CreatePostRequest) { const { fileDocument, ...otherData } = data; - // Chuẩn bị files và form data - const files: { [fieldName: string]: File } = {}; - if (fileDocument?.file) { - files['fileDocument.file'] = fileDocument.file; //tên key cần khớp backend + // Gom các field còn lại vào data (lọc undefined/null) + const formDataData: Record = {}; + for (const [key, value] of Object.entries(otherData)) { + if (value !== undefined && value !== null) { + formDataData[key] = value; + } } - // Gom các field còn lại vào data - const formDataData: Record = { - ...otherData, - }; + // Chuẩn bị files + const files: { [fieldName: string]: File[] } = {}; + if (fileDocument?.files) { + files['fileDocument.files'] = fileDocument.files; // key cần khớp backend + } if (fileDocument) { const fd: Record = { @@ -115,19 +118,18 @@ export class PostService { 'fileDocument.orgId': fileDocument.orgId, }; - // Chỉ append field nào có giá trị khác undefined for (const [key, value] of Object.entries(fd)) { - if (value !== undefined) { + if (value !== undefined && value !== null) { formDataData[key] = value; } } } - // Gọi method postWithFormData + // Gọi API return this.api.postWithFormData>( API_CONFIG.ENDPOINTS.POST.ADD_POST, formDataData, - files + Object.keys(files).length ? files : undefined // chỉ gửi nếu có file ); } diff --git a/src/app/core/services/config-service/api.enpoints.ts b/src/app/core/services/config-service/api.enpoints.ts index 1aff9d7b..a99a04bd 100644 --- a/src/app/core/services/config-service/api.enpoints.ts +++ b/src/app/core/services/config-service/api.enpoints.ts @@ -207,6 +207,7 @@ export const API_CONFIG = { readStatus: ReadStatusNotice ) => `/notification/my?page=${page}&size=${size}&readStatus=${readStatus}`, + GET_COUNT_MY_UNREAD: '/notification/my/unread-count', }, POST: { LOGIN: '/identity/auth/login', diff --git a/src/app/core/services/config-service/api.methods.ts b/src/app/core/services/config-service/api.methods.ts index e155b55d..eaa43dd0 100644 --- a/src/app/core/services/config-service/api.methods.ts +++ b/src/app/core/services/config-service/api.methods.ts @@ -200,7 +200,7 @@ export class ApiMethod { patchWithFormData( endpoint: string, data?: Record, - files?: File | { [fieldName: string]: File | File[] }, + files?: File | { [fieldName: string]: File | File[] | undefined }, apiType: 'MAIN_API' | 'SECONDARY_API' = 'MAIN_API' ): Observable { const url = `${API_CONFIG.BASE_URLS[apiType]}${endpoint}`; @@ -221,8 +221,12 @@ export class ApiMethod { Object.keys(files).forEach((fieldName) => { const fileItem = files[fieldName]; if (Array.isArray(fileItem)) { - fileItem.forEach((file) => formData.append(fieldName, file)); - } else { + fileItem.forEach((file) => { + if (file !== undefined && file !== null) { + formData.append(fieldName, file); + } + }); + } else if (fileItem !== undefined && fileItem !== null) { formData.append(fieldName, fileItem); } }); diff --git a/src/app/core/services/socket-service/port-socket.ts b/src/app/core/services/config-socket/port-socket.ts similarity index 65% rename from src/app/core/services/socket-service/port-socket.ts rename to src/app/core/services/config-socket/port-socket.ts index 19b4121e..1ff21d66 100644 --- a/src/app/core/services/socket-service/port-socket.ts +++ b/src/app/core/services/config-socket/port-socket.ts @@ -1,2 +1,3 @@ export const CODE_COMPILER_SOCKET = 'http://localhost:4098'; export const CONVERSATION_CHAT_SOCKET = 'http://localhost:4099'; +export const NOTIFICATION_SOCKET_PORT = 'http://localhost:4101'; diff --git a/src/app/core/services/socket-service/socketConnection.service.ts b/src/app/core/services/config-socket/socketConnection.service.ts similarity index 100% rename from src/app/core/services/socket-service/socketConnection.service.ts rename to src/app/core/services/config-socket/socketConnection.service.ts diff --git a/src/app/core/services/socket-service/notification-socket.service.ts b/src/app/core/services/socket-service/notification-socket.service.ts new file mode 100644 index 00000000..8b9adff5 --- /dev/null +++ b/src/app/core/services/socket-service/notification-socket.service.ts @@ -0,0 +1,36 @@ +// notification.service.ts +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { SocketConnectionService } from '../config-socket/socketConnection.service'; +import { NOTIFICATION_SOCKET_PORT } from '../config-socket/port-socket'; + +export interface NotificationEvent { + channel: string; + recipient: string; + templateCode: string; + param: Record; + subject: string; + body: string; +} + +@Injectable({ providedIn: 'root' }) +export class NotificationSocketService { + private readonly url = `${NOTIFICATION_SOCKET_PORT}?token=${localStorage.getItem( + 'token' + )}`; + + constructor(private socketService: SocketConnectionService) { + this.socketService.connect(this.url); + } + + listenNotifications(): Observable { + return this.socketService.on(this.url, 'notification'); + } + + listenNoticeCount(): Observable<{ unread: number }> { + return this.socketService.on<{ unread: number }>( + this.url, + 'notification-unread' + ); + } +} diff --git a/src/app/features/admin/user-management/pages/user-list/user-list.html b/src/app/features/admin/user-management/pages/user-list/user-list.html index 45d2ddf9..4651e419 100644 --- a/src/app/features/admin/user-management/pages/user-list/user-list.html +++ b/src/app/features/admin/user-management/pages/user-list/user-list.html @@ -21,11 +21,12 @@ [disabled]="false" [multiSelect]="true" [isDisplayCheckbox]="false" + [removeSingleSelected]="true" (onSelect)="handleSelect(filterRoleKey, $event)" [isOpen]="activeDropdown === filterRoleKey" (toggle)="toggleDropdown(filterRoleKey)" [isDisplaySelectedOpptionLabels]="true" - [isButtonControl]="true" + [isButtonControl]="false" [isSearchable]="true" [minHeight]="true" [needIndexColor]="true" @@ -132,7 +133,7 @@ [amountDataPerPage]="itemsPerPage" [needNo]="true" [needDelete]="true" - [needEdit]="true" + [needEdit]="false" [needViewResult]="false" [needswitch]="true" [idSelect]="'userId'" diff --git a/src/app/features/admin/user-management/pages/user-list/user-list.ts b/src/app/features/admin/user-management/pages/user-list/user-list.ts index 00565b9a..0f361e64 100644 --- a/src/app/features/admin/user-management/pages/user-list/user-list.ts +++ b/src/app/features/admin/user-management/pages/user-list/user-list.ts @@ -118,7 +118,7 @@ export class UserListComponent { // Pagination pageIndex: number = 1; - itemsPerPage: number = 8; + itemsPerPage: number = 10; totalDatas: number = 0; sortBy: EnumType['sort'] = 'CREATED_AT'; asc: boolean = false; diff --git a/src/app/features/conversation-chat/pages/chat/chat.component.ts b/src/app/features/conversation-chat/pages/chat/chat.component.ts index 6133c21e..b630d3c7 100644 --- a/src/app/features/conversation-chat/pages/chat/chat.component.ts +++ b/src/app/features/conversation-chat/pages/chat/chat.component.ts @@ -9,10 +9,10 @@ import { } from '@angular/core'; import { Subscription } from 'rxjs'; import { ChatService } from '../../../../core/services/api-service/chat-conversation.service'; -import { SocketConnectionService } from '../../../../core/services/socket-service/socketConnection.service'; +import { SocketConnectionService } from '../../../../core/services/config-socket/socketConnection.service'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { CONVERSATION_CHAT_SOCKET } from '../../../../core/services/socket-service/port-socket'; +import { CONVERSATION_CHAT_SOCKET } from '../../../../core/services/config-socket/port-socket'; import { Conversation, ConversationEvent, diff --git a/src/app/features/excercise/exercise-modal/update-exercise/update-exercise.component.html b/src/app/features/excercise/exercise-modal/update-exercise/update-exercise.component.html index ad1bcc04..738f4fd1 100644 --- a/src/app/features/excercise/exercise-modal/update-exercise/update-exercise.component.html +++ b/src/app/features/excercise/exercise-modal/update-exercise/update-exercise.component.html @@ -2,14 +2,14 @@ class="modal-overlay" [class.open]="isOpen" (click)="onOverlayClick($event)" - > +> + }} + + } + + - +
+ + + @if ( exerciseForm.get('cost')?.touched && + exerciseForm.get('cost')?.invalid ) { +
Chi phí là bắt buộc và phải >= 0
+ }
+ - -
-
-
- - -
+ +
-
- - -
+ +
+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
+ +
+ + +
-
+ -
- - -
+
+ + +
-
- -
-
+
+ +
+
- -
-
- - - + + + + + + diff --git a/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html b/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html index 06981985..426a6300 100644 --- a/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html +++ b/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html @@ -171,12 +171,12 @@

{{ exercise.title }}

- @if (!isBought && exercise.cost > 0) { + @if ((!isBought && exercise.cost > 0) && !isActionActive) { - } @if ( hasQuestions && canStartDoing && (exercise.cost <= 0 ? true : - isBought) ) { + } @if ( (hasQuestions && canStartDoing && (exercise.cost <= 0 ? true : + isBought)) || isActionActive ) { diff --git a/src/app/features/organization/organization-component/details-organization/details-organization.component.ts b/src/app/features/organization/organization-component/details-organization/details-organization.component.ts index 342c9a1f..2798c39d 100644 --- a/src/app/features/organization/organization-component/details-organization/details-organization.component.ts +++ b/src/app/features/organization/organization-component/details-organization/details-organization.component.ts @@ -97,9 +97,9 @@ export class DetailsOrganizationComponent implements OnInit { } onFileChange(event: any) { - const file = event.target.files[0]; - if (file) { - this.editForm.logo = file; + const logo = event.target.files[0]; + if (logo) { + this.editForm.logo = logo; } } diff --git a/src/app/features/organization/pages/org-blocks/org-blocks.component.ts b/src/app/features/organization/pages/org-blocks/org-blocks.component.ts index 210720a5..b5ac22ff 100644 --- a/src/app/features/organization/pages/org-blocks/org-blocks.component.ts +++ b/src/app/features/organization/pages/org-blocks/org-blocks.component.ts @@ -86,7 +86,12 @@ export class OrgBlocksComponent implements OnInit { next: (res) => { const result = res.result; this.totalPages = result.totalPages; - this.blocks = loadMore ? [...this.blocks, ...result.data] : result.data; + this.blocks = loadMore + ? [ + ...this.blocks, + ...result.data.filter((data) => data.code !== 'UNASSIGNED'), + ] + : result.data.filter((data) => data.code !== 'UNASSIGNED'); this.isLoading = false; }, error: () => (this.isLoading = false), diff --git a/src/app/features/post/pages/post-create/post-create.html b/src/app/features/post/pages/post-create/post-create.html index 151ebf09..596c316b 100644 --- a/src/app/features/post/pages/post-create/post-create.html +++ b/src/app/features/post/pages/post-create/post-create.html @@ -81,190 +81,182 @@ name="visibility" [(ngModel)]="post.postType" value="Org" - /> - Nội bộ - - - -
+ /> + Nội bộ + + + + + + +
+ Bình luận +
+ + +
+
+ + +
+ +
+
+ Hủy + Lưu Nháp + Đăng Bài +
+ +
+ @if (drafts.length > 0) { +
+
+

Bản nháp đã lưu

+ {{ drafts.length }} bản nháp +
+
    + @for (draft of drafts; track draft) { +
  • +
    +
    + {{ draft.title }} + + {{ draft.timestamp | date:'short' }} +
    +
    +
    + +
    +
  • + } +
+
+ } -
- Bình luận -
- - -
-
-
+
+
+
-
- @if (drafts.length > 0) { -
-
-

Bản nháp đã lưu

- {{ drafts.length }} bản nháp -
-
    - @for (draft of drafts; track draft) { -
  • -
    -
    - {{ draft.title }} - - {{ draft.timestamp | date:'short' }} - -
    -
    -
    - -
    -
  • - } -
-
- } + + + + + + + Chọn file + + + @if (filePreviews) { +
+
+ + + -
-
-
+
- - - - - - - Chọn file - - - @if (filePreview) { -
-
- @if (isImageFile) { - Xem trước ảnh - } - @if (isVideoFile) { - - } - -
-
- -
-
- } -
-
- + } + + + diff --git a/src/app/features/post/pages/post-create/post-create.ts b/src/app/features/post/pages/post-create/post-create.ts index 137119ec..e1e7782f 100644 --- a/src/app/features/post/pages/post-create/post-create.ts +++ b/src/app/features/post/pages/post-create/post-create.ts @@ -48,8 +48,8 @@ export interface Draft { DropdownButtonComponent, ButtonComponent, FormsModule, - CommonModule -], + CommonModule, + ], }) export class PostCreatePageComponent { @ViewChild('linkInput') linkInput!: ElementRef; @@ -99,8 +99,15 @@ export class PostCreatePageComponent { ) {} // ===== File (1 ảnh) ===== - selectedFile: File | null = null; - filePreview: string | null = null; + // ===== File (nhiều ảnh/video) ===== + selectedFiles: File[] = []; + filePreviews: { + url: string; + isImage: boolean; + isVideo: boolean; + name: string; + }[] = []; + isImageFile: boolean = false; isVideoFile: boolean = false; @@ -108,48 +115,33 @@ export class PostCreatePageComponent { this.loadAllDrafts(); } - onFileSelected(event: Event) { + onFilesSelected(event: Event) { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { - const file = input.files[0]; - - // Reset tất cả các cờ - this.selectedFile = null; - this.filePreview = null; - this.isImageFile = false; - this.isVideoFile = false; - - if (file.type.startsWith('image/')) { - this.selectedFile = file; - this.isImageFile = true; - } else if (file.type.startsWith('video/')) { - this.selectedFile = file; - this.isVideoFile = true; - } else { - // Nếu không phải ảnh hoặc video, không làm gì cả - return; - } - - // Đọc file để tạo URL xem trước - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result) { - this.filePreview = e.target.result as string; - // Kích hoạt phát hiện thay đổi để cập nhật UI - this.cdr.detectChanges(); - } - }; - reader.readAsDataURL(file); + this.selectedFiles = Array.from(input.files); + this.filePreviews = []; + + this.selectedFiles.forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + this.filePreviews.push({ + url: e.target.result as string, + isImage: file.type.startsWith('image/'), + isVideo: file.type.startsWith('video/'), + name: file.name, + }); + this.cdr.detectChanges(); + } + }; + reader.readAsDataURL(file); + }); } } - removeFile() { - // Đổi tên từ removeImage thành removeFile - this.selectedFile = null; - this.filePreview = null; - this.isImageFile = false; - this.isVideoFile = false; - this.post.fileDocument = null; // Xóa luôn mô tả khi remove file + removeFile(index: number) { + this.selectedFiles.splice(index, 1); + this.filePreviews.splice(index, 1); } // ===== Input handlers ===== @@ -297,9 +289,9 @@ export class PostCreatePageComponent { this.post.isPublic = this.post.postType !== 'Private'; // Gắn file vào fileDocument - if (this.selectedFile) { + if (this.selectedFiles) { if (!this.post.fileDocument) this.post.fileDocument = {}; - this.post.fileDocument.file = this.selectedFile; + this.post.fileDocument.file = this.selectedFiles; } this.store.dispatch( @@ -309,16 +301,20 @@ export class PostCreatePageComponent { // Debug nhanh const payload: CreatePostRequest = { title: this.post.title, - content: this.post.content, - isPublic: this.post.isPublic, + content: this.htmlToMd.convert(this.editorContent || ''), + isPublic: this.post.postType !== 'Private', allowComment: this.post.allowComment ?? false, postType: this.post.postType ?? 'Global', hashtag: this.post.hashtag, fileDocument: { - file: this.post.fileDocument?.file, + files: this.selectedFiles, // Gửi nhiều file description: this.post.fileDocument?.description, - isLectureVideo: this.isVideoFile, // Sử dụng cờ isVideoFile - isTextBook: this.isImageFile, // Sử dụng cờ isImageFile + tags: this.post.fileDocument?.tags, + isLectureVideo: this.selectedFiles.some((f) => + f.type.startsWith('video/') + ), + isTextBook: this.selectedFiles.some((f) => f.type.startsWith('image/')), + orgId: this.post.fileDocument?.orgId, }, }; @@ -386,8 +382,8 @@ export class PostCreatePageComponent { fileDocument: null, }; this.editorContent = ''; - this.selectedFile = null; - this.filePreview = null; + this.selectedFiles = []; + this.filePreviews = []; this.isImageFile = false; this.isVideoFile = false; this.selectedOptions = {}; diff --git a/src/app/features/post/pages/post-list/post-list.ts b/src/app/features/post/pages/post-list/post-list.ts index b6ac2d82..52b87acf 100644 --- a/src/app/features/post/pages/post-list/post-list.ts +++ b/src/app/features/post/pages/post-list/post-list.ts @@ -20,7 +20,10 @@ import { mapPostdatatoPostCardInfo } from '../../../../shared/utils/mapData'; import { LottieComponent, provideLottieOptions } from 'ngx-lottie'; import { ScrollEndDirective } from '../../../../shared/directives/scroll-end.directive'; import { BtnType1Component } from '../../../../shared/components/fxdonad-shared/ui-verser-io/btn-type1/btn-type1.component'; -import { openModalNotification } from '../../../../shared/utils/notification'; +import { + openModalNotification, + sendNotification, +} from '../../../../shared/utils/notification'; @Component({ selector: 'app-post-list', @@ -36,8 +39,8 @@ import { openModalNotification } from '../../../../shared/utils/notification'; TrendingComponent, LottieComponent, ScrollEndDirective, - BtnType1Component -], + BtnType1Component, + ], providers: [provideLottieOptions({ player: () => import('lottie-web') })], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -248,7 +251,12 @@ export class PostListComponent { this.postservice.unSavePost(postId).subscribe({ next: () => { post.isSaved = false; // ✅ cập nhật lại trạng thái - console.log(`Unsave thành công post: ${postId}`); + sendNotification( + this.store, + 'Đã hủy lưu', + 'Bạn đã hủy lưu bài viết này', + 'success' + ); }, error: (err) => { console.error('Unsave thất bại', err); @@ -259,7 +267,7 @@ export class PostListComponent { this.postservice.savePost(postId).subscribe({ next: () => { post.isSaved = true; - console.log(`Save thành công post: ${postId}`); + sendNotification(this.store, 'Đã lưu', 'Bài viết đã lưu', 'success'); }, error: (err) => { console.error('Save thất bại', err); diff --git a/src/app/features/service-payment/pages/qr-payment/qr-payment.component.ts b/src/app/features/service-payment/pages/qr-payment/qr-payment.component.ts index 9a21bc32..f86228a8 100644 --- a/src/app/features/service-payment/pages/qr-payment/qr-payment.component.ts +++ b/src/app/features/service-payment/pages/qr-payment/qr-payment.component.ts @@ -1,4 +1,10 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { + Component, + OnInit, + OnDestroy, + inject, + DestroyRef, +} from '@angular/core'; import { Observable, Subscription, interval, of } from 'rxjs'; import { Store } from '@ngrx/store'; @@ -20,6 +26,7 @@ import { } from '../../validate/qr-payment.utils'; import { decodeJWT } from '../../../../shared/utils/stringProcess'; import { ModalNoticeService } from '../../../../shared/store/modal-notice-state/modal-notice.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-qr-payment', @@ -29,6 +36,8 @@ import { ModalNoticeService } from '../../../../shared/store/modal-notice-state/ styleUrls: ['./qr-payment.component.scss'], }) export class QrPaymentComponent implements OnInit, OnDestroy { + private destroyRef = inject(DestroyRef); //ngắt request khi thoát trang (thêm 2 dòng, dòng này và dòng pipe trong service) + amount: number | null = null; // Số tiền VNĐ người dùng nhập maxAmount: number = 20000000; qrTimeOut: boolean = true; @@ -90,15 +99,19 @@ export class QrPaymentComponent implements OnInit, OnDestroy { this.countdownInterval?.unsubscribe(); } + //có ngắt request khi thoát trang, (còn lại là pipe dưới để ngắt request) fetchCurrentMoney() { - this.paymentService.getMyWallet().subscribe({ - next: (res) => { - this.currentGp = res.result.balance; - }, - error(err) { - console.log(err); - }, - }); + this.paymentService + .getMyWallet() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (res) => { + this.currentGp = res.result.balance; + }, + error(err) { + console.log(err); + }, + }); } paymentFailed() { diff --git a/src/app/shared/components/fxdonad-shared/comment/comment.component.html b/src/app/shared/components/fxdonad-shared/comment/comment.component.html index 224a9c43..ce02b08b 100644 --- a/src/app/shared/components/fxdonad-shared/comment/comment.component.html +++ b/src/app/shared/components/fxdonad-shared/comment/comment.component.html @@ -4,81 +4,75 @@

Đã tải {{ totalCommentsCount }} Bình luận

@if (authenticated) { -
- User Avatar - - -
+
+ User Avatar + + +
}
@for (comment of comments; track comment) { -
- -
- -
-
- {{ comment.user.username }} - {{ getTimeAgo(comment.createdAt) }} -

{{ comment.content }}

- -
- Phản hồi ({{ comment.replies?.length }}) -
-
+
+ +
+ +
+
+ {{ comment.user.username }} + {{ + comment.createdAt ? getTimeAgo(comment.createdAt) : "Vừa xong" + }} +

{{ comment.content }}

+ +
+ Phản hồi ({{ comment.replies?.length }})
- - @if (authenticated) { -
-
- - - -
+
+
+ + @if (authenticated) { +
+
+ + + +
+ } + +
+ @for (reply of comment.replies; track reply) { +
+ @if (!isLastReply(comment, reply.id)) { +
} - -
- @for (reply of comment.replies; track reply) { -
- @if (!isLastReply(comment, reply.id)) { -
- } -
-
- -
-
- {{ reply.user.username }} - {{ getTimeAgo(reply.createdAt) }} -
-

{{ reply.content }}

-
-
+
+
+ +
+
+ {{ reply.user.username }} + {{ getTimeAgo(reply.createdAt) }}
- } +

{{ reply.content }}

+
- } -
- - @if (isLoading) { -
-

Đang tải thêm bình luận...

+ }
+
}
+ + @if (isLoading) { +
+

Đang tải thêm bình luận...

+
+ } +
diff --git a/src/app/shared/components/my-shared/header/header.html b/src/app/shared/components/my-shared/header/header.html index f016dd35..8d647443 100644 --- a/src/app/shared/components/my-shared/header/header.html +++ b/src/app/shared/components/my-shared/header/header.html @@ -41,7 +41,11 @@
-
+
diff --git a/src/app/shared/components/my-shared/header/header.scss b/src/app/shared/components/my-shared/header/header.scss index fe1b45fb..7a6f5391 100644 --- a/src/app/shared/components/my-shared/header/header.scss +++ b/src/app/shared/components/my-shared/header/header.scss @@ -175,7 +175,7 @@ } /*notifications number with before*/ .notification::before { - content: "1"; + content: attr(data-count); color: white; font-size: 10px; width: 16px; diff --git a/src/app/shared/components/my-shared/header/header.ts b/src/app/shared/components/my-shared/header/header.ts index 415f4515..52ad29ef 100644 --- a/src/app/shared/components/my-shared/header/header.ts +++ b/src/app/shared/components/my-shared/header/header.ts @@ -1,4 +1,4 @@ -import { Component, SimpleChanges } from '@angular/core'; +import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -13,6 +13,8 @@ import { resetVariable } from '../../../store/variable-state/variable.actions'; import { avatarUrlDefault } from '../../../../core/constants/value.constant'; import { SetPasswordModalComponent } from '../../../../features/auth/components/modal/set-password-modal/set-password-modal.component'; import { NotificationModalComponent } from './notification-modal/notification-modal.component'; +import { NotificationSocketService } from '../../../../core/services/socket-service/notification-socket.service'; +import { NotificationListService } from '../../../../core/services/api-service/notification-list.service'; @Component({ selector: 'app-header', @@ -30,7 +32,7 @@ export class HeaderComponent { needReloadAvatar$: Observable; isDarkMode: boolean = false; - notificationCount: number = 10; + notificationCount: number = 0; isLoggedIn: boolean = false; showProfileMenu = false; isMenuVisible = false; @@ -45,7 +47,9 @@ export class HeaderComponent { private router: Router, private profileService: ProfileService, private themeService: ThemeService, - private store: Store + private store: Store, + private notificationService: NotificationSocketService, + private notificationListService: NotificationListService ) { this.needReloadAvatar$ = this.store.select( selectVariable('reloadAvatarHeader') @@ -77,6 +81,21 @@ export class HeaderComponent { this.needCreateNewPass = JSON.parse( localStorage.getItem('needPasswordSetup') || 'false' ); + + // 👇 Đăng ký lắng nghe notification từ socket + this.notificationService + .listenNoticeCount() + .subscribe((event: { unread: number }) => { + console.log('Header nhận count notification:', event.unread); + + // Tăng counter + this.notificationCount = event.unread; + + // Nếu muốn push vào modal hoặc show toast + // this.notifications.unshift(event); + }); + + this.getCountNotice(); } organizations = [ @@ -124,6 +143,17 @@ export class HeaderComponent { }); } + getCountNotice() { + this.notificationListService.countMyUnread().subscribe({ + next: (res) => { + this.notificationCount = res.result; + }, + error(err) { + console.log(err); + }, + }); + } + toggleProfileMenu() { if (this.showProfileMenu) { this.isMenuVisible = false; diff --git a/src/app/shared/utils/mapData.ts b/src/app/shared/utils/mapData.ts index d3f0898d..e955ee94 100644 --- a/src/app/shared/utils/mapData.ts +++ b/src/app/shared/utils/mapData.ts @@ -190,7 +190,7 @@ export function mapCommentToFilmResponse( parentId: comment.parentCommentId ?? null, content: comment.content, isDeactivated: false, // default vì trong Comment không có field này - createdAt: new Date().toISOString(), // hoặc gán giá trị mặc định + createdAt: comment.createdAt, // hoặc gán giá trị mặc định updatedAt: new Date().toISOString(), // tùy use case user: { id: comment.user.userId,