Merge pull request 'ttp-3' (#5) from ttp-3 into main
All checks were successful
Build Check / build (push) Successful in 2m33s

Reviewed-on: #5
This commit is contained in:
2025-12-16 16:28:29 +00:00
49 changed files with 2083 additions and 86 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "https://pb-dev.prod.k3s.technostrea.fr",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

View File

@@ -3,4 +3,5 @@ import { ActionType } from '@app/domain/action-type.util';
export interface LoaderAction {
action: ActionType;
isLoading: boolean;
isDone?: boolean;
}

View 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)}`;
}
}

View File

@@ -0,0 +1,6 @@
export interface SettingsProfileDto {
isProfilePublic: boolean;
showEmail: boolean;
showPhone: boolean;
allowGeolocation: boolean;
}

View File

@@ -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[];
}

View File

@@ -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>;
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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>

View File

@@ -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% {

View File

@@ -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]);
}
}

View 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>

View 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;
}
}

View 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();
});
});

View 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;
}
}

View File

@@ -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>

View File

@@ -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%;
}
}
}
}

View File

@@ -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();
});
});

View File

@@ -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);
}
}

View File

@@ -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"

View File

@@ -20,7 +20,7 @@ describe('PdfViewerComponent', () => {
fixture = TestBed.createComponent(PdfViewerComponent);
component = fixture.componentInstance;
component.profile = mockProfile;
fixture.componentRef.setInput('profile', mockProfile);
fixture.detectChanges();

View File

@@ -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}`;
});
}

View File

@@ -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>

View File

@@ -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%;
}
}
}
}

View File

@@ -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();
});
});

View File

@@ -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,
};
});
}
}

View File

@@ -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();

View File

@@ -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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -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(),
};

View File

@@ -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('');
});
});

View 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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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[];
}

View File

@@ -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[] {

View 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,
}
);
})
);
}
}

View File

@@ -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);
}
}

View 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);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -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')({