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. +
+ +
+
+ +
+ + + + {{ getFieldError('name') }} + +
+ + +
+ + + + {{ getFieldError('email') }} + +
+
+ + +
+ + +
+ + +
+ + + + {{ getFieldError('subject') }} + +
+ + +
+ + + + {{ getFieldError('message') }} + +
+ + +
+ +
+
+
+ + +
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; + } +}