Merge pull request 'ttp-3' (#5) from ttp-3 into main
All checks were successful
Build Check / build (push) Successful in 2m33s
All checks were successful
Build Check / build (push) Successful in 2m33s
Reviewed-on: #5
This commit is contained in:
11
angular.json
11
angular.json
@@ -30,9 +30,15 @@
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/leaflet/dist/images",
|
||||
"output": "assets/leaflet"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/leaflet/dist/leaflet.css",
|
||||
"node_modules/primeng/resources/themes/lara-light-blue/theme.css",
|
||||
"node_modules/primeng/resources/primeng.min.css",
|
||||
"node_modules/primeicons/primeicons.css",
|
||||
@@ -76,6 +82,9 @@
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "TrouveTonProfile:build:production"
|
||||
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
@@ -25,6 +25,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@ngneat/until-destroy": "^10.0.0",
|
||||
"express": "^4.18.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-pdf-viewer": "^10.3.3",
|
||||
"ngx-toastr": "^17.0.2",
|
||||
"pocketbase": "^0.21.5",
|
||||
@@ -44,6 +45,7 @@
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^18.18.0",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"angular-eslint": "20.4.0",
|
||||
@@ -6384,6 +6386,13 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
@@ -6475,6 +6484,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -14478,6 +14497,12 @@
|
||||
"shell-quote": "^1.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/less": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trouve-ton-profile",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "bash replace-prod-env.sh src/environments/environment.development.ts $ENV_URL && ng serve",
|
||||
@@ -14,7 +14,8 @@
|
||||
"format": "bash replace-prod-env.sh src/environments/environment.development.ts http://localhost:8090 && npm run prettier && npm run lint:fix",
|
||||
"lint": "ng lint",
|
||||
"lint:fix": "ng lint --fix",
|
||||
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments;server.ts\" && npm run lint:fix","fix:all": "npm run format && npm run tsc",
|
||||
"clean:imports": "ts-unused-exports tsconfig.json --excludePathsFromReport=\"src/main.ts;src/environments;server.ts\" && npm run lint:fix",
|
||||
"fix:all": "npm run format && npm run tsc",
|
||||
"check:all": "npm run format && npm run tsc && npm run lint && npm run test",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -41,6 +42,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@ngneat/until-destroy": "^10.0.0",
|
||||
"express": "^4.18.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"ng2-pdf-viewer": "^10.3.3",
|
||||
"ngx-toastr": "^17.0.2",
|
||||
"pocketbase": "^0.21.5",
|
||||
@@ -60,6 +62,7 @@
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^18.18.0",
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"angular-eslint": "20.4.0",
|
||||
|
||||
8
proxy.conf.json
Normal file
8
proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://pb-dev.prod.k3s.technostrea.fr",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ import { ActionType } from '@app/domain/action-type.util';
|
||||
export interface LoaderAction {
|
||||
action: ActionType;
|
||||
isLoading: boolean;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
21
src/app/domain/localisation/coordinates.model.ts
Normal file
21
src/app/domain/localisation/coordinates.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface Coordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export class CoordinatesValidator {
|
||||
static isValid(coords: Coordinates | null | undefined): boolean {
|
||||
if (!coords) return false;
|
||||
|
||||
return (
|
||||
coords.latitude >= -90 &&
|
||||
coords.latitude <= 90 &&
|
||||
coords.longitude >= -180 &&
|
||||
coords.longitude <= 180
|
||||
);
|
||||
}
|
||||
|
||||
static format(coords: Coordinates): string {
|
||||
return `${coords.latitude.toFixed(6)}, ${coords.longitude.toFixed(6)}`;
|
||||
}
|
||||
}
|
||||
6
src/app/domain/profiles/dto/settings-profile.dto.ts
Normal file
6
src/app/domain/profiles/dto/settings-profile.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SettingsProfileDto {
|
||||
isProfilePublic: boolean;
|
||||
showEmail: boolean;
|
||||
showPhone: boolean;
|
||||
allowGeolocation: boolean;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export interface Profile {
|
||||
cv: string;
|
||||
projets: string[];
|
||||
apropos: string;
|
||||
coordonnees?: { lat: number; lon: number };
|
||||
estVisible?: boolean;
|
||||
estGeolocaliser?: boolean;
|
||||
partageMail?: boolean;
|
||||
partagePhone?: boolean;
|
||||
}
|
||||
|
||||
export interface ProfilePaginated {
|
||||
@@ -18,5 +23,5 @@ export interface ProfilePaginated {
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: any[];
|
||||
items: Profile[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export interface ProfileRepository {
|
||||
list(params?: SearchFilters): Observable<ProfilePaginated>;
|
||||
@@ -8,4 +9,6 @@ export interface ProfileRepository {
|
||||
getByUserId(userId: string): Observable<Profile>;
|
||||
create(profile: Profile): Observable<Profile>;
|
||||
update(profileId: string, profile: Partial<Profile>): Observable<Profile>;
|
||||
updateCoordinates(profileId: string, latitude: number, longitude: number): Observable<Profile>;
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto): Observable<Profile>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export enum ThemeType {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
@@ -6,12 +8,7 @@ export enum ThemeType {
|
||||
|
||||
export interface UserSettings {
|
||||
theme: ThemeType;
|
||||
privacy: {
|
||||
isProfilePublic: boolean;
|
||||
showEmail: boolean;
|
||||
showPhone: boolean;
|
||||
allowGeolocation: boolean;
|
||||
};
|
||||
privacy: SettingsProfileDto;
|
||||
}
|
||||
|
||||
// Valeurs par défaut pour éviter les nulls
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { from, map, Observable } from 'rxjs';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import PocketBase from 'pocketbase';
|
||||
import PocketBase, { ListResult } from 'pocketbase';
|
||||
import { environment } from '@env/environment';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PbProfileRepository implements ProfileRepository {
|
||||
@@ -23,9 +24,7 @@ export class PbProfileRepository implements ProfileRepository {
|
||||
};
|
||||
|
||||
return from(
|
||||
this.pb
|
||||
.collection('profiles')
|
||||
.getList<ProfilePaginated>(params?.page, params?.perPage, requestOptions)
|
||||
this.pb.collection('profiles').getList<Profile>(params?.page, params?.perPage, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +50,23 @@ export class PbProfileRepository implements ProfileRepository {
|
||||
return from(this.pb.collection('profiles').update<Profile>(id, data));
|
||||
}
|
||||
|
||||
updateCoordinates(profileId: string, latitude: number, longitude: number): Observable<Profile> {
|
||||
const coordinates = { lat: latitude, lon: longitude };
|
||||
return from(
|
||||
this.pb.collection('profiles').update<Profile>(profileId, { coordonnees: coordinates })
|
||||
);
|
||||
}
|
||||
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto): Observable<Profile> {
|
||||
const settingsData = {
|
||||
estVisible: settings.isProfilePublic,
|
||||
estGeolocaliser: settings.allowGeolocation,
|
||||
partageMail: settings.showEmail,
|
||||
partagePhone: settings.showPhone,
|
||||
};
|
||||
return from(this.pb.collection('profiles').update<Profile>(profileId, settingsData));
|
||||
}
|
||||
|
||||
private onFilterSetting(params?: SearchFilters): string[] {
|
||||
const filters: string[] = [
|
||||
'utilisateur.verified = true',
|
||||
|
||||
@@ -324,6 +324,33 @@
|
||||
<ng-template #menu_nav>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4 mb-6">
|
||||
<nav class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="tab-button flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200"
|
||||
[class.active-tab]="menu() === 'location'"
|
||||
(click)="menu.set('location')"
|
||||
>
|
||||
<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 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
Localisation
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="menu.set('settings')"
|
||||
[class.active-tab]="menu() === 'settings'"
|
||||
@@ -472,8 +499,11 @@
|
||||
<ng-template #menu_content>
|
||||
<div class="tab-content">
|
||||
@switch (menu().toLowerCase()) {
|
||||
@case ('location') {
|
||||
<app-my-profile-map [profile]="profile()" />
|
||||
}
|
||||
@case ('settings') {
|
||||
<app-settings />
|
||||
<app-settings [profile]="profile()" />
|
||||
}
|
||||
@case ('home') {
|
||||
@if (!userLoading().isLoading) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
||||
import { UserFacade } from '@app/ui/users/user.facade';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
import { SettingsComponent } from '@app/shared/features/settings/settings.component';
|
||||
import { MyProfileMapComponent } from '@app/shared/features/my-profile-map/my-profile-map.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile',
|
||||
@@ -30,6 +31,7 @@ import { SettingsComponent } from '@app/shared/features/settings/settings.compon
|
||||
LoadingComponent,
|
||||
SettingsComponent,
|
||||
NgTemplateOutlet,
|
||||
MyProfileMapComponent,
|
||||
],
|
||||
providers: [UserFacade],
|
||||
templateUrl: './my-profile.component.html',
|
||||
@@ -38,7 +40,7 @@ import { SettingsComponent } from '@app/shared/features/settings/settings.compon
|
||||
@UntilDestroy()
|
||||
export class MyProfileComponent implements OnInit {
|
||||
protected readonly environment = environment;
|
||||
protected menu = signal<string>('settings');
|
||||
protected menu = signal<string>('location');
|
||||
|
||||
protected location = inject(Location);
|
||||
protected readonly route = inject(ActivatedRoute);
|
||||
|
||||
@@ -51,37 +51,104 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des profils -->
|
||||
<!-- Toggle Vue Liste / Carte -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Tous les profils</h2>
|
||||
|
||||
<!-- Boutons de switch de vue -->
|
||||
<div class="view-switcher">
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="viewMode() === 'list'"
|
||||
(click)="switchToListView()"
|
||||
>
|
||||
<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="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
|
||||
/>
|
||||
</svg>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="viewMode() === 'map'"
|
||||
(click)="switchToMapView()"
|
||||
>
|
||||
<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="M9 6.75V15m6-6v8.25m.503 3.498l4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 00-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0z"
|
||||
/>
|
||||
</svg>
|
||||
Carte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading().isLoading) {
|
||||
<app-loading message="Chargement des profils..." />
|
||||
} @else {
|
||||
<!-- Titre de section -->
|
||||
<div class="mb-6 flex items-center justify-between animate-fade-in">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Tous les profils</h2>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
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>
|
||||
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
||||
<!-- Vue Liste (existante) -->
|
||||
@if (viewMode() === 'list') {
|
||||
<div class="mb-6 flex items-center justify-between animate-fade-in">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
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>
|
||||
@if (profilePaginated().items) {
|
||||
<span>{{ profilePaginated().items.length }} profil(s)</span>
|
||||
} @else {
|
||||
<span> 0 profil(s)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des profils avec animation d'apparition -->
|
||||
<div class="animate-slide-up animation-delay-100">
|
||||
<app-vertical-profile-list [profiles]="profilePaginated().items" />
|
||||
</div>
|
||||
<div class="animate-slide-up animation-delay-100">
|
||||
<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 class="animate-slide-up animation-delay-100">
|
||||
<app-pagination [filters]="searchFilters()" (onPageChange)="onPageChange($event)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Vue Carte (nouvelle) -->
|
||||
@if (viewMode() === 'map') {
|
||||
<div class="animate-fade-in">
|
||||
@if (loading().isLoading) {
|
||||
<app-loading message="Chargement de la carte..." />
|
||||
} @else {
|
||||
<app-profile-list-map [profiles]="profilePaginated().items" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,60 @@
|
||||
/* Animations blob pour le fond */
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 10px;
|
||||
|
||||
.view-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #374151;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #6366f1;
|
||||
background: white;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.view-switcher {
|
||||
.view-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { SearchComponent } from '@app/shared/features/search/search.component';
|
||||
import { VerticalProfileListComponent } from '@app/shared/components/vertical-profile-list/vertical-profile-list.component';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
@@ -8,6 +8,10 @@ import { Router } from '@angular/router';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { FilterComponent } from '@app/shared/features/filter/filter.component';
|
||||
import { PaginationComponent } from '@app/shared/features/pagination/pagination.component';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { ProfileListMapComponent } from '@app/shared/features/profile-list-map/profile-list-map.component';
|
||||
|
||||
type ViewMode = 'list' | 'map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-list',
|
||||
@@ -18,6 +22,7 @@ import { PaginationComponent } from '@app/shared/features/pagination/pagination.
|
||||
LoadingComponent,
|
||||
FilterComponent,
|
||||
PaginationComponent,
|
||||
ProfileListMapComponent,
|
||||
],
|
||||
templateUrl: './profile-list.component.html',
|
||||
styleUrl: './profile-list.component.scss',
|
||||
@@ -32,6 +37,8 @@ export class ProfileListComponent {
|
||||
protected readonly loading = this.facade.loading;
|
||||
protected readonly error = this.facade.error;
|
||||
|
||||
protected readonly viewMode = signal<ViewMode>('list');
|
||||
|
||||
showNewQuery(filters: SearchFilters) {
|
||||
this.facade.load(filters);
|
||||
this.router.navigate(['/profiles'], { queryParams: { search: filters.search } });
|
||||
@@ -44,4 +51,19 @@ export class ProfileListComponent {
|
||||
onPageChange(filters: SearchFilters) {
|
||||
this.facade.load(filters);
|
||||
}
|
||||
|
||||
// Nouvelles méthodes
|
||||
switchToListView(): void {
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
switchToMapView(): void {
|
||||
this.viewMode.set('map');
|
||||
// Recharger les profils avec localisation si nécessaire
|
||||
}
|
||||
|
||||
onProfileClickFromMap(profile: ProfileViewModel): void {
|
||||
// Navigation vers le profil
|
||||
this.router.navigate(['/profiles', profile.id]);
|
||||
}
|
||||
}
|
||||
|
||||
7
src/app/shared/components/map/map.component.html
Normal file
7
src/app/shared/components/map/map.component.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="map-container" [style.height]="height">
|
||||
<div id="map" class="map"></div>
|
||||
|
||||
@if (!mapReady()) {
|
||||
<app-loading message="Chargement de la carte..." />
|
||||
}
|
||||
</div>
|
||||
76
src/app/shared/components/map/map.component.scss
Normal file
76
src/app/shared/components/map/map.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
// src/app/ui/shared/components/map/map.component.scss
|
||||
|
||||
.map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
z-index: 1000;
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f4f6;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Styles globaux pour les popups Leaflet
|
||||
:host ::ng-deep {
|
||||
.leaflet-popup-content {
|
||||
.map-popup {
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
22
src/app/shared/components/map/map.component.spec.ts
Normal file
22
src/app/shared/components/map/map.component.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MapComponent } from './map.component';
|
||||
|
||||
describe('MapComponent', () => {
|
||||
let component: MapComponent;
|
||||
let fixture: ComponentFixture<MapComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MapComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MapComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
305
src/app/shared/components/map/map.component.ts
Normal file
305
src/app/shared/components/map/map.component.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
effect,
|
||||
EventEmitter,
|
||||
input,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import * as L from 'leaflet';
|
||||
import { LoadingComponent } from '@app/shared/components/loading/loading.component';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
coordinates: Coordinates;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
profile?: ProfileViewModel;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-map',
|
||||
standalone: true,
|
||||
imports: [LoadingComponent],
|
||||
templateUrl: './map.component.html',
|
||||
styleUrl: './map.component.scss',
|
||||
})
|
||||
export class MapComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@Input() center: Coordinates = { latitude: 48.8566, longitude: 2.3522 }; // Paris par défaut
|
||||
@Input() zoom: number = 13;
|
||||
@Input() interactive: boolean = true;
|
||||
@Input() showUserLocation: boolean = false;
|
||||
@Input() height: string = '500px';
|
||||
|
||||
@Output() onLocationSelected = new EventEmitter<Coordinates>();
|
||||
@Output() onMarkerClick = new EventEmitter<MapMarker>();
|
||||
|
||||
markers = input<MapMarker[]>([]);
|
||||
|
||||
private map: L.Map | null = null;
|
||||
private markersLayer: L.LayerGroup | null = null;
|
||||
private userMarker: L.Marker | null = null;
|
||||
|
||||
protected mapReady = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const isReady = this.mapReady();
|
||||
if (isReady && this.map) {
|
||||
this.updateMarkers();
|
||||
}
|
||||
});
|
||||
|
||||
this.fixLeafletIconPath();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Fix pour les icônes Leaflet
|
||||
this.fixLeafletIconPath();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
setTimeout(() => this.initMap(), 100);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private initMap(): void {
|
||||
// Initialiser la carte
|
||||
this.map = L.map('map', {
|
||||
center: [this.center.latitude, this.center.longitude],
|
||||
zoom: this.zoom,
|
||||
zoomControl: true,
|
||||
dragging: this.interactive,
|
||||
scrollWheelZoom: this.interactive,
|
||||
});
|
||||
|
||||
// Ajouter le layer de tuiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map);
|
||||
|
||||
// Créer le layer pour les markers
|
||||
this.markersLayer = L.layerGroup().addTo(this.map);
|
||||
|
||||
// Ajouter l'événement de clic sur la carte si interactive
|
||||
if (this.interactive) {
|
||||
this.map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
this.onMapClick(e);
|
||||
});
|
||||
}
|
||||
|
||||
this.mapReady.set(true);
|
||||
this.updateMarkers();
|
||||
}
|
||||
|
||||
private onMapClick(event: L.LeafletMouseEvent): void {
|
||||
const coords: Coordinates = {
|
||||
latitude: event.latlng.lat,
|
||||
longitude: event.latlng.lng,
|
||||
};
|
||||
|
||||
this.onLocationSelected.emit(coords);
|
||||
|
||||
// Ajouter un marker temporaire à la position cliquée
|
||||
if (this.showUserLocation) {
|
||||
this.setUserLocation(coords);
|
||||
}
|
||||
}
|
||||
|
||||
/*private updateMarkers(): void {
|
||||
if (!this.markersLayer) return;
|
||||
|
||||
// Nettoyer les markers existants
|
||||
this.markersLayer.clearLayers();
|
||||
|
||||
// Ajouter les nouveaux markers
|
||||
this.markers().forEach((marker) => {
|
||||
const leafletMarker = L.marker([marker.coordinates.latitude, marker.coordinates.longitude], {
|
||||
icon: this.createCustomIcon(marker.icon),
|
||||
});
|
||||
|
||||
if (marker.title || marker.description) {
|
||||
leafletMarker.bindPopup(
|
||||
`<div class="map-popup">
|
||||
${marker.title ? `<strong>${marker.title}</strong>` : ''}
|
||||
${marker.description ? `<p>${marker.description}</p>` : ''}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
|
||||
leafletMarker.on('click', () => {
|
||||
this.onMarkerClick.emit(marker);
|
||||
});
|
||||
|
||||
this.markersLayer!.addLayer(leafletMarker);
|
||||
});
|
||||
|
||||
// Ajuster la vue pour montrer tous les markers
|
||||
if (this.markers().length > 0) {
|
||||
const bounds = L.latLngBounds(
|
||||
this.markers().map((m) => [m.coordinates.latitude, m.coordinates.longitude])
|
||||
);
|
||||
this.map?.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}*/
|
||||
|
||||
private updateMarkers(): void {
|
||||
if (!this.markersLayer) return;
|
||||
|
||||
this.markersLayer.clearLayers();
|
||||
|
||||
this.markers().forEach((marker) => {
|
||||
// 1. Création du Marker
|
||||
const leafletMarker = L.marker([marker.coordinates.latitude, marker.coordinates.longitude], {
|
||||
icon: this.createCustomIcon(),
|
||||
});
|
||||
|
||||
// --- A. GESTION DU SURVOL (HOVER) ---
|
||||
// On utilise un Tooltip pour l'affichage au survol
|
||||
if (marker.profile!.fullName) {
|
||||
leafletMarker.bindTooltip(marker.profile!.fullName!, {
|
||||
permanent: false, // false = n'apparaît qu'au survol
|
||||
direction: 'top', // s'affiche au-dessus du marker
|
||||
className: 'custom-map-tooltip', // Pour le style CSS si besoin
|
||||
offset: [0, -35], // Décalage pour ne pas chevaucher le marker
|
||||
});
|
||||
}
|
||||
|
||||
// --- B. GESTION DU CLIC (POPUP) ---
|
||||
// On construit la Card complète pour le clic
|
||||
const popupContainer = document.createElement('div');
|
||||
popupContainer.className =
|
||||
'custom-popup-card flex flex-col items-center gap-3 min-w-[160px] text-center p-1';
|
||||
|
||||
// Image (Avatar)
|
||||
if (marker.profile!.avatarUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = marker.profile!.avatarUrl;
|
||||
img.className = 'w-16 h-16 rounded-full object-cover border-2 border-indigo-500 shadow-sm';
|
||||
popupContainer.appendChild(img);
|
||||
} else {
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className =
|
||||
'w-16 h-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-500 font-bold text-xl';
|
||||
placeholder.innerText = marker.profile!.fullName
|
||||
? marker.profile!.fullName.charAt(0).toUpperCase()
|
||||
: '?';
|
||||
popupContainer.appendChild(placeholder);
|
||||
}
|
||||
|
||||
// Nom (Titre)
|
||||
if (marker.profile!.fullName) {
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'font-bold text-gray-800 text-sm m-0';
|
||||
title.innerText = marker.profile!.fullName;
|
||||
popupContainer.appendChild(title);
|
||||
}
|
||||
|
||||
// Bouton "Voir le profil"
|
||||
const btn = document.createElement('button');
|
||||
btn.className =
|
||||
'bg-indigo-600 text-white text-xs px-4 py-1.5 rounded-full hover:bg-indigo-700 transition-colors w-full mt-1';
|
||||
btn.innerText = 'Voir le profil';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
this.onMarkerClick.emit(marker);
|
||||
});
|
||||
popupContainer.appendChild(btn);
|
||||
|
||||
// On lie la popup au marker (Par défaut, bindPopup s'active au clic)
|
||||
leafletMarker.bindPopup(popupContainer, {
|
||||
closeButton: true, // On remet la croix pour pouvoir fermer si besoin
|
||||
offset: [0, -30],
|
||||
className: 'custom-leaflet-popup',
|
||||
});
|
||||
|
||||
this.markersLayer!.addLayer(leafletMarker);
|
||||
});
|
||||
|
||||
// Recentrage automatique (optionnel, selon votre goût)
|
||||
if (this.markers().length > 0 && this.map) {
|
||||
const bounds = L.latLngBounds(
|
||||
this.markers().map((m) => [m.coordinates.latitude, m.coordinates.longitude])
|
||||
);
|
||||
this.map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}
|
||||
|
||||
setUserLocation(coordinates: Coordinates): void {
|
||||
if (!this.map) return;
|
||||
|
||||
// Supprimer l'ancien marker utilisateur
|
||||
if (this.userMarker) {
|
||||
this.userMarker.remove();
|
||||
}
|
||||
|
||||
// Créer un nouveau marker utilisateur
|
||||
this.userMarker = L.marker([coordinates.latitude, coordinates.longitude], {
|
||||
icon: this.createUserIcon(),
|
||||
}).addTo(this.map);
|
||||
|
||||
this.userMarker.bindPopup('Votre position').openPopup();
|
||||
|
||||
// Centrer la carte sur la position
|
||||
this.map.setView([coordinates.latitude, coordinates.longitude], this.zoom);
|
||||
}
|
||||
|
||||
recenterMap(): void {
|
||||
if (this.map) {
|
||||
this.map.setView([this.center.latitude, this.center.longitude], this.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
private createCustomIcon(iconUrl?: string): L.Icon {
|
||||
return L.icon({
|
||||
iconUrl: iconUrl || 'assets/leaflet/marker-icon.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
}
|
||||
|
||||
private createUserIcon(): L.Icon {
|
||||
return L.icon({
|
||||
iconUrl: 'assets/leaflet/marker-icon.png',
|
||||
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
}
|
||||
|
||||
private fixLeafletIconPath(): void {
|
||||
const iconRetinaUrl = 'assets/leaflet/marker-icon-2x.png';
|
||||
const iconUrl = 'assets/leaflet/marker-icon.png';
|
||||
const shadowUrl = 'assets/leaflet/marker-shadow.png';
|
||||
const iconDefault = L.icon({
|
||||
iconRetinaUrl,
|
||||
iconUrl,
|
||||
shadowUrl,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
tooltipAnchor: [16, -28],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = iconDefault;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<div class="my-profile-map-container">
|
||||
<!-- En-tête -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<h3 class="title">Ma localisation</h3>
|
||||
<p class="subtitle">Partagez votre position pour être visible sur la carte des profils</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coordonnées actuelles -->
|
||||
<div class="coordinates-display">
|
||||
<div class="coordinates-icon">
|
||||
<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 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="coordinates-label">Coordonnées</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages d'erreur ou de chargement -->
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onGetCurrentLocation()" class="btn btn-secondary">
|
||||
<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 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
Me géolocaliser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte interactive -->
|
||||
<div class="map-wrapper">
|
||||
<app-map
|
||||
[center]="locationState().coordinates || { latitude: 48.8566, longitude: 2.3522 }"
|
||||
[markers]="markers()"
|
||||
[interactive]="true"
|
||||
[showUserLocation]="true"
|
||||
[height]="'400px'"
|
||||
(onLocationSelected)="onLocationSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Boutons de sauvegarde -->
|
||||
<div class="save-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="onSaveLocation()"
|
||||
[disabled]="!locationState().coordinates"
|
||||
>
|
||||
<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="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Enregistrer la position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Message d'information -->
|
||||
<div class="info-message">
|
||||
<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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
<p>
|
||||
Vous pouvez cliquer sur la carte pour définir manuellement votre position ou utiliser le
|
||||
bouton "Me géolocaliser" pour détecter automatiquement votre position.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,293 @@
|
||||
// src/app/ui/shared/components/my-profile-map/my-profile-map.component.scss
|
||||
|
||||
.my-profile-map-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.toggle-label {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
.toggle-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: #6366f1;
|
||||
|
||||
&::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #d1d5db;
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
.coordinates-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #eef2ff;
|
||||
border-radius: 10px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.coordinates-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
margin: 0 0 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.coordinates-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.alert-info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bae6fd;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #0c4a6e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.my-profile-map-container {
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
|
||||
.visibility-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MyProfileMapComponent } from './my-profile-map.component';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('MyProfileMapComponent', () => {
|
||||
let component: MyProfileMapComponent;
|
||||
let fixture: ComponentFixture<MyProfileMapComponent>;
|
||||
|
||||
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>> = mockProfileRepo;
|
||||
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MyProfileMapComponent],
|
||||
providers: [
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
|
||||
|
||||
{ provide: ToastrService, useValue: mockToastrService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MyProfileMapComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Component, effect, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { MapComponent, MapMarker } from '@app/shared/components/map/map.component';
|
||||
import { Coordinates, CoordinatesValidator } from '@app/domain/localisation/coordinates.model';
|
||||
import { LocationFacade } from '@app/ui/location/location.facade';
|
||||
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { FeedbackService } from '@app/ui/shared/services/feedback.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-profile-map',
|
||||
standalone: true,
|
||||
imports: [MapComponent],
|
||||
providers: [LocationFacade],
|
||||
templateUrl: './my-profile-map.component.html',
|
||||
styleUrl: './my-profile-map.component.scss',
|
||||
})
|
||||
export class MyProfileMapComponent implements OnInit {
|
||||
readonly profile = input<ProfileViewModel>();
|
||||
private readonly locationFacade = inject(LocationFacade);
|
||||
private readonly profileFacade = inject(ProfileFacade);
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
|
||||
private readonly loading = this.profileFacade.loading;
|
||||
private readonly error = this.profileFacade.error;
|
||||
|
||||
protected readonly locationState = this.locationFacade.locationState;
|
||||
protected readonly markers = signal<MapMarker[]>([]);
|
||||
|
||||
private initializedFromProfile = false;
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const profile = this.profile();
|
||||
const state = this.locationState();
|
||||
|
||||
// Initialiser une seule fois quand profile est disponible
|
||||
if (!this.initializedFromProfile && profile?.coordonnees) {
|
||||
this.initializedFromProfile = true;
|
||||
this.locationState.set({
|
||||
...state,
|
||||
coordinates: profile.coordonnees,
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour les markers quand les coordonnées changent
|
||||
if (state.coordinates) {
|
||||
this.updateMarkers(state.coordinates);
|
||||
}
|
||||
|
||||
if (!this.loading().isLoading) {
|
||||
switch (this.loading().action) {
|
||||
case ActionType.UPDATE:
|
||||
if (!this.error().hasError) {
|
||||
this.feedbackService.notify(
|
||||
ActionType.UPDATE,
|
||||
'Vos coordonnées géographique ont été enregistrés.',
|
||||
false
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
onSaveLocation() {
|
||||
const currentLocation = this.locationState().coordinates;
|
||||
if (currentLocation === null) return;
|
||||
this.profileFacade.updateCoordinate(
|
||||
this.profile()!.id!,
|
||||
currentLocation.latitude,
|
||||
currentLocation.longitude
|
||||
);
|
||||
}
|
||||
|
||||
onLocationSelected(coordinates: Coordinates): void {
|
||||
this.locationState.set({ ...this.locationState(), coordinates: coordinates });
|
||||
}
|
||||
|
||||
onGetCurrentLocation(): void {
|
||||
this.locationFacade.getCurrentLocation();
|
||||
}
|
||||
|
||||
private updateMarkers(coordinates: Coordinates): void {
|
||||
const marker: MapMarker[] = [
|
||||
{
|
||||
id: this.profile()!.id,
|
||||
coordinates: coordinates,
|
||||
title: this.profile()!.fullName,
|
||||
description: CoordinatesValidator.format(coordinates),
|
||||
profile: this.profile()!,
|
||||
},
|
||||
];
|
||||
this.markers.set(marker);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
<!-- PDF Viewer -->
|
||||
<div class="p-4 bg-gray-100 dark:bg-gray-900">
|
||||
<pdf-viewer
|
||||
[src]="cv_link()"
|
||||
[src]="cv_link()!"
|
||||
[zoom]="1"
|
||||
[rotation]="0"
|
||||
[original-size]="false"
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PdfViewerComponent', () => {
|
||||
fixture = TestBed.createComponent(PdfViewerComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
component.profile = mockProfile;
|
||||
fixture.componentRef.setInput('profile', mockProfile);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, computed, Input } from '@angular/core';
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { PdfViewerModule } from 'ng2-pdf-viewer';
|
||||
import { environment } from '@env/environment';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
@@ -11,9 +11,15 @@ import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
styleUrl: './pdf-viewer.component.scss',
|
||||
})
|
||||
export class PdfViewerComponent {
|
||||
@Input({ required: true }) profile: ProfileViewModel | undefined = undefined;
|
||||
profile = input.required<ProfileViewModel>();
|
||||
protected readonly environment = environment;
|
||||
protected readonly cv_link = computed(() => {
|
||||
return `${environment.baseUrl}/api/files/profiles/${this.profile!.id}/${this.profile!.cv}`;
|
||||
const currentProfile = this.profile();
|
||||
|
||||
if (!currentProfile || !currentProfile.cv) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${environment.baseUrl}/api/files/profiles/${currentProfile.id}/${currentProfile.cv}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="my-profile-map-container">
|
||||
<!-- Message d'information -->
|
||||
<div class="info-message">
|
||||
<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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
<p>Vous pouvez cliquer sur les profils présent sur la carte pour en savoir plus .</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons d'action -->
|
||||
<div class="actions">
|
||||
<button type="button" (click)="onGetCurrentLocation()" class="btn btn-secondary">
|
||||
<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 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
Me géolocaliser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte interactive -->
|
||||
<div class="map-wrapper">
|
||||
<app-map
|
||||
[center]="locationState().coordinates || { latitude: 48.8566, longitude: 2.3522 }"
|
||||
[markers]="markers()"
|
||||
[interactive]="true"
|
||||
[showUserLocation]="true"
|
||||
[height]="'400px'"
|
||||
(onMarkerClick)="onMarkerClicked($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,293 @@
|
||||
// src/app/ui/shared/components/my-profile-map/my-profile-map.component.scss
|
||||
|
||||
.my-profile-map-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.visibility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.toggle-label {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
|
||||
.toggle-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: #6366f1;
|
||||
|
||||
&::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #d1d5db;
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.coordinates-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
.coordinates-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #eef2ff;
|
||||
border-radius: 10px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.coordinates-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
margin: 0 0 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.coordinates-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.alert-info {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bae6fd;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #0c4a6e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Boutons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #fee2e2;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.my-profile-map-container {
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
|
||||
.visibility-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProfileListMapComponent } from './profile-list-map.component';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
|
||||
describe('ProfileListMapComponent', () => {
|
||||
let component: ProfileListMapComponent;
|
||||
let fixture: ComponentFixture<ProfileListMapComponent>;
|
||||
let mockProfileRepository = mockProfileRepo;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProfileListMapComponent],
|
||||
providers: [
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
|
||||
{ provide: ToastrService, useValue: mockToastR },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProfileListMapComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, effect, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { MapComponent, MapMarker } from '@app/shared/components/map/map.component';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { LocationFacade } from '@app/ui/location/location.facade';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-list-map',
|
||||
standalone: true,
|
||||
imports: [MapComponent],
|
||||
providers: [LocationFacade],
|
||||
templateUrl: './profile-list-map.component.html',
|
||||
styleUrl: './profile-list-map.component.scss',
|
||||
})
|
||||
export class ProfileListMapComponent implements OnInit {
|
||||
readonly profiles = input<ProfileViewModel[]>();
|
||||
private readonly locationFacade = inject(LocationFacade);
|
||||
private readonly router = inject(Router); // Injection du Router
|
||||
|
||||
protected readonly locationState = this.locationFacade.locationState;
|
||||
protected readonly markers = signal<MapMarker[]>([]);
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const state = this.locationState();
|
||||
const profiles = this.profiles();
|
||||
if (profiles) {
|
||||
this.markers.set(this.toMapMarker(profiles));
|
||||
}
|
||||
|
||||
// Centrer la carte par rapport à sa position lorsque les coordonnées changent
|
||||
if (state.coordinates) {
|
||||
this.locationState.set({ ...this.locationState(), coordinates: state.coordinates });
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
onGetCurrentLocation(): void {
|
||||
this.locationFacade.getCurrentLocation();
|
||||
}
|
||||
|
||||
// Nouvelle méthode pour gérer le clic sur "Voir plus"
|
||||
onMarkerClicked(marker: MapMarker): void {
|
||||
// Redirection vers la page détail
|
||||
this.router.navigate(['/profiles', marker.profile!.slug], {
|
||||
state: { user: marker.profile!.userViewModel, profile: marker.profile! },
|
||||
});
|
||||
}
|
||||
|
||||
private toMapMarker(profiles: ProfileViewModel[]): MapMarker[] {
|
||||
return profiles
|
||||
.filter((profile: ProfileViewModel) => {
|
||||
return (
|
||||
profile.settings!.allowGeolocation &&
|
||||
profile.coordonnees &&
|
||||
profile.coordonnees!.longitude != 0 &&
|
||||
profile.coordonnees!.latitude != 0
|
||||
);
|
||||
})
|
||||
.map((currentProfile: ProfileViewModel) => {
|
||||
return {
|
||||
id: currentProfile.id,
|
||||
coordinates: currentProfile.coordonnees!,
|
||||
title: currentProfile.fullName,
|
||||
description: currentProfile.bio,
|
||||
profile: currentProfile,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { SETTING_REPOSITORY_TOKEN } from '@app/infrastructure/settings/setting-r
|
||||
import { mockToastR } from '@app/testing/toastr.mock';
|
||||
import { SettingRepository } from '@app/domain/settings/setting.repository';
|
||||
import { mockSettingRepo } from '@app/testing/setting.mock';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { mockProfileRepo } from '@app/testing/profile.mock';
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent;
|
||||
@@ -14,6 +17,7 @@ describe('SettingsComponent', () => {
|
||||
|
||||
let mockToastrService: jest.Mocked<Partial<ToastrService>> = mockToastR;
|
||||
let mockSettingRepository: jest.Mocked<Partial<SettingRepository>> = mockSettingRepo;
|
||||
let mockProfileRepository: jest.Mocked<Partial<ProfileRepository>> = mockProfileRepo;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -21,6 +25,7 @@ describe('SettingsComponent', () => {
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: SETTING_REPOSITORY_TOKEN, useValue: mockSettingRepository },
|
||||
{ provide: PROFILE_REPOSITORY_TOKEN, useValue: mockProfileRepository },
|
||||
{ provide: ToastrService, useValue: mockToastrService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Component, effect, inject, OnInit } from '@angular/core';
|
||||
import { Component, effect, inject, input, OnInit } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ThemeType } from '@app/domain/settings/setting.model';
|
||||
import { SettingsFacade } from '@app/ui/settings/settings.facade';
|
||||
import { FeedbackService } from '@app/ui/shared/services/feedback.service';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { ProfileFacade } from '@app/ui/profiles/profile.facade';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -16,6 +20,10 @@ export class SettingsComponent implements OnInit {
|
||||
private readonly settingsFacade = inject(SettingsFacade);
|
||||
private readonly fb: FormBuilder = inject(FormBuilder);
|
||||
private readonly feedbackService = inject(FeedbackService);
|
||||
private readonly profileFacade = inject(ProfileFacade);
|
||||
private readonly loading = this.profileFacade.loading;
|
||||
private readonly error = this.profileFacade.error;
|
||||
profile = input<ProfileViewModel>();
|
||||
|
||||
settings = this.settingsFacade.settings;
|
||||
|
||||
@@ -33,20 +41,26 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const userSettings = this.settings();
|
||||
const userSettings = this.profile()!.settings!
|
||||
? this.profile()!.settings!
|
||||
: this.settings().privacy;
|
||||
if (userSettings) {
|
||||
this.settingsForm.patchValue(
|
||||
{
|
||||
theme: userSettings.theme,
|
||||
privacy: {
|
||||
isProfilePublic: userSettings.privacy.isProfilePublic,
|
||||
showEmail: userSettings.privacy.showEmail,
|
||||
showPhone: userSettings.privacy.showPhone,
|
||||
allowGeolocation: userSettings.privacy.allowGeolocation,
|
||||
},
|
||||
},
|
||||
{ emitEvent: false }
|
||||
);
|
||||
this.updateForm(userSettings);
|
||||
}
|
||||
|
||||
if (!this.loading().isLoading) {
|
||||
switch (this.loading().action) {
|
||||
case ActionType.UPDATE:
|
||||
if (!this.error().hasError && this.loading().isDone) {
|
||||
this.updateForm(userSettings);
|
||||
this.feedbackService.notify(
|
||||
ActionType.UPDATE,
|
||||
'Vos paramètres ont été enregistrés.',
|
||||
false
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -59,18 +73,20 @@ export class SettingsComponent implements OnInit {
|
||||
if (this.settingsForm.invalid) return;
|
||||
|
||||
const settingsFormValue = this.settingsForm.getRawValue();
|
||||
const privacyFormValue = {
|
||||
isProfilePublic: !!settingsFormValue.privacy.isProfilePublic,
|
||||
showEmail: !!settingsFormValue.privacy.showEmail,
|
||||
showPhone: !!settingsFormValue.privacy.showPhone,
|
||||
allowGeolocation: !!settingsFormValue.privacy.allowGeolocation,
|
||||
};
|
||||
|
||||
this.profileFacade.updateSettings(this.profile()!.id, privacyFormValue);
|
||||
|
||||
this.settingsFacade.updateSettings({
|
||||
...this.settings()!,
|
||||
theme: settingsFormValue.theme!,
|
||||
privacy: {
|
||||
isProfilePublic: !!settingsFormValue.privacy.isProfilePublic,
|
||||
showEmail: !!settingsFormValue.privacy.showEmail,
|
||||
showPhone: !!settingsFormValue.privacy.showPhone,
|
||||
allowGeolocation: !!settingsFormValue.privacy.allowGeolocation,
|
||||
},
|
||||
privacy: privacyFormValue,
|
||||
});
|
||||
|
||||
this.feedbackService.notify(null, 'Vos paramètres ont été enregistrés.', false);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
@@ -87,4 +103,19 @@ export class SettingsComponent implements OnInit {
|
||||
this.settingsForm.patchValue({ theme: themeMode });
|
||||
this.settingsFacade.applyThemeSettings({ ...this.settings(), theme: themeMode });
|
||||
}
|
||||
|
||||
private updateForm(userSettings: SettingsProfileDto) {
|
||||
this.settingsForm.patchValue(
|
||||
{
|
||||
theme: this.settings().theme,
|
||||
privacy: {
|
||||
isProfilePublic: userSettings.isProfilePublic ?? false,
|
||||
showEmail: userSettings.showEmail,
|
||||
showPhone: userSettings.showPhone,
|
||||
allowGeolocation: userSettings.allowGeolocation,
|
||||
},
|
||||
},
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { mockProfilePaginated, mockProfiles } from '@app/testing/profile.mock';
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export class FakeProfileRepository implements ProfileRepository {
|
||||
list(): Observable<ProfilePaginated> {
|
||||
@@ -26,4 +27,21 @@ export class FakeProfileRepository implements ProfileRepository {
|
||||
const existing = mockProfiles.find((p) => p.id === id) ?? mockProfiles[0];
|
||||
return of({ ...existing, ...data });
|
||||
}
|
||||
|
||||
updateCoordinates(profileId: string, latitude: number, longitude: number): Observable<Profile> {
|
||||
const existing = mockProfiles.find((p) => p.id === profileId) ?? mockProfiles[0];
|
||||
const coordinate = { latitude, longitude };
|
||||
return of({ ...existing, coordinate });
|
||||
}
|
||||
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto): Observable<Profile> {
|
||||
const existing = mockProfiles.find((p) => p.id === profileId) ?? mockProfiles[0];
|
||||
const settingsData = {
|
||||
estVisible: settings.isProfilePublic,
|
||||
estGeolocaliser: settings.allowGeolocation,
|
||||
partageMail: settings.showEmail,
|
||||
partagePhone: settings.showPhone,
|
||||
};
|
||||
return of({ ...existing, settingsData });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const mockProfiles: Profile[] = [
|
||||
created: '2024-01-17T10:00:00Z',
|
||||
updated: '2024-01-17T12:00:00Z',
|
||||
profession: 'Développeur Web',
|
||||
coordonnees: { lat: 0, lon: 0 },
|
||||
utilisateur: 'user_abc',
|
||||
estVerifier: true,
|
||||
secteur: 'Informatique',
|
||||
@@ -22,6 +23,7 @@ export const mockProfiles: Profile[] = [
|
||||
updated: '2024-01-18T09:00:00Z',
|
||||
profession: 'Designer UI/UX',
|
||||
utilisateur: 'user_xyz',
|
||||
coordonnees: { lat: 0, lon: 0 },
|
||||
estVerifier: false,
|
||||
secteur: 'Design',
|
||||
reseaux: JSON.parse('{"dribbble": "https://dribbble.com/test"}'),
|
||||
@@ -45,4 +47,6 @@ export const mockProfileRepo = {
|
||||
list: jest.fn().mockReturnValue(of([])),
|
||||
getById: jest.fn().mockReturnValue(of({} as Profile)),
|
||||
update: jest.fn().mockReturnValue(of({} as Profile)),
|
||||
updateCoordinates: jest.fn(),
|
||||
updateSettings: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { mockProfiles } from '@app/testing/profile.mock';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
|
||||
describe('ProfilePresenter', () => {
|
||||
it('devrait transformer un Profile en ProfileViewModel', () => {
|
||||
@@ -11,9 +12,10 @@ describe('ProfilePresenter', () => {
|
||||
|
||||
expect(viewModel).toEqual({
|
||||
id: profile.id,
|
||||
fullName: profile.profession.toUpperCase(), // transformation OK
|
||||
fullName: '', // transformation OK
|
||||
isVerifiedLabel: '✅ Vérifié',
|
||||
estVerifier: true,
|
||||
coordonnees: { latitude: 0, longitude: 0 } as Coordinates,
|
||||
profession: 'Développeur Web',
|
||||
reseaux: { linkedin: 'https://linkedin.com/in/test' },
|
||||
secteur: 'Informatique',
|
||||
@@ -26,6 +28,12 @@ describe('ProfilePresenter', () => {
|
||||
isProfileVisible: true,
|
||||
missingFields: [],
|
||||
projets: ['p1', 'p2'],
|
||||
settings: {
|
||||
showEmail: false,
|
||||
showPhone: false,
|
||||
allowGeolocation: false,
|
||||
isProfilePublic: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +51,6 @@ describe('ProfilePresenter', () => {
|
||||
const result = profilePresenter.toViewModels(mockProfiles);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].fullName).toBe(mockProfiles[0].profession.toUpperCase());
|
||||
expect(result[0].fullName).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
54
src/app/ui/location/location.facade.ts
Normal file
54
src/app/ui/location/location.facade.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { GetCurrentLocationUseCase } from '@app/usecase/location/get-current-location.use-case';
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
|
||||
export interface LocationState {
|
||||
coordinates: Coordinates | null;
|
||||
isLocationEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocationFacade {
|
||||
private readonly getCurrentLocationUseCase = inject(GetCurrentLocationUseCase);
|
||||
|
||||
readonly locationState = signal<LocationState>({
|
||||
coordinates: null,
|
||||
isLocationEnabled: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
getCurrentLocation(): void {
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
this.getCurrentLocationUseCase.execute().subscribe({
|
||||
next: (coordinates) => {
|
||||
this.updateState({ coordinates });
|
||||
this.setLoading(false);
|
||||
},
|
||||
error: (error) => {
|
||||
this.setError(error.message || 'Erreur lors de la récupération de la position');
|
||||
this.setLoading(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<LocationState>): void {
|
||||
this.locationState.update((state) => ({ ...state, ...partial }));
|
||||
}
|
||||
|
||||
private setLoading(isLoading: boolean): void {
|
||||
this.updateState({ isLoading });
|
||||
}
|
||||
|
||||
private setError(error: string): void {
|
||||
this.updateState({ error });
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.updateState({ error: null });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { inject, Injectable, signal } from '@angular/core';
|
||||
import { PROFILE_REPOSITORY_TOKEN } from '@app/infrastructure/profiles/profile-repository.token';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { ProfilePresenter } from '@app/ui/profiles/profile.presenter';
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import {
|
||||
ProfileViewModel,
|
||||
ProfileViewModelPaginated,
|
||||
} from '@app/ui/profiles/profile.presenter.model';
|
||||
import { LoaderAction } from '@app/domain/loader-action.util';
|
||||
import { ActionType } from '@app/domain/action-type.util';
|
||||
import { ErrorResponse } from '@app/domain/error-response.util';
|
||||
@@ -13,6 +16,9 @@ import { GetProfileUseCase } from '@app/usecase/profiles/get-profile.usecase';
|
||||
import { ProfileDTO } from '@app/domain/profiles/dto/create-profile.dto';
|
||||
import { SearchFilters } from '@app/domain/search/search-filters';
|
||||
import { SearchService } from '@app/infrastructure/search/search.service';
|
||||
import { UpdateCoordinateProfileUseCase } from '@app/usecase/profiles/update-coordinate-profile.usecase';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { UpdateSettingsProfileUseCase } from '@app/usecase/profiles/update-settings-profile.usecase';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -25,13 +31,19 @@ export class ProfileFacade {
|
||||
private listUseCase = new ListProfilesUseCase(this.profileRepository);
|
||||
private createUseCase = new CreateProfileUseCase(this.profileRepository);
|
||||
private updateUseCase = new UpdateProfileUseCase(this.profileRepository);
|
||||
private updateCoordinateUseCase = new UpdateCoordinateProfileUseCase(this.profileRepository);
|
||||
private updateSettingsUseCase = new UpdateSettingsProfileUseCase(this.profileRepository);
|
||||
private getUseCase = new GetProfileUseCase(this.profileRepository);
|
||||
|
||||
readonly searchFilters = this.searchService.getFilters();
|
||||
readonly profiles = signal<ProfileViewModel[]>([]);
|
||||
readonly profilePaginated = signal<ProfilePaginated>({} as ProfilePaginated);
|
||||
readonly profilePaginated = signal<ProfileViewModelPaginated>({} as ProfileViewModelPaginated);
|
||||
readonly profile = signal<ProfileViewModel>({} as ProfileViewModel);
|
||||
readonly loading = signal<LoaderAction>({ isLoading: false, action: ActionType.NONE });
|
||||
readonly loading = signal<LoaderAction>({
|
||||
isLoading: false,
|
||||
action: ActionType.NONE,
|
||||
isDone: false,
|
||||
});
|
||||
readonly error = signal<ErrorResponse>({
|
||||
action: ActionType.NONE,
|
||||
hasError: false,
|
||||
@@ -58,8 +70,11 @@ export class ProfileFacade {
|
||||
this.searchService.setFilters(filters);
|
||||
this.searchFilters.set(filters);
|
||||
|
||||
this.profilePaginated.set(profilePaginated);
|
||||
this.profiles.set(this.profilePresenter.toViewModels(profilePaginated.items as Profile[]));
|
||||
const profileViewModelPaginated =
|
||||
this.profilePresenter.toViewModelPaginated(profilePaginated);
|
||||
|
||||
this.profilePaginated.set(profileViewModelPaginated);
|
||||
this.profiles.set(profileViewModelPaginated.items);
|
||||
this.handleError(ActionType.READ, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -122,13 +137,42 @@ export class ProfileFacade {
|
||||
});
|
||||
}
|
||||
|
||||
updateCoordinate(profileId: string, latitude: number, longitude: number) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.updateCoordinateUseCase.execute(profileId, latitude, longitude).subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.UPDATE, false, null, false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateSettings(profileId: string, settings: SettingsProfileDto) {
|
||||
this.handleError(ActionType.UPDATE, false, null, true);
|
||||
|
||||
this.updateSettingsUseCase.execute(profileId, settings).subscribe({
|
||||
next: (profile: Profile) => {
|
||||
this.profile.set(this.profilePresenter.toViewModel(profile));
|
||||
this.handleError(ActionType.UPDATE, false, null, false, true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.handleError(ActionType.UPDATE, false, err, false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(
|
||||
action: ActionType = ActionType.NONE,
|
||||
hasError: boolean,
|
||||
message: string | null = null,
|
||||
isLoading = false
|
||||
isLoading = false,
|
||||
isDone = false
|
||||
) {
|
||||
this.error.set({ action, hasError, message });
|
||||
this.loading.set({ action, isLoading });
|
||||
this.loading.set({ action, isLoading, isDone });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { UserViewModel } from '@app/ui/users/user.presenter.model';
|
||||
|
||||
export interface ProfileViewModel {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -15,4 +19,16 @@ export interface ProfileViewModel {
|
||||
projets: string[];
|
||||
isProfileVisible?: boolean;
|
||||
missingFields?: string[];
|
||||
coordonnees?: Coordinates;
|
||||
settings?: SettingsProfileDto;
|
||||
slug?: string;
|
||||
userViewModel?: UserViewModel;
|
||||
}
|
||||
|
||||
export interface ProfileViewModelPaginated {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: ProfileViewModel[];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { ProfileViewModel } from '@app/ui/profiles/profile.presenter.model';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import {
|
||||
ProfileViewModel,
|
||||
ProfileViewModelPaginated,
|
||||
} from '@app/ui/profiles/profile.presenter.model';
|
||||
import { Profile, ProfilePaginated } from '@app/domain/profiles/profile.model';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
import { User } from '@app/domain/users/user.model';
|
||||
import { environment } from '@env/environment';
|
||||
import { UserPresenter } from '@app/ui/users/user.presenter';
|
||||
|
||||
export class ProfilePresenter {
|
||||
private readonly userPresenter = new UserPresenter();
|
||||
|
||||
toViewModelPaginated(profilePaginated: ProfilePaginated): ProfileViewModelPaginated {
|
||||
return { ...profilePaginated, items: this.toViewModels(profilePaginated.items) };
|
||||
}
|
||||
|
||||
toViewModel(profile: Profile): ProfileViewModel {
|
||||
const isProfileVisible = this.isProfileVisible(profile);
|
||||
const missingFields = this.missingFields(profile);
|
||||
const settings: SettingsProfileDto = {
|
||||
showEmail: profile.partageMail ?? false,
|
||||
showPhone: profile.partagePhone ?? false,
|
||||
allowGeolocation: profile.estGeolocaliser ?? false,
|
||||
isProfilePublic: profile.estVisible ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
let profileViewModel: ProfileViewModel = {
|
||||
id: profile.id,
|
||||
fullName: profile.profession.toUpperCase(), // ❗ exemple volontaire
|
||||
fullName: '', // ❗ exemple volontaire
|
||||
isVerifiedLabel: profile.estVerifier ? '✅ Vérifié' : '❌ Non vérifié',
|
||||
createdAtFormatted: new Date(profile.created).toLocaleDateString(),
|
||||
avatarUrl: '',
|
||||
avatarUrl: ``,
|
||||
estVerifier: profile.estVerifier,
|
||||
utilisateur: profile.utilisateur,
|
||||
profession: profile.profession,
|
||||
@@ -21,9 +40,33 @@ export class ProfilePresenter {
|
||||
projets: profile.projets,
|
||||
cv: profile.cv,
|
||||
bio: profile.bio,
|
||||
coordonnees: profile.coordonnees
|
||||
? { latitude: profile!.coordonnees!.lat!, longitude: profile!.coordonnees!.lon! }
|
||||
: undefined,
|
||||
settings,
|
||||
isProfileVisible,
|
||||
missingFields,
|
||||
};
|
||||
|
||||
const profileExpand = (profile as any) ? (profile as any).expand : { utilisateur: {} as User };
|
||||
const userExpand = profileExpand ? (profileExpand.utilisateur as User) : undefined;
|
||||
if (userExpand !== undefined) {
|
||||
const userViewModel = this.userPresenter.toViewModel(userExpand);
|
||||
|
||||
const userSlug = userViewModel.slug ?? '';
|
||||
const profileId = profile.id ? profile.id : '';
|
||||
const slug = userSlug === '' ? profileId : userSlug.concat('-', profileId);
|
||||
|
||||
profileViewModel = {
|
||||
...profileViewModel,
|
||||
userViewModel,
|
||||
slug,
|
||||
fullName: userExpand.name,
|
||||
avatarUrl: `${environment.baseUrl}/api/files/users/${profile.utilisateur}/${userExpand.avatar}`,
|
||||
};
|
||||
}
|
||||
|
||||
return profileViewModel;
|
||||
}
|
||||
|
||||
toViewModels(profiles: Profile[]): ProfileViewModel[] {
|
||||
|
||||
56
src/app/usecase/location/get-current-location.use-case.ts
Normal file
56
src/app/usecase/location/get-current-location.use-case.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Coordinates } from '@app/domain/localisation/coordinates.model';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GetCurrentLocationUseCase {
|
||||
execute(): Observable<Coordinates> {
|
||||
return from(
|
||||
new Promise<Coordinates>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject({
|
||||
code: 0,
|
||||
message: "La géolocalisation n'est pas supportée par votre navigateur",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let message = 'Erreur de géolocalisation';
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
message = 'Permission de géolocalisation refusée';
|
||||
break;
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
message = 'Position indisponible';
|
||||
break;
|
||||
case error.TIMEOUT:
|
||||
message = 'Délai de géolocalisation dépassé';
|
||||
break;
|
||||
}
|
||||
|
||||
reject({
|
||||
code: error.code,
|
||||
message,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export class UpdateCoordinateProfileUseCase {
|
||||
constructor(private readonly repo: ProfileRepository) {}
|
||||
|
||||
execute(profileId: string, latitude: number, longitude: number): Observable<Profile> {
|
||||
return this.repo.updateCoordinates(profileId, latitude, longitude);
|
||||
}
|
||||
}
|
||||
12
src/app/usecase/profiles/update-settings-profile.usecase.ts
Normal file
12
src/app/usecase/profiles/update-settings-profile.usecase.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ProfileRepository } from '@app/domain/profiles/profile.repository';
|
||||
import { Profile } from '@app/domain/profiles/profile.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SettingsProfileDto } from '@app/domain/profiles/dto/settings-profile.dto';
|
||||
|
||||
export class UpdateSettingsProfileUseCase {
|
||||
constructor(private readonly repo: ProfileRepository) {}
|
||||
|
||||
execute(profileId: string, settingsDto: SettingsProfileDto): Observable<Profile> {
|
||||
return this.repo.updateSettings(profileId, settingsDto);
|
||||
}
|
||||
}
|
||||
BIN
src/assets/leaflet/layers-2x.png
Normal file
BIN
src/assets/leaflet/layers-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/leaflet/layers.png
Normal file
BIN
src/assets/leaflet/layers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
src/assets/leaflet/marker-icon-2x.png
Normal file
BIN
src/assets/leaflet/marker-icon-2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/assets/leaflet/marker-icon.png
Normal file
BIN
src/assets/leaflet/marker-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/assets/leaflet/marker-shadow.png
Normal file
BIN
src/assets/leaflet/marker-shadow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
@@ -84,7 +84,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
// Plugin pour line-clamp (limiter le nombre de lignes)
|
||||
require('@tailwindcss/line-clamp'),
|
||||
// require('@tailwindcss/line-clamp'),
|
||||
|
||||
// Plugin pour les formulaires
|
||||
require('@tailwindcss/forms')({
|
||||
|
||||
Reference in New Issue
Block a user