feat : page not found

This commit is contained in:
styve Lioumba
2025-11-25 18:34:38 +01:00
parent b881a48aed
commit 218603911a
15 changed files with 877 additions and 57 deletions

View File

@@ -0,0 +1,7 @@
export interface SearchFilters {
search?: string;
verified: boolean;
secteur: string | null;
profession: string | null;
sort: string;
}

View File

@@ -2,15 +2,41 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component';
import { provideRouter } from '@angular/router';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { of } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { Sector } from '@app/domain/sectors/sector.model';
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
let mockProfileRepo: jest.Mocked<Partial<ProfileRepository>>;
let mockSectorRepo: jest.Mocked<Partial<SectorRepository>>;
beforeEach(async () => {
mockProfileRepo = {
create: jest.fn().mockReturnValue(of({} as Profile)),
list: jest.fn().mockReturnValue(of([])),
getByUserId: jest.fn().mockReturnValue(of({} as Profile)),
update: jest.fn().mockReturnValue(of({} as Profile)),
};
mockSectorRepo = {
list: jest.fn().mockReturnValue(of([])),
getOne: jest.fn().mockReturnValue(of({} as Sector)),
};
await TestBed.configureTestingModule({
imports: [HomeComponent],
providers: [provideRouter([])],
providers: [
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepo },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepo },
],
}).compileComponents();
fixture = TestBed.createComponent(HomeComponent);

View File

@@ -1,19 +1,19 @@
import { Component, inject } from '@angular/core';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SearchComponent } from '@app/shared/features/search/search.component';
import { Router } from '@angular/router';
import { SearchFilters } from '@app/domain/search-filters';
@Component({
selector: 'app-home',
standalone: true,
imports: [FaIconComponent, SearchComponent],
imports: [SearchComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {
private readonly router = inject(Router);
showNewQuery(newQuery: string) {
this.router.navigate(['/profiles'], { queryParams: { search: newQuery } });
showNewQuery(filters: SearchFilters) {
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
}
}

View File

@@ -1 +1,150 @@
<p>not-found works!</p>
<div class="page-container min-h-[calc(100vh-theme(spacing.32))] flex items-center justify-center">
<div class="w-full max-w-2xl mx-auto text-center space-y-8 px-4 sm:px-6 py-12 sm:py-16">
<!-- Illustration 404 animée -->
<div class="relative">
<!-- Cercles décoratifs en arrière-plan -->
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-64 h-64 sm:w-80 sm:h-80 bg-purple-100 dark:bg-purple-900/20 rounded-full blur-3xl opacity-50 animate-pulse"
></div>
</div>
<!-- Nombre 404 -->
<div class="relative">
<h1
class="text-8xl sm:text-9xl lg:text-[12rem] font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-purple-600 via-pink-600 to-purple-600 animate-gradient-x drop-shadow-lg"
>
404
</h1>
<!-- Icône décoratives -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-16 h-16 sm:w-24 sm:h-24 text-purple-600/20 dark:text-purple-400/20 animate-bounce"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<!-- Message d'erreur -->
<div class="space-y-3 sm:space-y-4">
<h2 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white">
Oups ! Page introuvable
</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto">
La page que vous recherchez n'existe pas ou a été déplacée. Retournez à l'accueil ou
explorez nos profils.
</p>
</div>
<!-- Suggestions et actions -->
<div class="space-y-6">
<!-- Boutons d'action -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4">
<a
[routerLink]="['/']"
class="group w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 sm:px-8 py-3 sm:py-3.5 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium text-sm sm:text-base transition-all hover:scale-105 hover:shadow-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 group-hover:-translate-x-1 transition-transform"
>
<path
fill-rule="evenodd"
d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z"
clip-rule="evenodd"
/>
</svg>
Retour à l'accueil
</a>
<a
[routerLink]="['/profiles']"
class="group w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 sm:px-8 py-3 sm:py-3.5 border-2 border-purple-600 dark:border-purple-500 text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg font-medium text-sm sm:text-base transition-all hover:scale-105"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 group-hover:rotate-12 transition-transform"
>
<path
d="M10 9a3 3 0 100-6 3 3 0 000 6zM6 8a2 2 0 11-4 0 2 2 0 014 0zM1.49 15.326a.78.78 0 01-.358-.442 3 3 0 014.308-3.516 6.484 6.484 0 00-1.905 3.959c-.023.222-.014.442.025.654a4.97 4.97 0 01-2.07-.655zM16.44 15.98a4.97 4.97 0 002.07-.654.78.78 0 00.357-.442 3 3 0 00-4.308-3.517 6.484 6.484 0 011.907 3.96 2.32 2.32 0 01-.026.654zM18 8a2 2 0 11-4 0 2 2 0 014 0zM5.304 16.19a.844.844 0 01-.277-.71 5 5 0 019.947 0 .843.843 0 01-.277.71A6.975 6.975 0 0110 18a6.974 6.974 0 01-4.696-1.81z"
/>
</svg>
Explorer les profils
</a>
</div>
<!-- Liens rapides -->
<div class="pt-8 border-t border-gray-200 dark:border-gray-800">
<p class="text-sm text-gray-500 dark:text-gray-500 mb-4">Liens rapides :</p>
<div class="flex flex-wrap justify-center gap-4 sm:gap-6 text-sm">
<a
[routerLink]="['/profiles']"
class="text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 hover:underline transition-colors"
>
Profils
</a>
<span class="text-gray-300 dark:text-gray-700"></span>
<a
[routerLink]="['/auth']"
class="text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 hover:underline transition-colors"
>
Se connecter
</a>
<span class="text-gray-300 dark:text-gray-700"></span>
<a
href="mailto:contact@technostrea.fr"
class="text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 hover:underline transition-colors"
>
Support
</a>
</div>
</div>
</div>
<!-- Message d'aide -->
<div class="pt-6">
<div
class="inline-flex items-start gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-800 text-left max-w-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white mb-1">Besoin d'aide ?</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe support à
<a
href="mailto:contact@technostrea.fr"
class="text-purple-600 dark:text-purple-400 hover:underline"
>
contact&commat;technostrea.fr
</a>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotFoundComponent } from './not-found.component';
import { provideRouter } from '@angular/router';
describe('NotFoundComponent', () => {
let component: NotFoundComponent;
@@ -9,6 +10,7 @@ describe('NotFoundComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFoundComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(NotFoundComponent);

View File

@@ -1,9 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [],
imports: [RouterLink],
templateUrl: './not-found.component.html',
styleUrl: './not-found.component.scss',
})

View File

@@ -6,11 +6,15 @@ import { of } from 'rxjs';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { Profile } from '@app/domain/profiles/profile.model';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { Sector } from '@app/domain/sectors/sector.model';
describe('ProfileListComponent', () => {
let component: ProfileListComponent;
let fixture: ComponentFixture<ProfileListComponent>;
let mockProfileRepository: jest.Mocked<ProfileRepository>;
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>>;
let mockSectorRepository: jest.Mocked<Partial<SectorRepository>>;
beforeEach(async () => {
mockProfileRepository = {
@@ -20,11 +24,17 @@ describe('ProfileListComponent', () => {
update: jest.fn().mockReturnValue(of({} as Profile)),
};
mockSectorRepository = {
list: jest.fn().mockReturnValue(of([])),
getOne: jest.fn().mockReturnValue(of({} as Sector)),
};
await TestBed.configureTestingModule({
imports: [ProfileListComponent],
providers: [
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
],
}).compileComponents();

View File

@@ -5,6 +5,7 @@ import { UntilDestroy } from '@ngneat/until-destroy';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
import { Router } from '@angular/router';
import { SearchFilters } from '@app/domain/search-filters';
@Component({
selector: 'app-profile-list',
@@ -26,7 +27,8 @@ export class ProfileListComponent implements OnInit {
this.facade.load();
}
showNewQuery(newQuery: string) {
this.router.navigate(['/profiles'], { queryParams: { search: newQuery } });
showNewQuery(filters: SearchFilters) {
console.log(filters);
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
}
}

View File

@@ -0,0 +1,386 @@
<!-- Filtres -->
<div class="w-full">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filtrer les résultats</h3>
@if (hasActiveFilters()) {
<button
type="button"
(click)="clearAllFilters()"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium transition-colors flex items-center gap-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
Effacer les filtres
</button>
}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<!-- Filtre: Profils vérifiés -->
<button
type="button"
(click)="toggleVerifiedFilter()"
[class.ring-2]="filters.verified"
[class.ring-purple-500]="filters.verified"
class="group relative flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<div
class="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center"
[class.bg-purple-100]="filters.verified"
[class.dark:bg-purple-900]="filters.verified"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-4 h-4"
[class.text-purple-600]="filters.verified"
[class.dark:text-purple-400]="filters.verified"
[class.text-gray-400]="!filters.verified"
>
<path
fill-rule="evenodd"
d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clip-rule="evenodd"
/>
</svg>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> Profils vérifiés </span>
</div>
@if (filters.verified) {
<div
class="flex-shrink-0 w-5 h-5 rounded-full bg-purple-600 text-white flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</div>
}
</button>
<!-- Filtre: Secteur -->
<ng-container *ngTemplateOutlet="secteur"></ng-container>
<!-- Filtre: Profession -->
<ng-container *ngTemplateOutlet="profession"></ng-container>
<!-- Filtre: Tri -->
<ng-container *ngTemplateOutlet="tri"></ng-container>
</div>
<!-- Tags des filtres actifs -->
@if (hasActiveFilters()) {
<div class="flex flex-wrap gap-2 mt-4">
@if (filters.verified) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
Vérifiés
<button
type="button"
(click)="toggleVerifiedFilter()"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
@if (filters.secteur) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
{{ filters.secteur }}
<button
type="button"
(click)="selectSector(null)"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
@if (filters.profession) {
<span
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium"
>
{{ filters.profession }}
<button
type="button"
(click)="selectProfession(null)"
class="hover:text-purple-900 dark:hover:text-purple-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</span>
}
</div>
}
</div>
<ng-template #secteur>
<div class="relative">
<button
type="button"
(click)="toggleSectorDropdown()"
[class.ring-2]="filters.secteur"
[class.ring-purple-500]="filters.secteur"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
[class.text-purple-600]="filters.secteur"
[class.dark:text-purple-400]="filters.secteur"
[class.text-gray-400]="!filters.secteur"
>
<path
fill-rule="evenodd"
d="M2 3.5A1.5 1.5 0 013.5 2h9A1.5 1.5 0 0114 3.5v11.75A2.75 2.75 0 0016.75 18h-12A2.75 2.75 0 012 15.25V3.5zm3.75 7a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5zm0 3a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5zM5 5.75A.75.75 0 015.75 5h4.5a.75.75 0 01.75.75v2.5a.75.75 0 01-.75.75h-4.5A.75.75 0 015 8.25v-2.5z"
clip-rule="evenodd"
/>
<path d="M16.5 6.5h-1v8.75a1.25 1.25 0 102.5 0V8a1.5 1.5 0 00-1.5-1.5z" />
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ filters.secteur || 'Secteur' }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showSectorDropdown"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showSectorDropdown) {
<div
class="absolute mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<button
type="button"
(click)="selectSector(null)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="!filters.secteur"
[class.dark:bg-purple-900]="!filters.secteur"
>
<span class="text-gray-700 dark:text-gray-300">Tous les secteurs</span>
</button>
@for (sector of sectors(); track sector.id) {
<button
type="button"
(click)="selectSector(sector.nom)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters.secteur === sector.nom"
[class.dark:bg-purple-900]="filters.secteur === sector.nom"
>
<span class="text-gray-700 dark:text-gray-300">{{ sector.nom }}</span>
</button>
}
</div>
}
</div>
</ng-template>
<ng-template #profession>
<div class="relative">
<button
type="button"
(click)="toggleProfessionDropdown()"
[class.ring-2]="filters.profession"
[class.ring-purple-500]="filters.profession"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
[class.text-purple-600]="filters.profession"
[class.dark:text-purple-400]="filters.profession"
[class.text-gray-400]="!filters.profession"
>
<path
fill-rule="evenodd"
d="M6 3.75A2.75 2.75 0 018.75 1h2.5A2.75 2.75 0 0114 3.75v.443c.572.055 1.14.122 1.706.2C17.053 4.582 18 5.75 18 7.07v3.469c0 1.126-.694 2.191-1.83 2.54-1.952.599-4.024.921-6.17.921s-4.219-.322-6.17-.921C2.694 12.73 2 11.665 2 10.539V7.07c0-1.321.947-2.489 2.294-2.676A41.047 41.047 0 016 4.193V3.75zm6.5 0v.325a41.622 41.622 0 00-5 0V3.75c0-.69.56-1.25 1.25-1.25h2.5c.69 0 1.25.56 1.25 1.25zM10 10a1 1 0 00-1 1v.01a1 1 0 001 1h.01a1 1 0 001-1V11a1 1 0 00-1-1H10z"
clip-rule="evenodd"
/>
<path
d="M3 15.055v-.684c.126.053.255.1.39.142 2.092.642 4.313.987 6.61.987 2.297 0 4.518-.345 6.61-.987.135-.041.264-.089.39-.142v.684c0 1.347-.985 2.53-2.363 2.686a41.454 41.454 0 01-9.274 0C3.985 17.585 3 16.402 3 15.055z"
/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ filters.profession || 'Profession' }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showProfessionDropdown"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showProfessionDropdown) {
<div
class="absolute z-10 mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<button
type="button"
(click)="selectProfession(null)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="!filters.profession"
[class.dark:bg-purple-900]="!filters.profession"
>
<span class="text-gray-700 dark:text-gray-300">Toutes les professions</span>
</button>
@for (profile of profiles(); track profile.id) {
<button
type="button"
(click)="selectProfession(profile.profession)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters.profession === profile.profession"
[class.dark:bg-purple-900]="filters.profession === profile.profession"
>
<span class="text-gray-700 dark:text-gray-300">{{ profile.profession }}</span>
</button>
}
</div>
}
</div>
</ng-template>
<ng-template #tri>
<div class="relative">
<button
type="button"
(click)="toggleSortDropdown()"
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700 hover:border-purple-500 dark:hover:border-purple-400 transition-all bg-white dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400"
>
<path
fill-rule="evenodd"
d="M2.24 6.8a.75.75 0 001.06-.04l1.95-2.1v8.59a.75.75 0 001.5 0V4.66l1.95 2.1a.75.75 0 101.1-1.02l-3.25-3.5a.75.75 0 00-1.1 0L2.2 5.74a.75.75 0 00.04 1.06zm8 6.4a.75.75 0 00-.04 1.06l3.25 3.5a.75.75 0 001.1 0l3.25-3.5a.75.75 0 10-1.1-1.02l-1.95 2.1V6.75a.75.75 0 00-1.5 0v8.59l-1.95-2.1a.75.75 0 00-1.06-.04z"
clip-rule="evenodd"
/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ sortLabel }}
</span>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 text-gray-400 transition-transform"
[class.rotate-180]="showSortDropdown"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
@if (showSortDropdown) {
<div
class="absolute z-10 mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-lg"
>
@for (sort of sortOptions; track sort.value) {
<button
type="button"
(click)="selectSort(sort)"
class="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
[class.bg-purple-50]="filters.sort === sort.value"
[class.dark:bg-purple-900]="filters.sort === sort.value"
>
<span class="text-gray-700 dark:text-gray-300">{{ sort.label }}</span>
</button>
}
</div>
}
</div>
</ng-template>

View File

@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilterComponent } from './filter.component';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { provideRouter } from '@angular/router';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { of } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { Sector } from '@app/domain/sectors/sector.model';
describe('FilterComponent', () => {
let component: FilterComponent;
let fixture: ComponentFixture<FilterComponent>;
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>>;
let mockSectorRepository: jest.Mocked<Partial<SectorRepository>>;
beforeEach(async () => {
mockProfileRepository = {
create: jest.fn().mockReturnValue(of({} as Profile)),
list: jest.fn().mockReturnValue(of([])),
getByUserId: jest.fn().mockReturnValue(of({} as Profile)),
update: jest.fn().mockReturnValue(of({} as Profile)),
};
mockSectorRepository = {
list: jest.fn().mockReturnValue(of([])),
getOne: jest.fn().mockReturnValue(of({} as Sector)),
};
await TestBed.configureTestingModule({
imports: [FilterComponent],
providers: [
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepository },
],
}).compileComponents();
fixture = TestBed.createComponent(FilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,128 @@
import { Component, inject, OnInit, output } from '@angular/core';
import { SearchFilters } from '@app/domain/search-filters';
import { SectorFacade } from '@app/ui/sectors/sector.facade';
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
import { NgTemplateOutlet } from '@angular/common';
interface SortOption {
value: string;
label: string;
}
@Component({
selector: 'app-filter',
standalone: true,
imports: [NgTemplateOutlet],
providers: [SectorFacade],
templateUrl: './filter.component.html',
styleUrl: './filter.component.scss',
})
export class FilterComponent implements OnInit {
filtersChanged = output<SearchFilters>();
// État des dropdowns
showSectorDropdown = false;
showProfessionDropdown = false;
showSortDropdown = false;
// Filtres
filters: SearchFilters = {
search: '',
verified: false,
secteur: null,
profession: null,
sort: 'recent',
};
protected readonly sectorFacade = inject(SectorFacade);
protected readonly sectors = this.sectorFacade.sectors;
protected readonly sectorLoading = this.sectorFacade.loading;
protected readonly sectorError = this.sectorFacade.error;
protected readonly ProfileFacade = inject(ProfileFacade);
protected readonly profiles = this.ProfileFacade.profiles;
protected readonly profileLoading = this.ProfileFacade.loading;
protected readonly profileError = this.ProfileFacade.error;
ngOnInit() {
this.sectorFacade.load();
this.ProfileFacade.load();
}
sortOptions: SortOption[] = [
{ value: 'recent', label: 'Récents' },
{ value: 'name-asc', label: 'Nom (A-Z)' },
{ value: 'name-desc', label: 'Nom (Z-A)' },
{ value: 'verified', label: 'Vérifiés' },
];
get sortLabel(): string {
return this.sortOptions.find((s) => s.value === this.filters.sort)?.label || 'Trier par';
}
// Gestion du filtre "Vérifiés"
toggleVerifiedFilter(): void {
this.filters.verified = !this.filters.verified;
this.emitFilters();
}
// Gestion des dropdowns
toggleSectorDropdown(): void {
this.showSectorDropdown = !this.showSectorDropdown;
this.showProfessionDropdown = false;
this.showSortDropdown = false;
}
toggleProfessionDropdown(): void {
this.showProfessionDropdown = !this.showProfessionDropdown;
this.showSectorDropdown = false;
this.showSortDropdown = false;
}
toggleSortDropdown(): void {
this.showSortDropdown = !this.showSortDropdown;
this.showSectorDropdown = false;
this.showProfessionDropdown = false;
}
// Sélection des filtres
selectSector(sector: string | null): void {
this.filters.secteur = sector;
this.showSectorDropdown = false;
this.emitFilters();
}
selectProfession(profession: string | null): void {
this.filters.profession = profession;
this.showProfessionDropdown = false;
this.emitFilters();
}
selectSort(sort: SortOption): void {
this.filters.sort = sort.value;
this.showSortDropdown = false;
this.emitFilters();
}
// Vérifier si des filtres sont actifs
hasActiveFilters(): boolean {
return this.filters.verified || !!this.filters.secteur || !!this.filters.profession;
}
// Effacer tous les filtres
clearAllFilters(): void {
this.filters = {
search: '',
verified: false,
secteur: null,
profession: null,
sort: 'recent',
};
this.emitFilters();
}
// Émettre les changements de filtres
private emitFilters(): void {
this.filtersChanged.emit({ ...this.filters });
}
}

View File

@@ -1,18 +1,38 @@
<form class="w-full" [formGroup]="searchForm" (ngSubmit)="onSubmit()">
<div class="flex w-full border rounded-full p-2 items-center">
<div class="flex-1 flex flex-row items-center">
<button class="inline-block w-8 h-8 p-1 text-gray-400">
<div class="w-full space-y-4 sm:space-y-6 z-[800]">
<!-- Filtres -->
<div>
<app-filter (filtersChanged)="onFiltersChanged($event)" />
</div>
<!-- Barre de recherche -->
<ng-container *ngTemplateOutlet="form"></ng-container>
</div>
<ng-template #form>
<form [formGroup]="searchForm" (ngSubmit)="onSubmit()" class="w-full">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-0 w-full">
<div
class="flex-1 flex items-center gap-2 sm:gap-3 px-4 py-3 sm:py-2.5 border border-gray-300 dark:border-gray-700 rounded-full sm:rounded-l-full sm:rounded-r-none bg-white dark:bg-gray-800"
>
<!-- Icône de recherche -->
<button
type="button"
class="flex-shrink-0 w-5 h-5 sm:w-6 sm:h-6 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Icône de recherche"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-full h-full fill-current"
class="w-full h-full"
viewBox="0 0 512 512"
fill="currentColor"
>
<path
d="M337.509 305.372h-17.501l-6.571-5.486c20.791-25.232 33.922-57.054 33.922-93.257C347.358 127.632 283.896 64 205.135 64 127.452 64 64 127.632 64 206.629s63.452 142.628 142.225 142.628c35.011 0 67.831-13.167 92.991-34.008l6.561 5.487v17.551L415.18 448 448 415.086 337.509 305.372zm-131.284 0c-54.702 0-98.463-43.887-98.463-98.743 0-54.858 43.761-98.742 98.463-98.742 54.7 0 98.462 43.884 98.462 98.742 0 54.856-43.762 98.743-98.462 98.743z"
fill="currentColor"
/>
</svg>
</button>
<!-- Input de recherche -->
<input
formControlName="search"
type="search"
@@ -20,24 +40,25 @@
autocorrect="off"
autocapitalize="none"
spellcheck="false"
required=""
class="flex-1 focus:ring-0 focus:outline-none placeholder:text-gray-400 bg-transparent text-gray-800 dark:text-white"
placeholder="Domaines, activités..."
title="Search"
role="searchbox"
aria-label="Search"
aria-controls="typeahead_results"
aria-autocomplete="list"
class="flex-1 focus:ring-0 focus:outline-none placeholder:text-gray-400 bg-transparent text-gray-800 dark:text-white text-sm sm:text-base"
placeholder="Rechercher par nom, prénom..."
aria-label="Rechercher un profil"
aria-controls="search_results"
/>
</div>
@if (searchForm.invalid) {
<button class="w-32 h-12 text-xs rounded-full bg-purple-800 hover:bg-purple-900 text-gray-50">
Voir tout
<!-- Bouton de recherche -->
<button
type="submit"
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">{{
searchForm.value!.search! === '' ? 'Voir tout' : 'Rechercher'
}}</span>
<span class="sm:hidden">{{
searchForm.value!.search! === '' ? 'Tout' : 'Rechercher'
}}</span>
</button>
} @else {
<button class="w-32 h-12 text-xs rounded-full bg-purple-800 hover:bg-purple-900 text-gray-50">
Rechercher
</button>
}
</div>
</form>
</form>
</ng-template>

View File

@@ -1,14 +1,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { provideRouter } from '@angular/router';
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
import { SECTOR_REPOSITORY_TOKEN } from '@app/infrastructure/sectors/sector-repository.token';
import { of } from 'rxjs';
import { Profile } from '@app/domain/profiles/profile.model';
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
import { SectorRepository } from '@app/domain/sectors/sector.repository';
import { Sector } from '@app/domain/sectors/sector.model';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockProfileRepo: jest.Mocked<Partial<ProfileRepository>>;
let mockSectorRepo: jest.Mocked<Partial<SectorRepository>>;
beforeEach(async () => {
mockProfileRepo = {
create: jest.fn().mockReturnValue(of({} as Profile)),
list: jest.fn().mockReturnValue(of([])),
getByUserId: jest.fn().mockReturnValue(of({} as Profile)),
update: jest.fn().mockReturnValue(of({} as Profile)),
};
mockSectorRepo = {
list: jest.fn().mockReturnValue(of([])),
getOne: jest.fn().mockReturnValue(of({} as Sector)),
};
await TestBed.configureTestingModule({
imports: [SearchComponent],
providers: [
provideRouter([]),
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepo },
{ provide: SECTOR_REPOSITORY_TOKEN, useValue: mockSectorRepo },
],
}).compileComponents();
fixture = TestBed.createComponent(SearchComponent);

View File

@@ -1,27 +1,39 @@
import { Component, inject, output } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { FilterComponent } from '@app/shared/features/filter/filter.component';
import { SearchFilters } from '@app/domain/search-filters';
import { NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, FilterComponent, NgTemplateOutlet],
templateUrl: './search.component.html',
styleUrl: './search.component.scss',
})
export class SearchComponent {
onSearchChange = output<string>();
onSearchChange = output<SearchFilters>();
private formBuilder: FormBuilder = inject(FormBuilder);
// Filtres
filters: SearchFilters = {
search: '',
verified: false,
secteur: null,
profession: null,
sort: 'recent',
};
searchForm = this.formBuilder.group({
search: new FormControl('', Validators.required),
});
onSubmit() {
const search = this.searchForm.value.search?.toLowerCase()!;
this.setNewName(search);
this.onSearchChange.emit({ ...this.filters, search });
}
setNewName(newName: string) {
this.onSearchChange.emit(newName);
onFiltersChanged(event: SearchFilters) {
this.filters = event;
}
}