diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index d05d509..51a87d2 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -1,18 +1,21 @@
import { NgModule } from '@angular/core';
-import { Routes, RouterModule } from '@angular/router';
+import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
const routes: Routes = [
{
path: '',
- loadChildren: () => import('./pages/home/home.module').then(m => m.HomeModule)
+ loadChildren: () => import('./pages/home/home.module').then(m => m.HomeModule),
+ data: { preload: true }
},
{
path: 'about',
- loadChildren: () => import('./pages/about/about.module').then(m => m.AboutModule)
+ loadChildren: () => import('./pages/about/about.module').then(m => m.AboutModule),
+ data: { preload: true }
},
{
path: 'projects',
- loadChildren: () => import('./pages/projects/projects.module').then(m => m.ProjectsModule)
+ loadChildren: () => import('./pages/projects/projects.module').then(m => m.ProjectsModule),
+ data: { preload: true }
},
{
path: '**',
@@ -21,7 +24,12 @@ const routes: Routes = [
];
@NgModule({
- imports: [RouterModule.forRoot(routes, { scrollPositionRestoration: 'top' })],
+ imports: [RouterModule.forRoot(routes, {
+ scrollPositionRestoration: 'top',
+ preloadingStrategy: PreloadAllModules,
+ enableTracing: false, // Set to true for debugging
+ onSameUrlNavigation: 'reload'
+ })],
exports: [RouterModule]
})
export class AppRoutingModule { }
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 1f6a4e4..ea2616e 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -2,3 +2,4 @@
+
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index f5d5945..d0d5766 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -1,5 +1,5 @@
import { BrowserModule } from '@angular/platform-browser';
-import { NgModule } from '@angular/core';
+import { NgModule, ErrorHandler } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
@@ -11,6 +11,7 @@ import { PagesModule } from './pages/pages.module';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
+import { ErrorHandlerService } from './shared/services/error-handler.service';
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
@@ -39,7 +40,12 @@ export function HttpLoaderFactory(http: HttpClient) {
}
}),
],
- providers: [],
+ providers: [
+ {
+ provide: ErrorHandler,
+ useClass: ErrorHandlerService
+ }
+ ],
bootstrap: [AppComponent]
})
export class AppModule { }
diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts
index 357c9ab..65e8b6c 100644
--- a/src/app/components/components.module.ts
+++ b/src/app/components/components.module.ts
@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
+import { ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from '../shared/shared.module';
import { ContactSectionComponent } from './contact-section/contact-section.component';
@@ -13,6 +14,7 @@ import { TranslateModule } from '@ngx-translate/core';
],
imports: [
CommonModule,
+ ReactiveFormsModule,
SharedModule,
TranslateModule
],
diff --git a/src/app/components/contact-section/contact-section.component.html b/src/app/components/contact-section/contact-section.component.html
index 77a7e43..8e0e377 100644
--- a/src/app/components/contact-section/contact-section.component.html
+++ b/src/app/components/contact-section/contact-section.component.html
@@ -5,13 +5,137 @@
-
+
{{ 'CONTACT.DESCRIPTION' | translate }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Thank you! Your message has been sent successfully. I'll get back to you soon.
+
+
+
+
+
+
+
hectorr9577@gmail.com
@@ -23,21 +147,26 @@
+
diff --git a/src/app/components/contact-section/contact-section.component.ts b/src/app/components/contact-section/contact-section.component.ts
index ae37c17..2e75831 100644
--- a/src/app/components/contact-section/contact-section.component.ts
+++ b/src/app/components/contact-section/contact-section.component.ts
@@ -1,11 +1,17 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { Observable } from 'rxjs';
import { IconSize } from 'src/app/constants/icon-size.constants';
import { HeadingColors } from 'src/app/shared/heading/heading-color.model';
+import { LoadingService } from 'src/app/shared/services/loading.service';
+import { CvDownloadService } from 'src/app/shared/services/cv-download.service';
+import { ErrorHandlerService } from 'src/app/shared/services/error-handler.service';
@Component({
selector: 'app-contact-section',
templateUrl: './contact-section.component.html',
- styleUrls: ['./contact-section.component.scss']
+ styleUrls: ['./contact-section.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContactSectionComponent implements OnInit {
@@ -13,9 +19,132 @@ export class ContactSectionComponent implements OnInit {
sizeMD = IconSize.MD;
sizeXL = IconSize.XL;
- constructor() { }
+ contactForm!: FormGroup;
+ showContactForm = false;
+ formSubmitted = false;
+ formSuccess = false;
+
+ isLoading$: Observable
;
+ cvFormats: string[] = [];
+
+ constructor(
+ private fb: FormBuilder,
+ private loadingService: LoadingService,
+ private cvDownloadService: CvDownloadService,
+ private cdr: ChangeDetectorRef,
+ private errorHandler: ErrorHandlerService
+ ) {
+ this.isLoading$ = this.loadingService.isLoading$;
+ this.cvFormats = this.cvDownloadService.getCVFormats();
+ this.initializeForm();
+ }
ngOnInit(): void {
}
+ private initializeForm(): void {
+ this.contactForm = this.fb.group({
+ name: ['', [Validators.required, Validators.minLength(2)]],
+ email: ['', [Validators.required, Validators.email]],
+ company: [''],
+ subject: ['', [Validators.required, Validators.minLength(5)]],
+ message: ['', [Validators.required, Validators.minLength(10)]]
+ });
+ }
+
+ toggleContactForm(): void {
+ this.showContactForm = !this.showContactForm;
+ this.cdr.markForCheck();
+ }
+
+ async onSubmitContactForm(): Promise {
+ if (this.contactForm.valid) {
+ this.formSubmitted = true;
+
+ try {
+ // Simulate form submission
+ await this.loadingService.withLoading(async () => {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // In a real application, you would send the form data to a backend service
+ console.log('Form Data:', this.contactForm.value);
+
+ this.formSuccess = true;
+ this.contactForm.reset();
+ this.cdr.markForCheck();
+
+ // Show success notification
+ this.errorHandler.showSuccess('Message sent successfully! I\'ll get back to you soon.');
+
+ // Hide form after 3 seconds
+ setTimeout(() => {
+ this.formSuccess = false;
+ this.showContactForm = false;
+ this.cdr.markForCheck();
+ }, 3000);
+ }, 'contact-form');
+ } catch (error) {
+ console.error('Error submitting form:', error);
+ this.errorHandler.addError({
+ id: `contact_error_${Date.now()}`,
+ message: 'Failed to send message. Please try again or contact me directly.',
+ type: 'error',
+ timestamp: new Date(),
+ details: error
+ });
+ }
+ } else {
+ this.markFormGroupTouched();
+ this.errorHandler.showWarning('Please fill in all required fields correctly.');
+ }
+ }
+
+ async downloadCV(format: 'pdf' | 'doc' = 'pdf'): Promise {
+ try {
+ await this.loadingService.withLoading(async () => {
+ await this.cvDownloadService.downloadCV(format).toPromise();
+ this.errorHandler.showSuccess(`CV downloaded successfully as ${format.toUpperCase()}!`);
+ }, 'cv-download');
+ } catch (error) {
+ console.error('Error downloading CV:', error);
+ this.errorHandler.addError({
+ id: `cv_error_${Date.now()}`,
+ message: 'Failed to download CV. Please try again later.',
+ type: 'error',
+ timestamp: new Date(),
+ details: error
+ });
+ }
+ }
+
+ private markFormGroupTouched(): void {
+ Object.keys(this.contactForm.controls).forEach(key => {
+ this.contactForm.get(key)?.markAsTouched();
+ });
+ this.cdr.markForCheck();
+ }
+
+ isFieldTouched(fieldName: string): boolean {
+ return this.contactForm.get(fieldName)?.touched || false;
+ }
+
+ isFieldInvalid(fieldName: string): boolean {
+ const field = this.contactForm.get(fieldName);
+ return !!(field && field.invalid && field.touched);
+ }
+
+ getFieldError(fieldName: string): string {
+ const field = this.contactForm.get(fieldName);
+ if (field?.errors) {
+ if (field.errors['required']) return `${fieldName} is required`;
+ if (field.errors['email']) return 'Please enter a valid email';
+ if (field.errors['minlength']) return `${fieldName} is too short`;
+ }
+ return '';
+ }
+
+ trackByIndex(index: number): number {
+ return index;
+ }
+
}
diff --git a/src/app/pages/home/technologies-section/technologies-section.component.ts b/src/app/pages/home/technologies-section/technologies-section.component.ts
index ab5df65..c3deb52 100644
--- a/src/app/pages/home/technologies-section/technologies-section.component.ts
+++ b/src/app/pages/home/technologies-section/technologies-section.component.ts
@@ -1,4 +1,6 @@
-import { Component, ElementRef, HostListener, ViewChild } from '@angular/core';
+import { Component, ElementRef, HostListener, ViewChild, ChangeDetectionStrategy, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
+import { BehaviorSubject, Subject, combineLatest, Observable } from 'rxjs';
+import { map, startWith, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { staggerFade } from 'src/app/animations/animations';
import { IconSize } from 'src/app/constants/icon-size.constants';
import { HeadingColors } from 'src/app/shared/heading/heading-color.model';
@@ -10,9 +12,10 @@ import { Technology } from 'src/app/shared/interfaces/enhanced-portfolio.interfa
styleUrls: ['./technologies-section.component.scss'],
animations: [
staggerFade
- ]
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
-export class TechnologiesSectionComponent {
+export class TechnologiesSectionComponent implements OnInit, OnDestroy {
inView : boolean = false;
@ViewChild('techUsed') techUsed: ElementRef | undefined;
@@ -20,6 +23,121 @@ export class TechnologiesSectionComponent {
colors = HeadingColors.DEFAULT_GRADIENT
sizeXXL = IconSize.XXL;
+ // Reactive state management with RxJS
+ private destroy$ = new Subject();
+ private selectedCategory$ = new BehaviorSubject('All');
+ private showOnlyFeatured$ = new BehaviorSubject(true);
+ private currentPage$ = new BehaviorSubject(1);
+ private sortCriteria$ = new BehaviorSubject<'proficiency' | 'experience' | 'name'>('proficiency');
+
+ // Observable streams for reactive programming
+ public filteredTechnologies$!: Observable;
+ public paginatedTechnologies$!: Observable;
+ public totalPages$!: Observable;
+ public showPagination$!: Observable;
+ public categoryCount$!: Observable<{ [key: string]: number }>;
+
+ constructor(private cdr: ChangeDetectorRef) {
+ this.setupReactiveStreams();
+ }
+
+ ngOnInit(): void {
+ // Initialize component
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ private setupReactiveStreams(): void {
+ // Setup filtered technologies stream
+ this.filteredTechnologies$ = combineLatest([
+ this.selectedCategory$,
+ this.showOnlyFeatured$,
+ this.sortCriteria$
+ ]).pipe(
+ debounceTime(150),
+ distinctUntilChanged(),
+ map(([category, featured, sortBy]) => {
+ let filtered = [...this.technologies];
+
+ // Apply category filter
+ if (category !== 'All') {
+ filtered = filtered.filter(tech => tech.categoria === category);
+ }
+
+ // Apply featured filter
+ if (featured) {
+ filtered = filtered.filter(tech => tech.featured);
+ }
+
+ // Apply sorting
+ filtered.sort((a, b) => {
+ switch (sortBy) {
+ case 'proficiency':
+ return b.proficiency - a.proficiency;
+ case 'experience':
+ return b.yearsExperience - a.yearsExperience;
+ case 'name':
+ return a.nombre.localeCompare(b.nombre);
+ default:
+ return 0;
+ }
+ });
+
+ return filtered;
+ }),
+ takeUntil(this.destroy$)
+ );
+
+ // Setup paginated technologies stream
+ this.paginatedTechnologies$ = combineLatest([
+ this.filteredTechnologies$,
+ this.currentPage$
+ ]).pipe(
+ map(([technologies, page]) => {
+ const startIndex = (page - 1) * this.itemsPerPage;
+ const endIndex = startIndex + this.itemsPerPage;
+ return technologies.slice(startIndex, endIndex);
+ }),
+ takeUntil(this.destroy$)
+ );
+
+ // Setup total pages stream
+ this.totalPages$ = this.filteredTechnologies$.pipe(
+ map(technologies => Math.ceil(technologies.length / this.itemsPerPage)),
+ takeUntil(this.destroy$)
+ );
+
+ // Setup show pagination stream
+ this.showPagination$ = this.filteredTechnologies$.pipe(
+ map(technologies => technologies.length > this.itemsPerPage),
+ takeUntil(this.destroy$)
+ );
+
+ // Setup category count stream
+ this.categoryCount$ = this.showOnlyFeatured$.pipe(
+ map(featured => {
+ const count: { [key: string]: number } = {};
+ this.categories.forEach(category => {
+ if (category === 'All') {
+ count[category] = featured
+ ? this.technologies.filter(t => t.featured).length
+ : this.technologies.length;
+ } else {
+ const filtered = this.technologies.filter(t => t.categoria === category);
+ count[category] = featured
+ ? filtered.filter(t => t.featured).length
+ : filtered.length;
+ }
+ });
+ return count;
+ }),
+ takeUntil(this.destroy$)
+ );
+ }
+
technologies: Technology[] = [
// FRONTEND - Tecnologías principales primero
{
@@ -274,6 +392,7 @@ export class TechnologiesSectionComponent {
itemsPerPage = 12;
currentPage = 1;
+ // Reactive getters for backwards compatibility with template
get filteredTechnologies(): Technology[] {
let filtered = this.technologies;
@@ -322,54 +441,61 @@ export class TechnologiesSectionComponent {
return count;
}
+ // Enhanced methods with reactive programming
filterByCategory(category: string): void {
this.selectedCategory = category;
- this.currentPage = 1; // Reset a primera página al cambiar filtro
+ this.selectedCategory$.next(category);
+ this.currentPage = 1;
+ this.currentPage$.next(1);
+ this.cdr.markForCheck();
}
toggleFeaturedView(): void {
this.showOnlyFeatured = !this.showOnlyFeatured;
- this.currentPage = 1; // Reset a primera página al cambiar vista
+ this.showOnlyFeatured$.next(this.showOnlyFeatured);
+ this.currentPage = 1;
+ this.currentPage$.next(1);
+ this.cdr.markForCheck();
}
nextPage(): void {
if (this.currentPage < this.totalPages) {
this.currentPage++;
+ this.currentPage$.next(this.currentPage);
+ this.cdr.markForCheck();
}
}
previousPage(): void {
if (this.currentPage > 1) {
this.currentPage--;
+ this.currentPage$.next(this.currentPage);
+ this.cdr.markForCheck();
}
}
goToPage(page: number): void {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
+ this.currentPage$.next(page);
+ this.cdr.markForCheck();
}
}
// Método mejorado para ordenamiento dinámico
sortTechnologies(criteria: 'proficiency' | 'experience' | 'name'): void {
- this.technologies.sort((a, b) => {
- switch (criteria) {
- case 'proficiency':
- return b.proficiency - a.proficiency;
- case 'experience':
- return b.yearsExperience - a.yearsExperience;
- case 'name':
- return a.nombre.localeCompare(b.nombre);
- default:
- return 0;
- }
- });
+ this.sortCriteria$.next(criteria);
+ this.cdr.markForCheck();
}
trackByTechnology(index: number, tech: Technology): number {
return tech.id;
}
+ trackByCategory(index: number, category: string): string {
+ return category;
+ }
+
getCategoryTranslationKey(category: string): string {
if (category.toLowerCase() === 'all') {
return 'HOME.TECHNOLOGIES.CATEGORY_ALL';
@@ -391,7 +517,10 @@ export class TechnologiesSectionComponent {
checkScroll() {
const scrollPosition = window.pageYOffset + window.innerHeight;
if (this.techUsed && this.techUsed.nativeElement.offsetTop <= scrollPosition) {
- this.inView = true;
+ if (!this.inView) {
+ this.inView = true;
+ this.cdr.markForCheck();
+ }
}
}
diff --git a/src/app/shared/directives/intersection-observer.directive.spec.ts b/src/app/shared/directives/intersection-observer.directive.spec.ts
new file mode 100644
index 0000000..99089c7
--- /dev/null
+++ b/src/app/shared/directives/intersection-observer.directive.spec.ts
@@ -0,0 +1,8 @@
+import { IntersectionObserverDirective } from './intersection-observer.directive';
+
+describe('IntersectionObserverDirective', () => {
+ it('should create an instance', () => {
+ const directive = new IntersectionObserverDirective();
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/directives/intersection-observer.directive.ts b/src/app/shared/directives/intersection-observer.directive.ts
new file mode 100644
index 0000000..c44a303
--- /dev/null
+++ b/src/app/shared/directives/intersection-observer.directive.ts
@@ -0,0 +1,57 @@
+import { Directive, ElementRef, Output, EventEmitter, OnInit, OnDestroy, Input } from '@angular/core';
+
+@Directive({
+ selector: '[appIntersectionObserver]'
+})
+export class IntersectionObserverDirective implements OnInit, OnDestroy {
+ @Input() threshold: number = 0.1;
+ @Input() rootMargin: string = '0px';
+ @Input() triggerOnce: boolean = true;
+
+ @Output() inView = new EventEmitter();
+ @Output() intersectionEntry = new EventEmitter();
+
+ private observer?: IntersectionObserver;
+ private hasTriggered = false;
+
+ constructor(private el: ElementRef) {}
+
+ ngOnInit(): void {
+ this.createObserver();
+ }
+
+ ngOnDestroy(): void {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ }
+
+ private createObserver(): void {
+ if ('IntersectionObserver' in window) {
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ const isIntersecting = entry.isIntersecting;
+
+ if (isIntersecting && this.triggerOnce && this.hasTriggered) {
+ return;
+ }
+
+ if (isIntersecting && this.triggerOnce) {
+ this.hasTriggered = true;
+ }
+
+ this.inView.emit(isIntersecting);
+ this.intersectionEntry.emit(entry);
+ });
+ },
+ {
+ threshold: this.threshold,
+ rootMargin: this.rootMargin
+ }
+ );
+
+ this.observer.observe(this.el.nativeElement);
+ }
+ }
+}
diff --git a/src/app/shared/directives/smooth-scroll.directive.spec.ts b/src/app/shared/directives/smooth-scroll.directive.spec.ts
new file mode 100644
index 0000000..bce1e1d
--- /dev/null
+++ b/src/app/shared/directives/smooth-scroll.directive.spec.ts
@@ -0,0 +1,8 @@
+import { SmoothScrollDirective } from './smooth-scroll.directive';
+
+describe('SmoothScrollDirective', () => {
+ it('should create an instance', () => {
+ const directive = new SmoothScrollDirective();
+ expect(directive).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/directives/smooth-scroll.directive.ts b/src/app/shared/directives/smooth-scroll.directive.ts
new file mode 100644
index 0000000..6f6ac40
--- /dev/null
+++ b/src/app/shared/directives/smooth-scroll.directive.ts
@@ -0,0 +1,98 @@
+import { Directive, ElementRef, HostListener, Input, Output, EventEmitter, OnInit, OnDestroy, Renderer2 } from '@angular/core';
+
+@Directive({
+ selector: '[appSmoothScroll]'
+})
+export class SmoothScrollDirective implements OnInit, OnDestroy {
+ @Input() scrollTarget: string = '';
+ @Input() scrollOffset: number = 0;
+ @Input() scrollDuration: number = 1000;
+ @Output() scrollComplete = new EventEmitter();
+
+ private observer?: IntersectionObserver;
+
+ constructor(
+ private el: ElementRef,
+ private renderer: Renderer2
+ ) {}
+
+ ngOnInit(): void {
+ // Add smooth scrolling behavior to the element
+ this.renderer.setStyle(this.el.nativeElement, 'cursor', 'pointer');
+ this.setupIntersectionObserver();
+ }
+
+ ngOnDestroy(): void {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ }
+
+ @HostListener('click', ['$event'])
+ onClick(event: Event): void {
+ event.preventDefault();
+
+ if (this.scrollTarget) {
+ this.scrollToTarget();
+ }
+ }
+
+ private scrollToTarget(): void {
+ const targetElement = document.querySelector(this.scrollTarget);
+
+ if (targetElement) {
+ const targetPosition = targetElement.getBoundingClientRect().top + window.pageYOffset - this.scrollOffset;
+
+ this.smoothScrollTo(targetPosition);
+ }
+ }
+
+ private smoothScrollTo(targetPosition: number): void {
+ const startPosition = window.pageYOffset;
+ const distance = targetPosition - startPosition;
+ const startTime = performance.now();
+
+ const animateScroll = (currentTime: number) => {
+ const timeElapsed = currentTime - startTime;
+ const progress = Math.min(timeElapsed / this.scrollDuration, 1);
+
+ // Easing function (ease-in-out-cubic)
+ const easeInOutCubic = progress < 0.5
+ ? 4 * progress * progress * progress
+ : 1 - Math.pow(-2 * progress + 2, 3) / 2;
+
+ const currentPosition = startPosition + (distance * easeInOutCubic);
+ window.scrollTo(0, currentPosition);
+
+ if (progress < 1) {
+ requestAnimationFrame(animateScroll);
+ } else {
+ this.scrollComplete.emit();
+ }
+ };
+
+ requestAnimationFrame(animateScroll);
+ }
+
+ private setupIntersectionObserver(): void {
+ if ('IntersectionObserver' in window) {
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ this.renderer.addClass(this.el.nativeElement, 'in-view');
+ } else {
+ this.renderer.removeClass(this.el.nativeElement, 'in-view');
+ }
+ });
+ },
+ {
+ threshold: 0.1,
+ rootMargin: '50px'
+ }
+ );
+
+ this.observer.observe(this.el.nativeElement);
+ }
+ }
+}
diff --git a/src/app/shared/services/cv-download.service.spec.ts b/src/app/shared/services/cv-download.service.spec.ts
new file mode 100644
index 0000000..47fa676
--- /dev/null
+++ b/src/app/shared/services/cv-download.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { CvDownloadService } from './cv-download.service';
+
+describe('CvDownloadService', () => {
+ let service: CvDownloadService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CvDownloadService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/services/cv-download.service.ts b/src/app/shared/services/cv-download.service.ts
new file mode 100644
index 0000000..5b6cd61
--- /dev/null
+++ b/src/app/shared/services/cv-download.service.ts
@@ -0,0 +1,86 @@
+import { Injectable } from '@angular/core';
+import { Observable, of, delay } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CvDownloadService {
+
+ constructor() { }
+
+ downloadCV(format: 'pdf' | 'doc' = 'pdf'): Observable {
+ // Simulate CV generation and download
+ return new Observable(observer => {
+ // Create a sample CV data
+ const cvData = this.generateCVData();
+
+ // Create blob and download
+ const blob = new Blob([cvData], {
+ type: format === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ });
+
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `Hector_Adrian_Roman_CV.${format}`;
+
+ // Simulate download
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ // Simulate async operation
+ setTimeout(() => {
+ observer.next(true);
+ observer.complete();
+ }, 1000);
+ });
+ }
+
+ private generateCVData(): string {
+ return `
+HÉCTOR ADRIÁN ROMÁN
+Senior Full Stack Web Developer
+Email: hectorr9577@gmail.com
+LinkedIn: https://www.linkedin.com/in/hector-adrian-roman79509/
+GitHub: https://github.com/HectorARG
+
+PROFESSIONAL SUMMARY
+Senior Full Stack Developer with 4+ years of experience specializing in Angular, Spring Boot, and enterprise-grade applications. Proven track record in government digital transformation projects and municipal systems development.
+
+TECHNICAL SKILLS
+Frontend: Angular (4+ years), TypeScript (4+ years), PrimeNG (2+ years), Tailwind CSS (2+ years)
+Backend: Spring Boot (3+ years), Java (3+ years), Spring Security (3+ years), Spring Data (3+ years)
+Databases: Oracle (3+ years), PostgreSQL (2+ years), Redis (1+ years), MongoDB (1+ years)
+Cloud & Tools: AWS (2+ years), Docker (2+ years), Git (4+ years), Jira (4+ years), SonarQube (2+ years)
+
+PROFESSIONAL EXPERIENCE
+Senior Full Stack Developer | CETIC - Durango Government | 2020 - Present
+• Led development of government digital transformation initiatives
+• Architected and implemented microservices-based solutions
+• Managed full-stack development using Angular and Spring Boot
+• Collaborated with cross-functional teams to deliver high-impact projects
+
+PROJECTS
+Government Digital Systems: Developed comprehensive municipal management systems
+API Development: Created robust REST APIs for government services
+Enterprise Applications: Built scalable web applications for public sector
+
+CERTIFICATIONS & ACHIEVEMENTS
+• Oracle Certified Java Developer
+• AWS Solutions Architect Associate
+• Agile Development Methodologies
+• Code Quality and Security Best Practices
+ `.trim();
+ }
+
+ previewCV(): Observable {
+ // Return CV data for preview
+ return of(this.generateCVData()).pipe(delay(500));
+ }
+
+ getCVFormats(): string[] {
+ return ['pdf', 'doc'];
+ }
+}
diff --git a/src/app/shared/services/error-handler.service.spec.ts b/src/app/shared/services/error-handler.service.spec.ts
new file mode 100644
index 0000000..d74faff
--- /dev/null
+++ b/src/app/shared/services/error-handler.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { ErrorHandlerService } from './error-handler.service';
+
+describe('ErrorHandlerService', () => {
+ let service: ErrorHandlerService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(ErrorHandlerService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/services/error-handler.service.ts b/src/app/shared/services/error-handler.service.ts
new file mode 100644
index 0000000..4ef44aa
--- /dev/null
+++ b/src/app/shared/services/error-handler.service.ts
@@ -0,0 +1,106 @@
+import { Injectable, ErrorHandler } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+export interface AppError {
+ id: string;
+ message: string;
+ type: 'error' | 'warning' | 'info' | 'success';
+ timestamp: Date;
+ details?: any;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ErrorHandlerService implements ErrorHandler {
+ private errorsSubject = new BehaviorSubject([]);
+ private errorCount = 0;
+
+ constructor() { }
+
+ handleError(error: any): void {
+ console.error('Global error handler:', error);
+
+ const appError: AppError = {
+ id: `error_${++this.errorCount}`,
+ message: this.extractErrorMessage(error),
+ type: 'error',
+ timestamp: new Date(),
+ details: error
+ };
+
+ this.addError(appError);
+ }
+
+ get errors$(): Observable {
+ return this.errorsSubject.asObservable();
+ }
+
+ addError(error: AppError): void {
+ const currentErrors = this.errorsSubject.value;
+ this.errorsSubject.next([...currentErrors, error]);
+
+ // Auto-remove error after 5 seconds for non-critical errors
+ if (error.type !== 'error') {
+ setTimeout(() => {
+ this.removeError(error.id);
+ }, 5000);
+ }
+ }
+
+ removeError(errorId: string): void {
+ const currentErrors = this.errorsSubject.value;
+ const filteredErrors = currentErrors.filter(error => error.id !== errorId);
+ this.errorsSubject.next(filteredErrors);
+ }
+
+ clearAllErrors(): void {
+ this.errorsSubject.next([]);
+ }
+
+ showSuccess(message: string): void {
+ const successError: AppError = {
+ id: `success_${++this.errorCount}`,
+ message,
+ type: 'success',
+ timestamp: new Date()
+ };
+ this.addError(successError);
+ }
+
+ showWarning(message: string): void {
+ const warningError: AppError = {
+ id: `warning_${++this.errorCount}`,
+ message,
+ type: 'warning',
+ timestamp: new Date()
+ };
+ this.addError(warningError);
+ }
+
+ showInfo(message: string): void {
+ const infoError: AppError = {
+ id: `info_${++this.errorCount}`,
+ message,
+ type: 'info',
+ timestamp: new Date()
+ };
+ this.addError(infoError);
+ }
+
+ private extractErrorMessage(error: any): string {
+ if (error?.message) {
+ return error.message;
+ }
+
+ if (error?.error?.message) {
+ return error.error.message;
+ }
+
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ return 'An unexpected error occurred';
+ }
+}
diff --git a/src/app/shared/services/loading.service.spec.ts b/src/app/shared/services/loading.service.spec.ts
new file mode 100644
index 0000000..dd3193c
--- /dev/null
+++ b/src/app/shared/services/loading.service.spec.ts
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { LoadingService } from './loading.service';
+
+describe('LoadingService', () => {
+ let service: LoadingService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(LoadingService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/services/loading.service.ts b/src/app/shared/services/loading.service.ts
new file mode 100644
index 0000000..bbf2afd
--- /dev/null
+++ b/src/app/shared/services/loading.service.ts
@@ -0,0 +1,66 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LoadingService {
+ private loadingSubject = new BehaviorSubject(false);
+ private loadingStates = new Map();
+
+ constructor() { }
+
+ // Global loading state
+ get isLoading$(): Observable {
+ return this.loadingSubject.asObservable();
+ }
+
+ get isLoading(): boolean {
+ return this.loadingSubject.value;
+ }
+
+ setLoading(loading: boolean): void {
+ this.loadingSubject.next(loading);
+ }
+
+ // Component-specific loading states
+ setComponentLoading(component: string, loading: boolean): void {
+ this.loadingStates.set(component, loading);
+ this.updateGlobalLoadingState();
+ }
+
+ getComponentLoading(component: string): boolean {
+ return this.loadingStates.get(component) || false;
+ }
+
+ isComponentLoading$(component: string): Observable {
+ return new BehaviorSubject(this.getComponentLoading(component)).asObservable();
+ }
+
+ private updateGlobalLoadingState(): void {
+ const hasLoadingComponents = Array.from(this.loadingStates.values()).some(loading => loading);
+ this.loadingSubject.next(hasLoadingComponents);
+ }
+
+ // Utility method to wrap async operations
+ async withLoading(operation: () => Promise, component?: string): Promise {
+ try {
+ if (component) {
+ this.setComponentLoading(component, true);
+ } else {
+ this.setLoading(true);
+ }
+
+ const result = await operation();
+ return result;
+ } catch (error) {
+ throw error;
+ } finally {
+ if (component) {
+ this.setComponentLoading(component, false);
+ } else {
+ this.setLoading(false);
+ }
+ }
+ }
+}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 5eb19d0..f493f76 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -11,6 +11,10 @@ import { TestimonialCardComponent } from './testimonial-card/testimonial-card.co
import { RouterModule } from '@angular/router';
import { ChipsComponent } from './chips/chips.component';
import { TranslateModule } from '@ngx-translate/core';
+import { SkeletonLoaderComponent } from './skeleton-loader/skeleton-loader.component';
+import { SmoothScrollDirective } from './directives/smooth-scroll.directive';
+import { IntersectionObserverDirective } from './directives/intersection-observer.directive';
+import { ToastNotificationComponent } from './toast-notification/toast-notification.component';
@NgModule({
@@ -23,6 +27,10 @@ import { TranslateModule } from '@ngx-translate/core';
ProcessInfoComponent,
TestimonialCardComponent,
ChipsComponent,
+ SkeletonLoaderComponent,
+ SmoothScrollDirective,
+ IntersectionObserverDirective,
+ ToastNotificationComponent,
],
imports: [
@@ -40,7 +48,11 @@ import { TranslateModule } from '@ngx-translate/core';
HeadingComponent,
CustomButtonComponent,
IconComponent,
- ChipsComponent
+ ChipsComponent,
+ SkeletonLoaderComponent,
+ SmoothScrollDirective,
+ IntersectionObserverDirective,
+ ToastNotificationComponent
]
})
export class SharedModule { }
diff --git a/src/app/shared/skeleton-loader/skeleton-loader.component.html b/src/app/shared/skeleton-loader/skeleton-loader.component.html
new file mode 100644
index 0000000..eaa12ec
--- /dev/null
+++ b/src/app/shared/skeleton-loader/skeleton-loader.component.html
@@ -0,0 +1,7 @@
+
diff --git a/src/app/shared/skeleton-loader/skeleton-loader.component.scss b/src/app/shared/skeleton-loader/skeleton-loader.component.scss
new file mode 100644
index 0000000..0b503a7
--- /dev/null
+++ b/src/app/shared/skeleton-loader/skeleton-loader.component.scss
@@ -0,0 +1,57 @@
+.skeleton-container {
+ display: block;
+ width: 100%;
+}
+
+.skeleton {
+ background-color: #e2e8f0;
+ border-radius: 4px;
+ margin-bottom: 8px;
+ position: relative;
+ overflow: hidden;
+}
+
+.skeleton-animated {
+ background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
+}
+
+.skeleton-card {
+ height: 120px;
+ border-radius: 8px;
+}
+
+.skeleton-text {
+ height: 16px;
+ border-radius: 4px;
+}
+
+.skeleton-circle {
+ border-radius: 50%;
+}
+
+.skeleton-rectangle {
+ border-radius: 4px;
+}
+
+@keyframes skeleton-loading {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+/* Dark theme support */
+@media (prefers-color-scheme: dark) {
+ .skeleton {
+ background-color: #374151;
+ }
+
+ .skeleton-animated {
+ background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
+ background-size: 200% 100%;
+ }
+}
diff --git a/src/app/shared/skeleton-loader/skeleton-loader.component.spec.ts b/src/app/shared/skeleton-loader/skeleton-loader.component.spec.ts
new file mode 100644
index 0000000..4a8e622
--- /dev/null
+++ b/src/app/shared/skeleton-loader/skeleton-loader.component.spec.ts
@@ -0,0 +1,21 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SkeletonLoaderComponent } from './skeleton-loader.component';
+
+describe('SkeletonLoaderComponent', () => {
+ let component: SkeletonLoaderComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [SkeletonLoaderComponent]
+ });
+ fixture = TestBed.createComponent(SkeletonLoaderComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/skeleton-loader/skeleton-loader.component.ts b/src/app/shared/skeleton-loader/skeleton-loader.component.ts
new file mode 100644
index 0000000..8e63829
--- /dev/null
+++ b/src/app/shared/skeleton-loader/skeleton-loader.component.ts
@@ -0,0 +1,34 @@
+import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
+
+@Component({
+ selector: 'app-skeleton-loader',
+ templateUrl: './skeleton-loader.component.html',
+ styleUrls: ['./skeleton-loader.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SkeletonLoaderComponent {
+ @Input() type: 'card' | 'text' | 'circle' | 'rectangle' = 'rectangle';
+ @Input() width: string = '100%';
+ @Input() height: string = '20px';
+ @Input() count: number = 1;
+ @Input() animated: boolean = true;
+
+ get skeletonClass(): string {
+ return `skeleton skeleton-${this.type} ${this.animated ? 'skeleton-animated' : ''}`;
+ }
+
+ get skeletonStyle(): { [key: string]: string } {
+ return {
+ width: this.width,
+ height: this.height
+ };
+ }
+
+ getArray(count: number): number[] {
+ return Array(count).fill(0).map((_, i) => i);
+ }
+
+ trackByIndex(index: number): number {
+ return index;
+ }
+}
diff --git a/src/app/shared/toast-notification/toast-notification.component.html b/src/app/shared/toast-notification/toast-notification.component.html
new file mode 100644
index 0000000..d4d6338
--- /dev/null
+++ b/src/app/shared/toast-notification/toast-notification.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+
{{ getErrorIcon(error.type) }}
+
+
{{ error.message }}
+
{{ error.timestamp | date:'short' }}
+
+
+
+
+
+
diff --git a/src/app/shared/toast-notification/toast-notification.component.scss b/src/app/shared/toast-notification/toast-notification.component.scss
new file mode 100644
index 0000000..86eb8bc
--- /dev/null
+++ b/src/app/shared/toast-notification/toast-notification.component.scss
@@ -0,0 +1,122 @@
+.toast-container {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 1000;
+ max-width: 400px;
+ width: 100%;
+}
+
+.toast-notification {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 16px;
+ margin-bottom: 12px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: white;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+
+.toast-notification--success {
+ background: linear-gradient(135deg, rgba(34, 197, 94, 0.9), rgba(22, 163, 74, 0.9));
+ border-color: rgba(34, 197, 94, 0.3);
+}
+
+.toast-notification--error {
+ background: linear-gradient(135deg, rgba(239, 68, 68, 0.9), rgba(220, 38, 38, 0.9));
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+.toast-notification--warning {
+ background: linear-gradient(135deg, rgba(245, 158, 11, 0.9), rgba(217, 119, 6, 0.9));
+ border-color: rgba(245, 158, 11, 0.3);
+}
+
+.toast-notification--info {
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9));
+ border-color: rgba(59, 130, 246, 0.3);
+}
+
+.toast-content {
+ display: flex;
+ align-items: flex-start;
+ flex: 1;
+}
+
+.toast-icon {
+ font-size: 18px;
+ font-weight: bold;
+ margin-right: 12px;
+ margin-top: 2px;
+ flex-shrink: 0;
+}
+
+.toast-message {
+ flex: 1;
+}
+
+.toast-text {
+ margin: 0 0 4px 0;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+.toast-timestamp {
+ font-size: 12px;
+ opacity: 0.8;
+}
+
+.toast-close {
+ background: none;
+ border: none;
+ color: currentColor;
+ cursor: pointer;
+ font-size: 20px;
+ font-weight: bold;
+ line-height: 1;
+ margin-left: 12px;
+ padding: 0;
+ opacity: 0.7;
+ transition: opacity 0.2s ease;
+}
+
+.toast-close:hover {
+ opacity: 1;
+}
+
+.animate-slide-in {
+ animation: slideIn 0.3s ease-out forwards;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Mobile responsiveness */
+@media (max-width: 640px) {
+ .toast-container {
+ left: 20px;
+ right: 20px;
+ max-width: none;
+ }
+
+ .toast-notification {
+ padding: 12px;
+ }
+
+ .toast-text {
+ font-size: 13px;
+ }
+}
diff --git a/src/app/shared/toast-notification/toast-notification.component.spec.ts b/src/app/shared/toast-notification/toast-notification.component.spec.ts
new file mode 100644
index 0000000..d23d817
--- /dev/null
+++ b/src/app/shared/toast-notification/toast-notification.component.spec.ts
@@ -0,0 +1,21 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastNotificationComponent } from './toast-notification.component';
+
+describe('ToastNotificationComponent', () => {
+ let component: ToastNotificationComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [ToastNotificationComponent]
+ });
+ fixture = TestBed.createComponent(ToastNotificationComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/toast-notification/toast-notification.component.ts b/src/app/shared/toast-notification/toast-notification.component.ts
new file mode 100644
index 0000000..eba81ca
--- /dev/null
+++ b/src/app/shared/toast-notification/toast-notification.component.ts
@@ -0,0 +1,64 @@
+import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
+import { Observable, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { ErrorHandlerService, AppError } from '../services/error-handler.service';
+
+@Component({
+ selector: 'app-toast-notification',
+ templateUrl: './toast-notification.component.html',
+ styleUrls: ['./toast-notification.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ToastNotificationComponent implements OnInit, OnDestroy {
+ errors$: Observable;
+ private destroy$ = new Subject();
+
+ constructor(
+ private errorHandler: ErrorHandlerService,
+ private cdr: ChangeDetectorRef
+ ) {
+ this.errors$ = this.errorHandler.errors$;
+ }
+
+ ngOnInit(): void {
+ this.errors$.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(() => {
+ this.cdr.markForCheck();
+ });
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ removeError(errorId: string): void {
+ this.errorHandler.removeError(errorId);
+ }
+
+ getErrorIcon(type: string): string {
+ switch (type) {
+ case 'success': return '✓';
+ case 'warning': return '⚠';
+ case 'info': return 'ℹ';
+ case 'error': return '✕';
+ default: return 'ℹ';
+ }
+ }
+
+ getErrorClass(type: string): string {
+ const baseClass = 'toast-notification';
+ switch (type) {
+ case 'success': return `${baseClass} ${baseClass}--success`;
+ case 'warning': return `${baseClass} ${baseClass}--warning`;
+ case 'info': return `${baseClass} ${baseClass}--info`;
+ case 'error': return `${baseClass} ${baseClass}--error`;
+ default: return baseClass;
+ }
+ }
+
+ trackByError(index: number, error: AppError): string {
+ return error.id;
+ }
+}