Merge pull request 'ttp-25' (#26) from ttp-25 into main
Reviewed-on: #26 Reviewed-by: technostrea <contact@technostrea.fr>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trouve-ton-profile",
|
"name": "trouve-ton-profile",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
|
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ describe('AppComponent', () => {
|
|||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should have the 'TrouveTonProfile' title`, () => {
|
it(`should have the 'TrouveTonProfil' title`, () => {
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app.title).toEqual('TrouveTonProfile');
|
expect(app.title).toEqual('TrouveTonProfil');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ import { ThemeService } from '@app/core/services/theme/theme.service';
|
|||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
title = 'TrouveTonProfile';
|
title = 'TrouveTonProfil';
|
||||||
themeService = inject(ThemeService);
|
themeService = inject(ThemeService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
|
|
||||||
import { authGuard } from './auth.guard';
|
import { authGuard } from './auth.guard';
|
||||||
import { CanActivateFn, Router, UrlTree } from '@angular/router';
|
import { CanActivateFn, Router, UrlTree } from '@angular/router';
|
||||||
import { AuthRepository } from '@app/domain/authentification/auth.repository';
|
|
||||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
|
||||||
import { AUTH_REPOSITORY_TOKEN } from '@app/infrastructure/authentification/auth-repository.token';
|
|
||||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
|
||||||
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
import { AuthFacade } from '@app/ui/authentification/auth.facade';
|
||||||
|
|
||||||
describe('authGuard', () => {
|
describe('authGuard', () => {
|
||||||
|
|||||||
@@ -12,3 +12,11 @@ export interface Profile {
|
|||||||
projets: string[];
|
projets: string[];
|
||||||
apropos: string;
|
apropos: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfilePaginated {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalItems: number;
|
||||||
|
items: any[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
|
|
||||||
export interface ProfileRepository {
|
export interface ProfileRepository {
|
||||||
list(params?: SearchFilters): Observable<Profile[]>;
|
list(params?: SearchFilters): Observable<ProfilePaginated>;
|
||||||
getByUserId(userId: string): Observable<Profile>;
|
getByUserId(userId: string): Observable<Profile>;
|
||||||
create(profile: Profile): Observable<Profile>;
|
create(profile: Profile): Observable<Profile>;
|
||||||
update(profileId: string, profile: Partial<Profile>): Observable<Profile>;
|
update(profileId: string, profile: Partial<Profile>): Observable<Profile>;
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ export interface SearchFilters {
|
|||||||
profession: string | null;
|
profession: string | null;
|
||||||
sort: string;
|
sort: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
perPage?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
totalItems?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||||
import { from, Observable } from 'rxjs';
|
import { from, Observable } from 'rxjs';
|
||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { environment } from '@env/environment';
|
import { environment } from '@env/environment';
|
||||||
@@ -12,17 +12,21 @@ export class PbProfileRepository implements ProfileRepository {
|
|||||||
private pb = new PocketBase(environment.baseUrl);
|
private pb = new PocketBase(environment.baseUrl);
|
||||||
|
|
||||||
private defaultOptions = {
|
private defaultOptions = {
|
||||||
expand: 'utilisateur',
|
expand: 'utilisateur,secteur',
|
||||||
};
|
};
|
||||||
|
|
||||||
list(params?: SearchFilters): Observable<Profile[]> {
|
list(params?: SearchFilters): Observable<ProfilePaginated> {
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
...this.defaultOptions,
|
...this.defaultOptions,
|
||||||
sort: this.onSortSetting(params),
|
sort: this.onSortSetting(params),
|
||||||
filter: this.onFilterSetting(params).join(' && '),
|
filter: this.onFilterSetting(params).join(' && '),
|
||||||
};
|
};
|
||||||
|
|
||||||
return from(this.pb.collection('profiles').getFullList<Profile>(requestOptions));
|
return from(
|
||||||
|
this.pb
|
||||||
|
.collection('profiles')
|
||||||
|
.getList<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getByUserId(userId: string): Observable<Profile> {
|
getByUserId(userId: string): Observable<Profile> {
|
||||||
@@ -49,7 +53,7 @@ export class PbProfileRepository implements ProfileRepository {
|
|||||||
|
|
||||||
if (params) {
|
if (params) {
|
||||||
if (params.secteur) {
|
if (params.secteur) {
|
||||||
filters.push(`secteur ~ '${params.secteur}'`);
|
filters.push(`secteur.nom ~ '${params.secteur}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.profession) {
|
if (params.profession) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export class SearchService implements SearchRepository {
|
|||||||
secteur: null,
|
secteur: null,
|
||||||
profession: null,
|
profession: null,
|
||||||
sort: 'recent',
|
sort: 'recent',
|
||||||
|
page: 1,
|
||||||
|
perPage: 25,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFilters(filters: SearchFilters) {
|
setFilters(filters: SearchFilters) {
|
||||||
|
|||||||
@@ -78,8 +78,12 @@
|
|||||||
<img
|
<img
|
||||||
alt="{{ user()!.username }}"
|
alt="{{ user()!.username }}"
|
||||||
class="object-cover w-full h-full"
|
class="object-cover w-full h-full"
|
||||||
src="https://api.dicebear.com/9.x/adventurer/svg?seed={{
|
src="https://api.dicebear.com/9.x/initials/svg?seed={{
|
||||||
user()!.username
|
user().name
|
||||||
|
? user().name
|
||||||
|
: user().username
|
||||||
|
? user().username
|
||||||
|
: user().email
|
||||||
}}"
|
}}"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<div class="w-full h-full rounded-full overflow-hidden bg-white">
|
<div class="w-full h-full rounded-full overflow-hidden bg-white">
|
||||||
@if (user().avatar) {
|
@if (user().avatar) {
|
||||||
<img
|
<img
|
||||||
alt="{{ user().username }}"
|
alt="{{ user().name }}"
|
||||||
class="object-cover w-full h-full"
|
class="object-cover w-full h-full"
|
||||||
src="{{ environment.baseUrl }}/api/files/users/{{ user().id }}/{{
|
src="{{ environment.baseUrl }}/api/files/users/{{ user().id }}/{{
|
||||||
user().avatar
|
user().avatar
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<img
|
<img
|
||||||
alt="{{ user().username }}"
|
alt="{{ user().name }}"
|
||||||
class="object-cover w-full h-full"
|
class="object-cover w-full h-full"
|
||||||
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
|
src="https://api.dicebear.com/9.x/initials/svg?seed={{ user().name }}"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -96,10 +96,6 @@
|
|||||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
{{ user().name }}
|
{{ user().name }}
|
||||||
</h1>
|
</h1>
|
||||||
} @else if (user().username) {
|
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
|
||||||
{{ user().username }}
|
|
||||||
</h1>
|
|
||||||
} @else {
|
} @else {
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
<h1 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
{{ user().email }}
|
{{ user().email }}
|
||||||
|
|||||||
@@ -70,13 +70,17 @@
|
|||||||
d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"
|
d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ profiles().length }} profil(s)</span>
|
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste des profils avec animation d'apparition -->
|
<!-- Liste des profils avec animation d'apparition -->
|
||||||
<div class="animate-slide-up animation-delay-100">
|
<div class="animate-slide-up animation-delay-100">
|
||||||
<app-vertical-profile-list [profiles]="profiles()" />
|
<app-vertical-profile-list [profiles]="profilePaginated().items" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animate-slide-up animation-delay-100">
|
||||||
|
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,34 +6,42 @@ import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
|||||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
import { SearchService } from '@app/infrastructure/search/search.service';
|
|
||||||
import { FilterComponent } from '@app/shared/features/filter/filter.component';
|
import { FilterComponent } from '@app/shared/features/filter/filter.component';
|
||||||
|
import { PaginationComponent } from '@app/shared/features/pagination/pagination.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-profile-list',
|
selector: 'app-profile-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SearchComponent, VerticalProfileListComponent, LoadingComponent, FilterComponent],
|
imports: [
|
||||||
|
SearchComponent,
|
||||||
|
VerticalProfileListComponent,
|
||||||
|
LoadingComponent,
|
||||||
|
FilterComponent,
|
||||||
|
PaginationComponent,
|
||||||
|
],
|
||||||
templateUrl: './profile-list.component.html',
|
templateUrl: './profile-list.component.html',
|
||||||
styleUrl: './profile-list.component.scss',
|
styleUrl: './profile-list.component.scss',
|
||||||
})
|
})
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
export class ProfileListComponent {
|
export class ProfileListComponent {
|
||||||
private readonly searchService = inject(SearchService);
|
|
||||||
private readonly facade = inject(ProfileFacade);
|
private readonly facade = inject(ProfileFacade);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
protected readonly profiles = this.facade.profiles;
|
protected readonly searchFilters = this.facade.searchFilters;
|
||||||
|
protected readonly profilePaginated = this.facade.profilePaginated;
|
||||||
protected readonly loading = this.facade.loading;
|
protected readonly loading = this.facade.loading;
|
||||||
protected readonly error = this.facade.error;
|
protected readonly error = this.facade.error;
|
||||||
|
|
||||||
protected readonly searchFilters = this.searchService.getFilters();
|
|
||||||
|
|
||||||
showNewQuery(filters: SearchFilters) {
|
showNewQuery(filters: SearchFilters) {
|
||||||
this.facade.load(this.searchFilters());
|
this.facade.load(filters);
|
||||||
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
|
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
|
||||||
}
|
}
|
||||||
|
|
||||||
onFilterChange(filters: SearchFilters) {
|
onFilterChange(filters: SearchFilters) {
|
||||||
this.facade.load(filters);
|
this.facade.load(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPageChange(filters: SearchFilters) {
|
||||||
|
this.facade.load(filters);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<span
|
<span
|
||||||
class="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
|
class="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
|
||||||
>
|
>
|
||||||
TrouveTonProfile
|
TrouveTonProfil
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<!-- Copyright (optionnel) -->
|
<!-- Copyright (optionnel) -->
|
||||||
<div class="pt-6 border-t border-gray-200 dark:border-gray-800">
|
<div class="pt-6 border-t border-gray-200 dark:border-gray-800">
|
||||||
<p class="text-center text-xs sm:text-sm text-gray-500 dark:text-gray-500">
|
<p class="text-center text-xs sm:text-sm text-gray-500 dark:text-gray-500">
|
||||||
© {{ currentYear }} TrouveTonProfile. Tous droits réservés.
|
© {{ currentYear }} TrouveTonProfil. Tous droits réservés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
for="profession"
|
for="profession"
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
>
|
>
|
||||||
Profession
|
Profession<small class="text-sm font-medium text-red-500"> * </small>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
for="secteur"
|
for="secteur"
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||||
>
|
>
|
||||||
Secteur d'activité
|
Secteur d'activité<small class="text-sm font-medium text-red-500"> * </small>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
|||||||
@@ -17,33 +17,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Actions utilisateur -->
|
<!-- Actions utilisateur -->
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-4">
|
||||||
<!-- Avatar utilisateur (si connecté) -->
|
|
||||||
@if (isAuthenticated() && isEmailVerified() && user(); as user) {
|
|
||||||
<a
|
|
||||||
[routerLink]="['my-profile']"
|
|
||||||
[state]="{ user }"
|
|
||||||
class="w-9 h-9 sm:w-10 sm:h-10 rounded-full overflow-hidden bg-gray-200 border-2 border-transparent hover:border-indigo-500 dark:hover:border-indigo-400 transition-all ring-2 ring-transparent hover:ring-2 hover:ring-indigo-200 dark:hover:ring-indigo-900"
|
|
||||||
aria-label="Mon profil"
|
|
||||||
>
|
|
||||||
@if (user.avatar) {
|
|
||||||
<img
|
|
||||||
[alt]="user.username"
|
|
||||||
class="object-cover w-full h-full"
|
|
||||||
[src]="environment.baseUrl + '/api/files/users/' + user.id + '/' + user.avatar"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<img
|
|
||||||
[alt]="user.username"
|
|
||||||
class="object-cover w-full h-full"
|
|
||||||
[src]="'https://api.dicebear.com/9.x/adventurer/svg?seed=' + user.username"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Toggle thème -->
|
<!-- Toggle thème -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -84,15 +58,64 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Séparateur -->
|
|
||||||
<div class="h-6 w-px bg-gray-300 dark:bg-gray-700"></div>
|
|
||||||
|
|
||||||
<!-- Bouton de connexion/déconnexion -->
|
|
||||||
@if (isAuthenticated() && isEmailVerified()) {
|
@if (isAuthenticated() && isEmailVerified()) {
|
||||||
|
<!-- Menu utilisateur connecté -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Lien Mon Profil - visible sur desktop -->
|
||||||
|
<a
|
||||||
|
[routerLink]="['my-profile']"
|
||||||
|
[state]="{ user: user() }"
|
||||||
|
class="hidden sm:flex items-center gap-2 px-4 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors font-medium"
|
||||||
|
aria-label="Mon profil"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">Mon Profil</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Icône Mon Profil - visible sur mobile uniquement -->
|
||||||
|
<a
|
||||||
|
[routerLink]="['my-profile']"
|
||||||
|
[state]="{ user: user() }"
|
||||||
|
class="sm:hidden p-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Mon profil"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Séparateur (desktop seulement) -->
|
||||||
|
<div class="hidden sm:block h-6 w-px bg-gray-300 dark:bg-gray-700"></div>
|
||||||
|
|
||||||
|
<!-- Bouton Déconnexion - version desktop -->
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/auth']"
|
[routerLink]="['/auth']"
|
||||||
(click)="authFacade.logout()"
|
(click)="authFacade.logout()"
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
class="hidden sm:flex items-center gap-2 px-4 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors font-medium border border-red-200 dark:border-red-800"
|
||||||
aria-label="Se déconnecter"
|
aria-label="Se déconnecter"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -109,12 +132,39 @@
|
|||||||
d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"
|
d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline text-sm font-medium">Déconnexion</span>
|
<span class="text-sm">Déconnexion</span>
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
|
||||||
|
<!-- Icône Déconnexion - version mobile -->
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/auth']"
|
[routerLink]="['/auth']"
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
(click)="authFacade.logout()"
|
||||||
|
class="sm:hidden p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors border border-red-200 dark:border-red-800"
|
||||||
|
aria-label="Se déconnecter"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Boutons pour utilisateur non connecté -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Bouton Connexion mis en valeur -->
|
||||||
|
<a
|
||||||
|
[routerLink]="['/auth']"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 dark:bg-indigo-500 text-white hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-colors font-medium shadow-md hover:shadow-lg"
|
||||||
aria-label="Se connecter"
|
aria-label="Se connecter"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -127,8 +177,9 @@
|
|||||||
d="M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z"
|
d="M234-276q51-39 114-61.5T480-360q69 0 132 22.5T726-276q35-41 54.5-93T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 59 19.5 111t54.5 93Zm246-164q-59 0-99.5-40.5T340-580q0-59 40.5-99.5T480-720q59 0 99.5 40.5T620-580q0 59-40.5 99.5T480-440Zm0 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q53 0 100-15.5t86-44.5q-39-29-86-44.5T480-280q-53 0-100 15.5T294-220q39 29 86 44.5T480-160Zm0-360q26 0 43-17t17-43q0-26-17-43t-43-17q-26 0-43 17t-17 43q0 26 17 43t43 17Zm0-60Zm0 360Z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline text-sm font-medium">Connexion</span>
|
<span class="text-sm">Connexion</span>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<!-- Pagination responsive -->
|
|
||||||
<nav class="flex justify-center mt-8 sm:mt-12" aria-label="Pagination">
|
|
||||||
<ul class="flex flex-wrap gap-2 items-center justify-center">
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium">1</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
3
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-pagination',
|
|
||||||
standalone: true,
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './pagination.component.html',
|
|
||||||
styleUrl: './pagination.component.scss',
|
|
||||||
})
|
|
||||||
export class PaginationComponent {}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<!-- Grid des projets -->
|
<!-- Grid des projets -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||||
@for (project of projects(); track project.id) {
|
@for (project of projects(); track project.id) {
|
||||||
<app-project-item [project]="project" />
|
<app-project-item [project]="project" />
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
<img
|
<img
|
||||||
alt="{{ user!.username }}"
|
alt="{{ user!.username }}"
|
||||||
class="object-cover object-center h-full w-full rounded-full"
|
class="object-cover object-center h-full w-full rounded-full"
|
||||||
src="https://api.dicebear.com/9.x/adventurer/svg?seed={{ user.username }}"
|
src="https://api.dicebear.com/9.x/initials/svg?seed={{
|
||||||
|
user.name ? user.name : user.username ? user.username : user.email
|
||||||
|
}}"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<!-- Champ Nom -->
|
<!-- Champ Nom -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Nom
|
Nom(s)<small class="text-sm font-medium text-red-500"> * </small>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<!-- Champ Prénom -->
|
<!-- Champ Prénom -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="firstname" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label for="firstname" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Prénom
|
Prénom(s)<small class="text-sm font-medium text-red-500"> * </small>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
@@ -108,7 +108,11 @@
|
|||||||
d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6h5a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2h5v5.586l-1.293-1.293zM9 4a1 1 0 012 0v2H9V4z"
|
d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6h5a2 2 0 012 2v7a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2h5v5.586l-1.293-1.293zM9 4a1 1 0 012 0v2H9V4z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
@if (userForm.valid) {
|
||||||
|
Sauvegarder les modifications
|
||||||
|
} @else {
|
||||||
Modifier mon identité
|
Modifier mon identité
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
@if (user() !== undefined) {
|
@if (user() !== undefined) {
|
||||||
<a
|
<a [routerLink]="[user().slug]" [state]="{ user: user(), profile }" class="block group">
|
||||||
[routerLink]="[user().username ? user().username : user().id]"
|
|
||||||
[state]="{ user: user(), profile }"
|
|
||||||
class="block group"
|
|
||||||
>
|
|
||||||
<!-- Card du profil -->
|
<!-- Card du profil -->
|
||||||
<div
|
<div
|
||||||
class="relative bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-2"
|
class="relative bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-2"
|
||||||
@@ -56,7 +52,7 @@
|
|||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
|
class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors"
|
||||||
>
|
>
|
||||||
{{ user().name }}
|
{{ user().firstName }} {{ user().lastName }}
|
||||||
</h3>
|
</h3>
|
||||||
} @else if (user().username) {
|
} @else if (user().username) {
|
||||||
<h3
|
<h3
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export class VerticalProfileItemComponent implements OnInit {
|
|||||||
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
|
@Input({ required: true }) profile: ProfileViewModel = {} as ProfileViewModel;
|
||||||
protected router = inject(Router);
|
protected router = inject(Router);
|
||||||
private readonly facade = inject(UserFacade);
|
private readonly facade = inject(UserFacade);
|
||||||
protected defaultImg = Math.floor(Math.random() * 10) + 1;
|
|
||||||
|
|
||||||
protected user = this.facade.user;
|
protected user = this.facade.user;
|
||||||
protected readonly loading = this.facade.loading;
|
protected readonly loading = this.facade.loading;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class FilterComponent implements OnInit {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sectorFacade.load();
|
this.sectorFacade.load();
|
||||||
this.profileFacade.load();
|
this.profileFacade.load();
|
||||||
|
this.initFormValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si des filtres sont actifs pour afficher le bouton "Effacer"
|
// Vérifier si des filtres sont actifs pour afficher le bouton "Effacer"
|
||||||
@@ -65,5 +66,19 @@ export class FilterComponent implements OnInit {
|
|||||||
|
|
||||||
clearAllFilters(): void {
|
clearAllFilters(): void {
|
||||||
this.searchService.reset();
|
this.searchService.reset();
|
||||||
|
this.onFilterChange.emit(this.activeFilters());
|
||||||
|
this.initFormValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initFormValues() {
|
||||||
|
const filters = this.searchService.getFilters();
|
||||||
|
this.activeFilters.set(filters());
|
||||||
|
|
||||||
|
this.filterForm.setValue({
|
||||||
|
verified: this.activeFilters().verified,
|
||||||
|
secteur: this.activeFilters().secteur,
|
||||||
|
profession: this.activeFilters().profession,
|
||||||
|
sort: this.activeFilters().sort || 'recent',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/app/shared/features/pagination/pagination.component.html
Normal file
38
src/app/shared/features/pagination/pagination.component.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!-- Pagination responsive -->
|
||||||
|
<section class="flex justify-center mt-8 sm:mt-12" aria-label="Pagination">
|
||||||
|
<ul class="flex flex-wrap gap-2 items-center justify-center">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 transition-colors"
|
||||||
|
[class.opacity-50]="currentPage === 1"
|
||||||
|
[class.pointer-events-none]="currentPage === 1"
|
||||||
|
[class.hover:bg-gray-100]="currentPage > 1"
|
||||||
|
(click)="goToPreviousPage()"
|
||||||
|
type="button"
|
||||||
|
[disabled]="currentPage === 1"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="px-4 py-2 rounded-lg bg-indigo-600 text-white font-medium">
|
||||||
|
{{ currentPage }} / {{ filters.totalPages! }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 transition-colors"
|
||||||
|
[class.opacity-50]="currentPage >= filters.totalPages!"
|
||||||
|
[class.pointer-events-none]="currentPage >= filters.totalPages!"
|
||||||
|
[class.hover:bg-gray-100]="currentPage < filters.totalPages!"
|
||||||
|
(click)="goToNextPage()"
|
||||||
|
type="button"
|
||||||
|
[disabled]="currentPage >= filters.totalPages!"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
34
src/app/shared/features/pagination/pagination.component.ts
Normal file
34
src/app/shared/features/pagination/pagination.component.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Component, Input, OnInit, output } from '@angular/core';
|
||||||
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-pagination',
|
||||||
|
standalone: true,
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './pagination.component.html',
|
||||||
|
styleUrl: './pagination.component.scss',
|
||||||
|
})
|
||||||
|
export class PaginationComponent implements OnInit {
|
||||||
|
@Input({ required: true }) filters: SearchFilters = {} as SearchFilters;
|
||||||
|
onPageChange = output<SearchFilters>();
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.currentPage = this.filters.page!;
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPreviousPage() {
|
||||||
|
this.currentPage = this.currentPage - 1;
|
||||||
|
this.emitPageChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToNextPage() {
|
||||||
|
this.currentPage = this.currentPage + 1;
|
||||||
|
this.emitPageChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitPageChange() {
|
||||||
|
const filters = { ...this.filters, page: this.currentPage };
|
||||||
|
this.onPageChange.emit(filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,10 +44,10 @@
|
|||||||
class="w-full sm:w-auto px-6 sm:px-8 py-3 sm:py-2.5 rounded-full sm:rounded-l-none sm:rounded-r-full bg-purple-600 hover:bg-purple-700 active:bg-purple-800 text-white font-medium text-sm sm:text-base transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
class="w-full sm:w-auto px-6 sm:px-8 py-3 sm:py-2.5 rounded-full sm:rounded-l-none sm:rounded-r-full bg-purple-600 hover:bg-purple-700 active:bg-purple-800 text-white font-medium text-sm sm:text-base transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">{{
|
<span class="hidden sm:inline">{{
|
||||||
searchForm.value!.search! === '' ? 'Voir tout' : 'Rechercher'
|
searchForm.value!.search! === '' ? 'Explorer' : 'Rechercher'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="sm:hidden">{{
|
<span class="sm:hidden">{{
|
||||||
searchForm.value!.search! === '' ? 'Tout' : 'Rechercher'
|
searchForm.value!.search! === '' ? 'Explorer' : 'Rechercher'
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { mockProfiles } from '@app/testing/profile.mock';
|
import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
|
||||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
|
|
||||||
export class FakeProfileRepository implements ProfileRepository {
|
export class FakeProfileRepository implements ProfileRepository {
|
||||||
list(): Observable<Profile[]> {
|
list(): Observable<ProfilePaginated> {
|
||||||
return of(mockProfiles);
|
return of(mockProfilePaginated);
|
||||||
}
|
}
|
||||||
|
|
||||||
getByUserId(userId: string): Observable<Profile> {
|
getByUserId(userId: string): Observable<Profile> {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PbProfileRepository } from '@app/infrastructure/profiles/pb-profile.rep
|
|||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile } from '@app/domain/profiles/profile.model';
|
||||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
import { mockProfiles } from '@app/testing/profile.mock';
|
import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
|
||||||
|
|
||||||
jest.mock('pocketbase'); // on mock le module PocketBase
|
jest.mock('pocketbase'); // on mock le module PocketBase
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ describe('PbProfileRepository', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Création d’un faux client PocketBase avec les méthodes dont on a besoin
|
// Création d’un faux client PocketBase avec les méthodes dont on a besoin
|
||||||
mockCollection = {
|
mockCollection = {
|
||||||
getFullList: jest.fn(),
|
getList: jest.fn(),
|
||||||
getFirstListItem: jest.fn(),
|
getFirstListItem: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -35,18 +35,18 @@ describe('PbProfileRepository', () => {
|
|||||||
// ------------------------------------------
|
// ------------------------------------------
|
||||||
// 🔹 TEST : list()
|
// 🔹 TEST : list()
|
||||||
// ------------------------------------------
|
// ------------------------------------------
|
||||||
it('devrait appeler pb.collection("profiles").getFullList() avec un tri par profession', (done) => {
|
it('devrait appeler pb.collection("profiles").getList() avec un tri par profession', (done) => {
|
||||||
mockCollection.getFullList.mockResolvedValue(mockProfiles);
|
mockCollection.getList.mockResolvedValue(mockProfilePaginated);
|
||||||
const options = {
|
const options = {
|
||||||
expand: 'utilisateur',
|
expand: 'utilisateur,secteur',
|
||||||
filter:
|
filter:
|
||||||
"utilisateur.verified = true && utilisateur.name != '' && profession != 'Profession non renseignée' && secteur != ''",
|
"utilisateur.verified = true && utilisateur.name != '' && profession != 'Profession non renseignée' && secteur != ''",
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
};
|
};
|
||||||
repo.list().subscribe((result) => {
|
repo.list().subscribe((result) => {
|
||||||
expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles');
|
expect(mockPocketBase.collection).toHaveBeenCalledWith('profiles');
|
||||||
expect(mockCollection.getFullList).toHaveBeenCalledWith(options);
|
expect(mockCollection.getList).toHaveBeenCalledWith(undefined, undefined, options);
|
||||||
expect(result).toEqual(mockProfiles);
|
expect(result).toEqual(mockProfilePaginated);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
|
|
||||||
export const mockProfiles: Profile[] = [
|
export const mockProfiles: Profile[] = [
|
||||||
{
|
{
|
||||||
@@ -30,3 +30,11 @@ export const mockProfiles: Profile[] = [
|
|||||||
apropos: 'Designer Freelance',
|
apropos: 'Designer Freelance',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const mockProfilePaginated: ProfilePaginated = {
|
||||||
|
page: 1,
|
||||||
|
perPage: 10,
|
||||||
|
totalPages: 1,
|
||||||
|
totalItems: 1,
|
||||||
|
items: mockProfiles,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
|
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
|
||||||
import { FakeProfileRepository } from '@app/testing/domain/profiles/fake-profile.repository';
|
import { FakeProfileRepository } from '@app/testing/domain/profiles/fake-profile.repository';
|
||||||
import { mockProfiles } from '@app/testing/profile.mock';
|
import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
|
||||||
|
|
||||||
describe('ListProfilesUseCase', () => {
|
describe('ListProfilesUseCase', () => {
|
||||||
it('doit retourner la liste des profils', () => {
|
it('doit retourner la liste des profils', () => {
|
||||||
@@ -9,8 +9,8 @@ describe('ListProfilesUseCase', () => {
|
|||||||
|
|
||||||
useCase.execute().subscribe({
|
useCase.execute().subscribe({
|
||||||
next: (profiles) => {
|
next: (profiles) => {
|
||||||
expect(profiles.length).toBe(2);
|
expect(profiles.items.length).toBe(2);
|
||||||
expect(profiles).toEqual(mockProfiles);
|
expect(profiles).toEqual(mockProfilePaginated);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export class AuthFacade {
|
|||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.logoutUseCase.execute();
|
this.logoutUseCase.execute();
|
||||||
|
this.isAuthenticated.set(false);
|
||||||
this.getCurrentUser();
|
this.getCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
|
import { ListProfilesUseCase } from '@app/usecase/profiles/list-profiles.usecase';
|
||||||
import { inject, Injectable, signal } from '@angular/core';
|
import { inject, Injectable, signal } from '@angular/core';
|
||||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
|
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
|
||||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||||
@@ -12,19 +12,23 @@ import { UpdateProfileUseCase } from '@app/usecase/profiles/update-profile.useca
|
|||||||
import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase';
|
import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase';
|
||||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
|
import { SearchService } from '@app/infrastructure/search/search.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ProfileFacade {
|
export class ProfileFacade {
|
||||||
private profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
|
private profileRepository = inject(PROFILE_REPOSITORY_TOKEN);
|
||||||
|
private readonly searchService = inject(SearchService);
|
||||||
|
|
||||||
private listUseCase = new ListProfilesUseCase(this.profileRepository);
|
private listUseCase = new ListProfilesUseCase(this.profileRepository);
|
||||||
private createUseCase = new CreateProfileUseCase(this.profileRepository);
|
private createUseCase = new CreateProfileUseCase(this.profileRepository);
|
||||||
private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
|
private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
|
||||||
private getUseCase = new GetProfileUseCase(this.profileRepository);
|
private getUseCase = new GetProfileUseCase(this.profileRepository);
|
||||||
|
|
||||||
|
readonly searchFilters = this.searchService.getFilters();
|
||||||
readonly profiles = signal<ProfileViewModel[]>([]);
|
readonly profiles = signal<ProfileViewModel[]>([]);
|
||||||
|
readonly profilePaginated = signal<ProfilePaginated>({} as ProfilePaginated);
|
||||||
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
||||||
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
||||||
readonly error = signal<ErrorResponse>({
|
readonly error = signal<ErrorResponse>({
|
||||||
@@ -36,9 +40,25 @@ export class ProfileFacade {
|
|||||||
load(search?: SearchFilters) {
|
load(search?: SearchFilters) {
|
||||||
this.handleError(ActionType.READ, false, null, true);
|
this.handleError(ActionType.READ, false, null, true);
|
||||||
|
|
||||||
|
if (search === undefined || search === null) {
|
||||||
|
search = this.searchFilters();
|
||||||
|
}
|
||||||
|
|
||||||
this.listUseCase.execute(search).subscribe({
|
this.listUseCase.execute(search).subscribe({
|
||||||
next: (profiles) => {
|
next: (profilePaginated: ProfilePaginated) => {
|
||||||
this.profiles.set(ProfilePresenter.toViewModels(profiles));
|
const filters = {
|
||||||
|
...this.searchFilters(),
|
||||||
|
page: profilePaginated.page,
|
||||||
|
perPage: profilePaginated.perPage,
|
||||||
|
totalItems: profilePaginated.totalItems,
|
||||||
|
totalPages: profilePaginated.totalPages,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.searchService.setFilters(filters);
|
||||||
|
this.searchFilters.set(filters);
|
||||||
|
|
||||||
|
this.profilePaginated.set(profilePaginated);
|
||||||
|
this.profiles.set(ProfilePresenter.toViewModels(profilePaginated.items as Profile[]));
|
||||||
this.handleError(ActionType.READ, false, null, false);
|
this.handleError(ActionType.READ, false, null, false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ export interface UserViewModel {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { User } from '@app/domain/users/user.model';
|
|||||||
|
|
||||||
export class UserPresenter {
|
export class UserPresenter {
|
||||||
toViewModel(user: User): UserViewModel {
|
toViewModel(user: User): UserViewModel {
|
||||||
return {
|
const slug = user.name
|
||||||
|
? user.name.toLowerCase().replace(/\s/g, '-')
|
||||||
|
: user.email.split('@')[0].toLowerCase().trim();
|
||||||
|
|
||||||
|
let userViewModel: UserViewModel = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
@@ -11,7 +15,16 @@ export class UserPresenter {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
|
slug,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (user.name) {
|
||||||
|
const firstName = user.name.split(' ').slice(0, -1).join(' ').toLowerCase().trim() ?? '';
|
||||||
|
const lastName = user.name.split(' ').slice(-1)[0].toUpperCase().trim() ?? '';
|
||||||
|
userViewModel = { ...userViewModel, firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
return userViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
toViewModels(users: User[]): UserViewModel[] {
|
toViewModels(users: User[]): UserViewModel[] {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Profile } from '@app/domain/profiles/profile.model';
|
import { ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||||
|
|
||||||
export class ListProfilesUseCase {
|
export class ListProfilesUseCase {
|
||||||
constructor(private readonly repo: ProfileRepository) {}
|
constructor(private readonly repo: ProfileRepository) {}
|
||||||
|
|
||||||
execute(params?: SearchFilters): Observable<Profile[]> {
|
execute(params?: SearchFilters): Observable<ProfilePaginated> {
|
||||||
return this.repo.list(params);
|
return this.repo.list(params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user