From 55d9df754f50a98c6f390128969650d3aafd95d0 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 20 Oct 2025 17:34:53 +0300 Subject: [PATCH 01/32] feat: first reg component (html+css), add main registration data models, init reg service --- package-lock.json | 36 ++- package.json | 2 + src/app/app.html | 343 +-------------------- src/app/app.routes.ts | 9 +- src/app/components/sign-up/sign-up.html | 34 ++ src/app/components/sign-up/sign-up.scss | 142 +++++++++ src/app/components/sign-up/sign-up.spec.ts | 23 ++ src/app/components/sign-up/sign-up.ts | 11 + src/app/models/registration-types.ts | 40 +++ src/app/services/registration.spec.ts | 16 + src/app/services/registration.ts | 8 + src/index.html | 2 + src/styles.scss | 38 +++ 13 files changed, 359 insertions(+), 345 deletions(-) create mode 100644 src/app/components/sign-up/sign-up.html create mode 100644 src/app/components/sign-up/sign-up.scss create mode 100644 src/app/components/sign-up/sign-up.spec.ts create mode 100644 src/app/components/sign-up/sign-up.ts create mode 100644 src/app/models/registration-types.ts create mode 100644 src/app/services/registration.spec.ts create mode 100644 src/app/services/registration.ts diff --git a/package-lock.json b/package-lock.json index fb5af7c..7c21044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "name": "multi-form", "version": "0.0.0", "dependencies": { + "@angular/cdk": "^20.2.9", "@angular/common": "^20.2.0", "@angular/compiler": "^20.2.0", "@angular/core": "^20.2.0", "@angular/forms": "^20.2.0", + "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.2.0", "@angular/router": "^20.2.0", "rxjs": "~7.8.0", @@ -416,6 +418,21 @@ } } }, + "node_modules/@angular/cdk": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.9.tgz", + "integrity": "sha512-rbY1AMz9389WJI29iAjWp4o0QKRQHCrQQUuP0ctNQzh1tgWpwiRLx8N4yabdVdsCA846vPsyKJtBlSNwKMsjJA==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "20.3.6", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.6.tgz", @@ -555,6 +572,23 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-20.2.9.tgz", + "integrity": "sha512-xo/ozyRXCoJMi89XLTJI6fdPKBv2wBngWMiCrtTg23+pHbuyA/kDbk3v62eJkDD1xdhC4auXaIHu4Ddf5zTgSA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "20.2.9", + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/forms": "^20.0.0 || ^21.0.0", + "@angular/platform-browser": "^20.0.0 || ^21.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "20.3.6", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.6.tgz", @@ -7616,7 +7650,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -7670,7 +7703,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" diff --git a/package.json b/package.json index f753864..66394f9 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ }, "private": true, "dependencies": { + "@angular/cdk": "^20.2.9", "@angular/common": "^20.2.0", "@angular/compiler": "^20.2.0", "@angular/core": "^20.2.0", "@angular/forms": "^20.2.0", + "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.2.0", "@angular/router": "^20.2.0", "rxjs": "~7.8.0", diff --git a/src/app/app.html b/src/app/app.html index 7528372..90c6b64 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,342 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - - + \ No newline at end of file diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..ce76ff6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,3 +1,10 @@ import { Routes } from '@angular/router'; +import { SignUp } from './components/sign-up/sign-up'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: '', redirectTo:'/signin', pathMatch: 'full'}, + { path: 'signin', component: SignUp }, + // { path: 'emailsignin', component: EmailSignIn }, + // { path: 'additional', component: Additional }, + // { path: 'rules', component: Rules } +]; diff --git a/src/app/components/sign-up/sign-up.html b/src/app/components/sign-up/sign-up.html new file mode 100644 index 0000000..7a155da --- /dev/null +++ b/src/app/components/sign-up/sign-up.html @@ -0,0 +1,34 @@ +
+

Выберите способ регистрации

+
+
+
...
+

Email

+

Регистрация с помощью email

+
+
+
...
+

Социальные сети

+

Быстрая регистрация через социальные сети

+
+
+ + +
diff --git a/src/app/components/sign-up/sign-up.scss b/src/app/components/sign-up/sign-up.scss new file mode 100644 index 0000000..0ad0ef9 --- /dev/null +++ b/src/app/components/sign-up/sign-up.scss @@ -0,0 +1,142 @@ +.step-container { + max-width: 500px; + margin: 0 auto; + padding: 2rem; +} + +h2 { + text-align: center; + margin-bottom: 2rem; + color: #333; +} + +.method-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 2rem; +} + +.method-card { + border: 2px solid #e0e0e0; + border-radius: 12px; + padding: 1.5rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: white; + + h3 { + margin: 0.5rem 0; + color: #333; + } + + p { + margin: 0; + color: #666; + font-size: 0.9rem; + } + + &:hover { + border-color: #2196f3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(33, 150, 243, 0.1); + } + &.selected { + border-color: 2rem; + margin-bottom: 0.5rem; + } +} + +.method-icon { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.social-buttons { + margin: 2rem 0; + padding: 1.5rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #f9f9f9; + + p { + text-align: center; + margin-bottom: 1rem; + color: #666; + } +} + +.social-options { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.social-btn { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #f5f5f5; + transform: translateY(-1px); + } + + &.google:hover { + border-color: #db4437; + } + + &.meta:hover { + border-color: #1877f2; + } + + &.github:hover { + border-color: #333; + } +} + +.social-icon { + font-size: 1.2rem; +} + +.navigation { + text-align: center; + margin-top: 2rem; +} + +.btn-primary { + background: #2196f3; + color: white; + border: none; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s ease; + + &:hover:not(:disabled) { + background: #1976d2; + } + + &:disabled { + background: #ccc; + cursor: not-allowed; + } +} + +@media (max-width: 768px) { + .method-options { + grid-template-columns: 1fr; + } + + .step-container { + padding: 1rem; + } +} \ No newline at end of file diff --git a/src/app/components/sign-up/sign-up.spec.ts b/src/app/components/sign-up/sign-up.spec.ts new file mode 100644 index 0000000..7a7ebb5 --- /dev/null +++ b/src/app/components/sign-up/sign-up.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SignUp } from './sign-up'; + +describe('SignUp', () => { + let component: SignUp; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SignUp] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SignUp); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/components/sign-up/sign-up.ts new file mode 100644 index 0000000..fd2984b --- /dev/null +++ b/src/app/components/sign-up/sign-up.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-sign-up', + imports: [], + templateUrl: './sign-up.html', + styleUrl: './sign-up.scss' +}) +export class SignUp { + +} diff --git a/src/app/models/registration-types.ts b/src/app/models/registration-types.ts new file mode 100644 index 0000000..a1e7754 --- /dev/null +++ b/src/app/models/registration-types.ts @@ -0,0 +1,40 @@ +export type RegistrationData = { + method: 'email' | 'social', + basicInfo?: BasicInfo, + additionalInfo?: AdditionalInfo, + confirmation?: Confirmation, + socialProvider?: string +} + +export type BasicInfo = { + email: string, + name: string, + country: string, + phone?: string +} + +export type AdditionalInfo = { + address: Address, + birthDate: Date, + gender: string, + parentInfo?: ParentInfo +} + +export type Address = { + country: string, + city: string, + street: string +} + +export type ParentInfo = { + name: string, + email: string +} + +export type Confirmation = { + acceptTerms: boolean, + acceptPrivacy: boolean, + subscribe: boolean +} + +export type RegistrationStep = 'method' | 'basic' | 'additional' | 'confirmation'; \ No newline at end of file diff --git a/src/app/services/registration.spec.ts b/src/app/services/registration.spec.ts new file mode 100644 index 0000000..1cb6d28 --- /dev/null +++ b/src/app/services/registration.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Registration } from './registration'; + +describe('Registration', () => { + let service: Registration; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Registration); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/services/registration.ts b/src/app/services/registration.ts new file mode 100644 index 0000000..15066be --- /dev/null +++ b/src/app/services/registration.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class Registration { + +} diff --git a/src/index.html b/src/index.html index ff133ce..b796457 100644 --- a/src/index.html +++ b/src/index.html @@ -6,6 +6,8 @@ + + diff --git a/src/styles.scss b/src/styles.scss index 90d4ee0..940c192 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1 +1,39 @@ + +// Include theming for Angular Material with `mat.theme()`. +// This Sass mixin will define CSS variables that are used for styling Angular Material +// components according to the Material 3 design spec. +// Learn more about theming and how to use it for your application's +// custom components at https://material.angular.dev/guide/theming +@use '@angular/material' as mat; + +html { + @include mat.theme(( + color: ( + primary: mat.$azure-palette, + tertiary: mat.$blue-palette, + ), + typography: Roboto, + density: 0, + )); +} + +body { + // Default the application to a light color theme. This can be changed to + // `dark` to enable the dark color theme, or to `light dark` to defer to the + // user's system settings. + color-scheme: light; + + // Set a default background, font and text colors for the application using + // Angular Material's system-level CSS variables. Learn more about these + // variables at https://material.angular.dev/guide/system-variables + background-color: var(--mat-sys-surface); + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-medium); + + // Reset the user agent margin. + margin: 0; +} /* You can add global styles to this file, and also import other style files */ + +html, body { height: 100%; } +body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } From 763c7595b2b5aead8ef03aecb72bf75908bbe134 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 20 Oct 2025 17:46:57 +0300 Subject: [PATCH 02/32] feat: registration service for saving/loading from ls --- src/app/models/registration-types.ts | 2 +- src/app/services/registration.ts | 34 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/app/models/registration-types.ts b/src/app/models/registration-types.ts index a1e7754..dba7734 100644 --- a/src/app/models/registration-types.ts +++ b/src/app/models/registration-types.ts @@ -1,5 +1,5 @@ export type RegistrationData = { - method: 'email' | 'social', + method?: 'email' | 'social', basicInfo?: BasicInfo, additionalInfo?: AdditionalInfo, confirmation?: Confirmation, diff --git a/src/app/services/registration.ts b/src/app/services/registration.ts index 15066be..e3090c5 100644 --- a/src/app/services/registration.ts +++ b/src/app/services/registration.ts @@ -1,8 +1,40 @@ import { Injectable } from '@angular/core'; +import { RegistrationData } from '../models/registration-types'; +import { BehaviorSubject } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class Registration { - + private readonly STORAGE_KEY = 'registration_data'; + + private data: RegistrationData = {}; + private dataSubject = new BehaviorSubject(this.loadFromStorage()); + + public data$ = this.dataSubject.asObservable(); + + public updateData(updates: Partial): void { + this.data = { ...this.data, ...updates }; + this.saveToStorage(); + this.dataSubject.next(this.data); + } + + public getCurrentData(): RegistrationData { + return { ...this.data }; + } + + private saveToStorage(): void { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.data)); + } + + private loadFromStorage(): RegistrationData { + const stored = localStorage.getItem(this.STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } + + public clearData(): void { + localStorage.removeItem(this.STORAGE_KEY); + this.data = {}; + this.dataSubject.next(this.data); + } } From 531ced357ef7c3809c3bde588094a75be360b417 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 20 Oct 2025 21:04:44 +0300 Subject: [PATCH 03/32] feat: sign-up step 0 ready --- src/app/app.routes.ts | 16 ++++- src/app/components/sign-up/sign-up.html | 80 +++++++++++++-------- src/app/components/sign-up/sign-up.ts | 94 ++++++++++++++++++++++++- src/app/services/registration.ts | 11 +++ 4 files changed, 166 insertions(+), 35 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ce76ff6..8a65ff6 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,8 +2,20 @@ import { Routes } from '@angular/router'; import { SignUp } from './components/sign-up/sign-up'; export const routes: Routes = [ - { path: '', redirectTo:'/signin', pathMatch: 'full'}, - { path: 'signin', component: SignUp }, + { path: '', redirectTo:'/signup', pathMatch: 'full'}, + { + path: 'signup', + children: [ + { + path: '', + redirectTo: 'method', + pathMatch: 'full' + }, + { + path: 'method', + component: SignUp + } + ] }, // { path: 'emailsignin', component: EmailSignIn }, // { path: 'additional', component: Additional }, // { path: 'rules', component: Rules } diff --git a/src/app/components/sign-up/sign-up.html b/src/app/components/sign-up/sign-up.html index 7a155da..d336cb9 100644 --- a/src/app/components/sign-up/sign-up.html +++ b/src/app/components/sign-up/sign-up.html @@ -1,34 +1,54 @@

Выберите способ регистрации

-
-
-
...
-

Email

-

Регистрация с помощью email

+
+
+
+
📧
+

Email

+

Регистрация с помощью email

+
+
+
🌐
+

Социальные сети

+

Быстрая регистрация через социальные сети

+
-
-
...
-

Социальные сети

-

Быстрая регистрация через социальные сети

-
-
- - + + @if (methodForm.get("method")?.value === "social") { + + } + @if (methodForm.get("method")?.value === "email") { + + } +
diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/components/sign-up/sign-up.ts index fd2984b..1d85096 100644 --- a/src/app/components/sign-up/sign-up.ts +++ b/src/app/components/sign-up/sign-up.ts @@ -1,11 +1,99 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { Registration } from '../../services/registration'; +import { RegistrationData } from '../../models/registration-types'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ selector: 'app-sign-up', - imports: [], + imports: [ReactiveFormsModule], templateUrl: './sign-up.html', styleUrl: './sign-up.scss' }) -export class SignUp { +export class SignUp implements OnInit { + public methodForm: FormGroup; + public selectedMethod: 'email' | 'social' | null = null; + constructor( + private fb: FormBuilder, + private dataService: Registration, + private router: Router + ) { + this.methodForm = this.createForm(); + } + + public ngOnInit(): void { + const currentData = this.dataService.getCurrentData(); + if (currentData.method) { + this.methodForm.patchValue({ + method: currentData.method, + socialProvider: currentData.socialProvider || null + }); + } + + this.methodForm.valueChanges.subscribe(value => { + this.dataService.updateData(value); + }) + } + + private createForm(): FormGroup { + return this.fb.group({ + method: ['', Validators.required], + socialProvider: [''] + }); + } + + public selectMethod(method: 'email' | 'social'): void { + this.methodForm.patchValue({ + method, + socialProvider: method === 'email' ? null : this.methodForm.get('socialProvider')?.value + }); + } + + public onSubmit(): void { + if (this.methodForm.valid) { + this.dataService.updateData(this.methodForm.value); + + if (this.methodForm.get('method')?.value === 'email') { + this.router.navigate(['/signup', 'basic']); + } + } else { + this.markFormGroupTouched(); + } + } + + public mockSocialLogin(provider: string): void { + this.methodForm.patchValue({ socialProvider: provider }); + const mockData: RegistrationData = { + method: 'social', + socialProvider: provider, + basicInfo: { + email: 'john@doe.com', + name: 'John Doe', + country: 'Беларусь', + phone: '+375291112233' + }, + additionalInfo: { + address: { + country: 'Беларусь', + city: 'Минск', + street: 'Жукова' + }, + birthDate: new Date('1990-01-01'), + gender: 'другой' + } + }; + + this.dataService.updateData(mockData); + + this.router.navigate(['/signup', 'additional']); + } + + private markFormGroupTouched(): void { + Object.keys(this.methodForm.controls).forEach(key => { + const control = this.methodForm.get(key); + control?.markAsTouched(); + }) + } } diff --git a/src/app/services/registration.ts b/src/app/services/registration.ts index e3090c5..24e9acf 100644 --- a/src/app/services/registration.ts +++ b/src/app/services/registration.ts @@ -17,6 +17,7 @@ export class Registration { this.data = { ...this.data, ...updates }; this.saveToStorage(); this.dataSubject.next(this.data); + console.log(this.data) } public getCurrentData(): RegistrationData { @@ -27,6 +28,16 @@ export class Registration { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.data)); } + public getFormData(): Partial { + return this.data; + } + + public resetFormData(): void { + this.data = {}; + this.saveToStorage(); + this.dataSubject.next(this.data); + } + private loadFromStorage(): RegistrationData { const stored = localStorage.getItem(this.STORAGE_KEY); return stored ? JSON.parse(stored) : {}; From 995eaef21ed6494b27636c986d4b2caf1bbd8746 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 20 Oct 2025 22:21:29 +0300 Subject: [PATCH 04/32] feat: basic --- src/app/app.routes.ts | 6 +- .../country-select/country-select.html | 23 +++++ .../country-select/country-select.scss | 46 +++++++++ .../country-select/country-select.spec.ts | 23 +++++ .../country-select/country-select.ts | 98 +++++++++++++++++++ .../components/custom-input/custom-input.html | 18 ++++ .../components/custom-input/custom-input.scss | 45 +++++++++ .../custom-input/custom-input.spec.ts | 23 +++++ .../components/custom-input/custom-input.ts | 98 +++++++++++++++++++ src/app/components/step-basic/step-basic.html | 65 ++++++++++++ src/app/components/step-basic/step-basic.scss | 98 +++++++++++++++++++ .../components/step-basic/step-basic.spec.ts | 23 +++++ src/app/components/step-basic/step-basic.ts | 88 +++++++++++++++++ 13 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 src/app/components/country-select/country-select.html create mode 100644 src/app/components/country-select/country-select.scss create mode 100644 src/app/components/country-select/country-select.spec.ts create mode 100644 src/app/components/country-select/country-select.ts create mode 100644 src/app/components/custom-input/custom-input.html create mode 100644 src/app/components/custom-input/custom-input.scss create mode 100644 src/app/components/custom-input/custom-input.spec.ts create mode 100644 src/app/components/custom-input/custom-input.ts create mode 100644 src/app/components/step-basic/step-basic.html create mode 100644 src/app/components/step-basic/step-basic.scss create mode 100644 src/app/components/step-basic/step-basic.spec.ts create mode 100644 src/app/components/step-basic/step-basic.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 8a65ff6..3b80859 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { SignUp } from './components/sign-up/sign-up'; +import { StepBasic } from './components/step-basic/step-basic'; export const routes: Routes = [ { path: '', redirectTo:'/signup', pathMatch: 'full'}, @@ -14,9 +15,12 @@ export const routes: Routes = [ { path: 'method', component: SignUp + }, + { + path: 'basic', + component: StepBasic } ] }, - // { path: 'emailsignin', component: EmailSignIn }, // { path: 'additional', component: Additional }, // { path: 'rules', component: Rules } ]; diff --git a/src/app/components/country-select/country-select.html b/src/app/components/country-select/country-select.html new file mode 100644 index 0000000..1d3390f --- /dev/null +++ b/src/app/components/country-select/country-select.html @@ -0,0 +1,23 @@ +
+ + + @if (isInvalid && errorMessage()) { +
+ {{ errorMessage() }} +
+ } +
\ No newline at end of file diff --git a/src/app/components/country-select/country-select.scss b/src/app/components/country-select/country-select.scss new file mode 100644 index 0000000..0d22e3f --- /dev/null +++ b/src/app/components/country-select/country-select.scss @@ -0,0 +1,46 @@ +.form-field { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; + } + + select { + width: 100%; + padding: 0.75rem; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 1rem; + background: white; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #2196f3; + } + + &.error { + border-color: #d32f2f; + } + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + } + } + + &.invalid { + label { + color: #d32f2f; + } + } +} + +.error-message { + color: #d32f2f; + font-size: 0.875rem; + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/src/app/components/country-select/country-select.spec.ts b/src/app/components/country-select/country-select.spec.ts new file mode 100644 index 0000000..bfb5442 --- /dev/null +++ b/src/app/components/country-select/country-select.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CountrySelect } from './country-select'; + +describe('CountrySelect', () => { + let component: CountrySelect; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CountrySelect] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CountrySelect); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/country-select/country-select.ts b/src/app/components/country-select/country-select.ts new file mode 100644 index 0000000..fa5fca0 --- /dev/null +++ b/src/app/components/country-select/country-select.ts @@ -0,0 +1,98 @@ +import { Component, forwardRef, input, output } from '@angular/core'; +import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; + +type Country = { + code: string; + name: string; + phoneCode: string; +} + +@Component({ + selector: 'app-country-select', + imports: [ReactiveFormsModule], + templateUrl: './country-select.html', + styleUrl: './country-select.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CountrySelect), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CountrySelect), + multi: true + } + ] +}) +export class CountrySelect implements ControlValueAccessor, Validator { + public label = input('Страна'); + public id = input('country'); + public errorMessage = input('Выберите страну'); + public required = input(false); + public countryChange = output(); + + public value = ''; + public disabled = false; + public isInvalid = false; + + public countries: Country[] = [ + { code: 'by', name: 'Беларусь', phoneCode: '+375' }, + { code: 'ru', name: 'Россия', phoneCode: '+7' }, + { code: 'us', name: 'США', phoneCode: '+1' }, + { code: 'de', name: 'Германия', phoneCode: '+49' }, + { code: 'fr', name: 'Франция', phoneCode: '+33' }, + { code: 'it', name: 'Италия', phoneCode: '+39' }, + { code: 'es', name: 'Испания', phoneCode: '+34' }, + { code: 'jp', name: 'Япония', phoneCode: '+81' }, + { code: 'cn', name: 'Китай', phoneCode: '+86' } + ]; + + public onChange = (value: string) => {}; + public onTouched = () => {}; + public onValidatorChange = () => {}; + + public onChangeEvent(event: Event): void { + const value = (event.target as HTMLSelectElement).value; + this.value = value; + this.onChange(value); + this.countryChange.emit(value); + this.validateControl(); + } + + public writeValue(value: string): void { + this.value = value || ''; + } + + public registerOnChange(fn: any): void { + this.onChange = fn; + } + + public registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + public validate(control: AbstractControl): ValidationErrors | null { + const value = control.value; + + if (this.required() && !value) { + this.isInvalid = true; + return { required: true }; + } + + this.isInvalid = false; + return null; + } + + registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + + private validateControl(): void { + this.onValidatorChange(); + } +} diff --git a/src/app/components/custom-input/custom-input.html b/src/app/components/custom-input/custom-input.html new file mode 100644 index 0000000..ae71749 --- /dev/null +++ b/src/app/components/custom-input/custom-input.html @@ -0,0 +1,18 @@ +
+ + + @if (isInvalid && errorMessage()) { +
+ {{ errorMessage() }} +
+ } +
\ No newline at end of file diff --git a/src/app/components/custom-input/custom-input.scss b/src/app/components/custom-input/custom-input.scss new file mode 100644 index 0000000..753e44e --- /dev/null +++ b/src/app/components/custom-input/custom-input.scss @@ -0,0 +1,45 @@ +.form-field { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; + } + + input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #2196f3; + } + + &.error { + border-color: #d32f2f; + } + + &:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + } + } + + &.invalid { + label { + color: #d32f2f; + } + } +} + +.error-message { + color: #d32f2f; + font-size: 0.875rem; + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/src/app/components/custom-input/custom-input.spec.ts b/src/app/components/custom-input/custom-input.spec.ts new file mode 100644 index 0000000..ebd5b38 --- /dev/null +++ b/src/app/components/custom-input/custom-input.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CustomInput } from './custom-input'; + +describe('CustomInput', () => { + let component: CustomInput; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CustomInput] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CustomInput); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/custom-input/custom-input.ts b/src/app/components/custom-input/custom-input.ts new file mode 100644 index 0000000..688e340 --- /dev/null +++ b/src/app/components/custom-input/custom-input.ts @@ -0,0 +1,98 @@ +import { Component, forwardRef } from '@angular/core'; +import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; +import { input } from '@angular/core'; + +@Component({ + selector: 'app-custom-input', + imports: [ReactiveFormsModule], + templateUrl: './custom-input.html', + styleUrl: './custom-input.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomInput), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CustomInput), + multi: true + } + ] +}) +export class CustomInput implements ControlValueAccessor, Validator { + public label = input(''); + public type = input('text'); + public id = input(''); + public placeholder = input(''); + public errorMessage = input(''); + public required = input(false); + public minLength = input(); + public pattern = input(); + + public value = ''; + public disabled = false; + public isInvalid = false; + + private onChange = (value: string) => {}; + public onTouched = () => {}; + private onValidatorChange = () => {}; + + public onInput(event: Event): void { + const value = (event.target as HTMLInputElement).value; + this.value = value; + this.onChange(value); + this.validateControl(); + } + + public writeValue(value: string): void { + this.value = value || ''; + } + + public registerOnChange(fn: any): void { + this.onChange = fn; + } + + public registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + public validate(control: AbstractControl): ValidationErrors | null { + const value = control.value; + + if (this.required() && !value) { + this.isInvalid = true; + return { required: true }; + } + + if (value && this.minLength() && value.length < this.minLength()) { + this.isInvalid = true; + return { + minLength: { + requiredLength: this.minLength, + actualLength: value.length + } + }; + } + + if (value && this.pattern() && !new RegExp(this.pattern()).test(value)) { + this.isInvalid = true; + return { pattern: true }; + } + + this.isInvalid = false; + return null; + } + + public registerOnValidatorChange(fn: () => void): void { + this.onValidatorChange = fn; + } + + private validateControl(): void { + this.onValidatorChange(); + } +} diff --git a/src/app/components/step-basic/step-basic.html b/src/app/components/step-basic/step-basic.html new file mode 100644 index 0000000..b6b8810 --- /dev/null +++ b/src/app/components/step-basic/step-basic.html @@ -0,0 +1,65 @@ +
+

Основные данные

+ +
+ + + + + + + + + + + @if (showPhoneField) { + + + } + + + + +
+
\ No newline at end of file diff --git a/src/app/components/step-basic/step-basic.scss b/src/app/components/step-basic/step-basic.scss new file mode 100644 index 0000000..0ce4798 --- /dev/null +++ b/src/app/components/step-basic/step-basic.scss @@ -0,0 +1,98 @@ +.step-container { + max-width: 500px; + margin: 0 auto; + padding: 1rem; +} + +h2 { + text-align: center; + margin-bottom: 2rem; + color: #333; +} + +form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.navigation-buttons { + display: flex; + justify-content: space-between; + margin-top: 2rem; + gap: 1rem; +} + +.btn-primary { + background: #2196f3; + color: white; + border: none; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s ease; + flex: 1; + + &:hover:not(:disabled) { + background: #1976d2; + } + + &:disabled { + background: #ccc; + cursor: not-allowed; + } +} + +.btn-secondary { + background: #f5f5f5; + color: #333; + border: 1px solid #ddd; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + + &:hover { + background: #e0e0e0; + } +} + +.form-debug { + margin-top: 2rem; + padding: 1rem; + background: #f5f5f5; + border-radius: 4px; + font-family: monospace; + font-size: 0.8rem; +} + +// Анимация появления поля телефона +app-custom-input { + &:last-of-type { + animation: slideDown 0.3s ease; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .step-container { + padding: 0.5rem; + } + + .navigation-buttons { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/app/components/step-basic/step-basic.spec.ts b/src/app/components/step-basic/step-basic.spec.ts new file mode 100644 index 0000000..e94bb4c --- /dev/null +++ b/src/app/components/step-basic/step-basic.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StepBasic } from './step-basic'; + +describe('StepBasic', () => { + let component: StepBasic; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StepBasic] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StepBasic); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/step-basic/step-basic.ts b/src/app/components/step-basic/step-basic.ts new file mode 100644 index 0000000..dfeb3aa --- /dev/null +++ b/src/app/components/step-basic/step-basic.ts @@ -0,0 +1,88 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { CustomInput } from '../custom-input/custom-input'; +import { CountrySelect } from '../country-select/country-select'; +import { Registration } from '../../services/registration'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-step-basic', + imports: [ReactiveFormsModule, CustomInput, CountrySelect], + templateUrl: './step-basic.html', + styleUrl: './step-basic.scss' +}) +export class StepBasic implements OnInit { + public basicInfoForm: FormGroup; + public showPhoneField = false; + + constructor( + private fb: FormBuilder, + private dataService: Registration, + private router: Router + ) { + this.basicInfoForm = this.createForm(); + } + + public ngOnInit(): void { + const currentData = this.dataService.getCurrentData(); + + if (currentData.basicInfo) { + this.basicInfoForm.patchValue(currentData.basicInfo); + + if (currentData.basicInfo.country) { + this.showPhoneField = true; + } + } + + this.basicInfoForm.valueChanges.subscribe(value => { + this.dataService.updateData({ basicInfo: value }); + }); + + this.basicInfoForm.get('country')?.valueChanges.subscribe(country => { + this.showPhoneField = !!country; + + if (!country) { + this.basicInfoForm.patchValue({ phone: '' }); + } + }) + } + + private createForm(): FormGroup { + return this.fb.group({ + email: ['', [Validators.required, Validators.email]], + name: ['', [Validators.required, Validators.minLength(2)]], + country: ['', Validators.required], + phone: [''] + }); + } + + public onCountryChange(countryCode: string): void { + this.showPhoneField = !!countryCode; + console.log('Selected country:', countryCode); + } + + public onSubmit(): void { + if (this.basicInfoForm.valid) { + this.dataService.updateData({ basicInfo: this.basicInfoForm.value }); + this.router.navigate(['/signup', 'additional']); + } else { + this.markFormGroupTouched(); + } + } + + public goBack(): void { + this.router.navigate(['/signup', 'method']); + } + + private markFormGroupTouched(): void { + Object.keys(this.basicInfoForm.controls).forEach(key => { + const control = this.basicInfoForm.get(key); + control?.markAsTouched(); + }); + } + + public get email() { return this.basicInfoForm.get('email'); } + public get name() { return this.basicInfoForm.get('name'); } + public get country() { return this.basicInfoForm.get('country'); } + public get phone() { return this.basicInfoForm.get('phone'); } +} From 0007d7207caf17872e192d6db09aa71ced74f5d6 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 21 Oct 2025 12:41:35 +0300 Subject: [PATCH 05/32] feat: custom inputs fixes, phone number musk implement --- package-lock.json | 32 +++++++++++++++++ package.json | 3 +- .../country-select/country-select.html | 4 +-- .../components/custom-input/custom-input.html | 35 +++++++++++++------ .../components/custom-input/custom-input.ts | 6 ++-- src/app/components/sign-up/sign-up.ts | 4 +-- src/app/components/step-basic/step-basic.html | 1 - src/app/components/step-basic/step-basic.ts | 5 +-- 8 files changed, 70 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c21044..5c568af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.2.0", "@angular/router": "^20.2.0", + "ngx-mask": "^20.0.3", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -319,6 +320,23 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "20.3.6", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.6.tgz", + "integrity": "sha512-qNaVvEOKvigoCQMg0ABnq44HhiHqKD4WN3KoUcXneklcMYCzFE5nuQxKylfWzCRiI5XqiJ9pqiL1m2D7o+Vdiw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "20.3.6" + } + }, "node_modules/@angular/build": { "version": "20.3.6", "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.6.tgz", @@ -7132,6 +7150,20 @@ "node": ">= 0.6" } }, + "node_modules/ngx-mask": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/ngx-mask/-/ngx-mask-20.0.3.tgz", + "integrity": "sha512-5bmrgbFGudj0mFN6cPv/TI+cFJxT4l61mLIFskdvaXsJL/Oj7thRmWYqvqHXjCboOcx8gT6T/Zypl5u9l2J8Jg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=14.0.0", + "@angular/core": ">=14.0.0", + "@angular/forms": ">=14.0.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", diff --git a/package.json b/package.json index 66394f9..72c1e1e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@angular/material": "^20.2.9", "@angular/platform-browser": "^20.2.0", "@angular/router": "^20.2.0", + "ngx-mask": "^20.0.3", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -46,4 +47,4 @@ "karma-jasmine-html-reporter": "~2.1.0", "typescript": "~5.9.2" } -} \ No newline at end of file +} diff --git a/src/app/components/country-select/country-select.html b/src/app/components/country-select/country-select.html index 1d3390f..a45556f 100644 --- a/src/app/components/country-select/country-select.html +++ b/src/app/components/country-select/country-select.html @@ -10,8 +10,8 @@ > @for (country of countries; track country.phoneCode) { - } diff --git a/src/app/components/custom-input/custom-input.html b/src/app/components/custom-input/custom-input.html index ae71749..d518d46 100644 --- a/src/app/components/custom-input/custom-input.html +++ b/src/app/components/custom-input/custom-input.html @@ -1,15 +1,30 @@
- + @if (type() === 'tel') { + + } + @else { + + } @if (isInvalid && errorMessage()) {
{{ errorMessage() }} diff --git a/src/app/components/custom-input/custom-input.ts b/src/app/components/custom-input/custom-input.ts index 688e340..63fa517 100644 --- a/src/app/components/custom-input/custom-input.ts +++ b/src/app/components/custom-input/custom-input.ts @@ -1,10 +1,11 @@ import { Component, forwardRef } from '@angular/core'; import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; import { input } from '@angular/core'; +import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; @Component({ selector: 'app-custom-input', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, NgxMaskDirective], templateUrl: './custom-input.html', styleUrl: './custom-input.scss', providers: [ @@ -17,7 +18,8 @@ import { input } from '@angular/core'; provide: NG_VALIDATORS, useExisting: forwardRef(() => CustomInput), multi: true - } + }, + provideNgxMask() ] }) export class CustomInput implements ControlValueAccessor, Validator { diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/components/sign-up/sign-up.ts index 1d85096..210ae7c 100644 --- a/src/app/components/sign-up/sign-up.ts +++ b/src/app/components/sign-up/sign-up.ts @@ -71,8 +71,8 @@ export class SignUp implements OnInit { basicInfo: { email: 'john@doe.com', name: 'John Doe', - country: 'Беларусь', - phone: '+375291112233' + country: 'by', + phone: '(029) 111-22-33' }, additionalInfo: { address: { diff --git a/src/app/components/step-basic/step-basic.html b/src/app/components/step-basic/step-basic.html index b6b8810..e2a160e 100644 --- a/src/app/components/step-basic/step-basic.html +++ b/src/app/components/step-basic/step-basic.html @@ -32,7 +32,6 @@

Основные данные

errorMessage="Пожалуйста, выберите страну" (countryChange)="onCountryChange($event)"> - @if (showPhoneField) { Date: Tue, 21 Oct 2025 14:56:06 +0300 Subject: [PATCH 06/32] feat: some fixes, unsubs and some scss --- .../country-select/country-select.scss | 16 +++-- .../country-select/country-select.ts | 20 +----- .../components/custom-input/custom-input.scss | 14 +++-- src/app/components/sign-up/sign-up.html | 11 ++-- src/app/components/sign-up/sign-up.ts | 62 +++++++------------ src/app/components/step-basic/step-basic.html | 2 + src/app/components/step-basic/step-basic.scss | 1 - src/app/components/step-basic/step-basic.ts | 35 +++++++---- src/app/models/mock-data.ts | 32 ++++++++++ src/app/models/registration-types.ts | 11 +++- src/app/services/registration.ts | 2 - 11 files changed, 113 insertions(+), 93 deletions(-) create mode 100644 src/app/models/mock-data.ts diff --git a/src/app/components/country-select/country-select.scss b/src/app/components/country-select/country-select.scss index 0d22e3f..7d514e6 100644 --- a/src/app/components/country-select/country-select.scss +++ b/src/app/components/country-select/country-select.scss @@ -1,3 +1,7 @@ +$error_collor: #d32f2f; +$color_1: #e0e0e0; +$color_2: #2196f3; + .form-field { margin-bottom: 1.5rem; @@ -11,7 +15,7 @@ select { width: 100%; padding: 0.75rem; - border: 2px solid #e0e0e0; + border: 2px solid $color_1; border-radius: 6px; font-size: 1rem; background: white; @@ -19,28 +23,28 @@ &:focus { outline: none; - border-color: #2196f3; + border-color: $color_2; } &.error { - border-color: #d32f2f; + border-color: $error_collor; } &:disabled { - background-color: #f5f5f5; + background-color: $color_1; cursor: not-allowed; } } &.invalid { label { - color: #d32f2f; + color: $error_collor; } } } .error-message { - color: #d32f2f; + color: $error_collor; font-size: 0.875rem; margin-top: 0.25rem; } \ No newline at end of file diff --git a/src/app/components/country-select/country-select.ts b/src/app/components/country-select/country-select.ts index fa5fca0..323fb10 100644 --- a/src/app/components/country-select/country-select.ts +++ b/src/app/components/country-select/country-select.ts @@ -1,11 +1,7 @@ import { Component, forwardRef, input, output } from '@angular/core'; import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; - -type Country = { - code: string; - name: string; - phoneCode: string; -} +import { countries } from '../../models/mock-data'; +import { Country } from '../../models/registration-types'; @Component({ selector: 'app-country-select', @@ -36,17 +32,7 @@ export class CountrySelect implements ControlValueAccessor, Validator { public disabled = false; public isInvalid = false; - public countries: Country[] = [ - { code: 'by', name: 'Беларусь', phoneCode: '+375' }, - { code: 'ru', name: 'Россия', phoneCode: '+7' }, - { code: 'us', name: 'США', phoneCode: '+1' }, - { code: 'de', name: 'Германия', phoneCode: '+49' }, - { code: 'fr', name: 'Франция', phoneCode: '+33' }, - { code: 'it', name: 'Италия', phoneCode: '+39' }, - { code: 'es', name: 'Испания', phoneCode: '+34' }, - { code: 'jp', name: 'Япония', phoneCode: '+81' }, - { code: 'cn', name: 'Китай', phoneCode: '+86' } - ]; + public countries: Country[] = countries; public onChange = (value: string) => {}; public onTouched = () => {}; diff --git a/src/app/components/custom-input/custom-input.scss b/src/app/components/custom-input/custom-input.scss index 753e44e..fedf265 100644 --- a/src/app/components/custom-input/custom-input.scss +++ b/src/app/components/custom-input/custom-input.scss @@ -1,3 +1,7 @@ +$error_collor: #d32f2f; +$color_1: #e0e0e0; +$color_2: #2196f3; + .form-field { margin-bottom: 1.5rem; @@ -11,18 +15,18 @@ input { width: 100%; padding: 0.75rem; - border: 2px solid #e0e0e0; + border: 2px solid $color_1; border-radius: 6px; font-size: 1rem; transition: border-color 0.2s ease; &:focus { outline: none; - border-color: #2196f3; + border-color: $color_2; } &.error { - border-color: #d32f2f; + border-color: $error_collor; } &:disabled { @@ -33,13 +37,13 @@ &.invalid { label { - color: #d32f2f; + color: $error_collor; } } } .error-message { - color: #d32f2f; + color: $error_collor; font-size: 0.875rem; margin-top: 0.25rem; } \ No newline at end of file diff --git a/src/app/components/sign-up/sign-up.html b/src/app/components/sign-up/sign-up.html index d336cb9..d7b03a2 100644 --- a/src/app/components/sign-up/sign-up.html +++ b/src/app/components/sign-up/sign-up.html @@ -20,24 +20,21 @@

Социальные сети

-
} @if (methodForm.get("method")?.value === "email") { diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/components/sign-up/sign-up.ts index 210ae7c..7ba0220 100644 --- a/src/app/components/sign-up/sign-up.ts +++ b/src/app/components/sign-up/sign-up.ts @@ -1,9 +1,10 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Registration } from '../../services/registration'; import { RegistrationData } from '../../models/registration-types'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { mockUser } from '../../models/mock-data'; +import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'app-sign-up', @@ -11,9 +12,10 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula templateUrl: './sign-up.html', styleUrl: './sign-up.scss' }) -export class SignUp implements OnInit { +export class SignUp implements OnInit, OnDestroy { public methodForm: FormGroup; public selectedMethod: 'email' | 'social' | null = null; + private destroy$ = new Subject(); constructor( private fb: FormBuilder, @@ -24,30 +26,23 @@ export class SignUp implements OnInit { } public ngOnInit(): void { - const currentData = this.dataService.getCurrentData(); - if (currentData.method) { - this.methodForm.patchValue({ - method: currentData.method, - socialProvider: currentData.socialProvider || null - }); - } - - this.methodForm.valueChanges.subscribe(value => { - this.dataService.updateData(value); - }) + this.dataService.clearData(); + this.methodForm.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(value => { + this.dataService.updateData(value); + }) } private createForm(): FormGroup { return this.fb.group({ - method: ['', Validators.required], - socialProvider: [''] + method: ['', Validators.required] }); } public selectMethod(method: 'email' | 'social'): void { this.methodForm.patchValue({ - method, - socialProvider: method === 'email' ? null : this.methodForm.get('socialProvider')?.value + method }); } @@ -63,27 +58,8 @@ export class SignUp implements OnInit { } } - public mockSocialLogin(provider: string): void { - this.methodForm.patchValue({ socialProvider: provider }); - const mockData: RegistrationData = { - method: 'social', - socialProvider: provider, - basicInfo: { - email: 'john@doe.com', - name: 'John Doe', - country: 'by', - phone: '(029) 111-22-33' - }, - additionalInfo: { - address: { - country: 'Беларусь', - city: 'Минск', - street: 'Жукова' - }, - birthDate: new Date('1990-01-01'), - gender: 'другой' - } - }; + public mockSocialLogin(): void { + const mockData: RegistrationData = mockUser; this.dataService.updateData(mockData); @@ -91,9 +67,13 @@ export class SignUp implements OnInit { } private markFormGroupTouched(): void { - Object.keys(this.methodForm.controls).forEach(key => { - const control = this.methodForm.get(key); + Object.values(this.methodForm.controls).forEach(control => { control?.markAsTouched(); }) } + + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/app/components/step-basic/step-basic.html b/src/app/components/step-basic/step-basic.html index e2a160e..13a3fb5 100644 --- a/src/app/components/step-basic/step-basic.html +++ b/src/app/components/step-basic/step-basic.html @@ -9,6 +9,7 @@

Основные данные

id="email" placeholder="Введите ваш email" formControlName="email" + [pattern]="'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$'" [required]="true" errorMessage="Введите корректный email"> @@ -19,6 +20,7 @@

Основные данные

id="name" placeholder="Введите ваше имя" formControlName="name" + [pattern]="'^[a-zA-Zа-яА-Я0-9]+$'" [required]="true" [minLength]="2" errorMessage="Имя должно содержать минимум 2 символа без спецсимволов"> diff --git a/src/app/components/step-basic/step-basic.scss b/src/app/components/step-basic/step-basic.scss index 0ce4798..bc6a09f 100644 --- a/src/app/components/step-basic/step-basic.scss +++ b/src/app/components/step-basic/step-basic.scss @@ -69,7 +69,6 @@ form { font-size: 0.8rem; } -// Анимация появления поля телефона app-custom-input { &:last-of-type { animation: slideDown 0.3s ease; diff --git a/src/app/components/step-basic/step-basic.ts b/src/app/components/step-basic/step-basic.ts index 26e72d5..1bf9595 100644 --- a/src/app/components/step-basic/step-basic.ts +++ b/src/app/components/step-basic/step-basic.ts @@ -1,9 +1,10 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { CustomInput } from '../custom-input/custom-input'; import { CountrySelect } from '../country-select/country-select'; import { Registration } from '../../services/registration'; import { Router } from '@angular/router'; +import { debounceTime, Subject, takeUntil } from 'rxjs'; @Component({ selector: 'app-step-basic', @@ -11,9 +12,10 @@ import { Router } from '@angular/router'; templateUrl: './step-basic.html', styleUrl: './step-basic.scss', }) -export class StepBasic implements OnInit { +export class StepBasic implements OnInit, OnDestroy { public basicInfoForm: FormGroup; public showPhoneField = false; + private destroy$ = new Subject(); constructor( private fb: FormBuilder, @@ -34,24 +36,30 @@ export class StepBasic implements OnInit { } } - this.basicInfoForm.valueChanges.subscribe(value => { - this.dataService.updateData({ basicInfo: value }); + this.basicInfoForm.valueChanges + .pipe(takeUntil(this.destroy$), + debounceTime(1000)) + .subscribe(value => { + this.dataService.updateData({ basicInfo: value }); }); - this.basicInfoForm.get('country')?.valueChanges.subscribe(country => { - this.showPhoneField = !!country; + this.basicInfoForm.get('country')?.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(country => { + this.showPhoneField = !!country; - if (!country) { - this.basicInfoForm.patchValue({ phone: '' }); - } + if (!country) { + this.basicInfoForm.patchValue({ phone: '' }); + } }) } private createForm(): FormGroup { - const customEmailPattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$' + const customEmailPattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$'; + const namePattern = '^[a-zA-Zа-яА-Я0-9]+$'; return this.fb.group({ email: ['', [Validators.required, Validators.pattern(customEmailPattern)]], - name: ['', [Validators.required, Validators.minLength(2)]], + name: ['', [Validators.required, Validators.minLength(2), Validators.pattern(namePattern)]], country: ['', Validators.required], phone: [''] }); @@ -82,6 +90,11 @@ export class StepBasic implements OnInit { }); } + public ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + public get email() { return this.basicInfoForm.get('email'); } public get name() { return this.basicInfoForm.get('name'); } public get country() { return this.basicInfoForm.get('country'); } diff --git a/src/app/models/mock-data.ts b/src/app/models/mock-data.ts new file mode 100644 index 0000000..aaa1845 --- /dev/null +++ b/src/app/models/mock-data.ts @@ -0,0 +1,32 @@ +import { Country, RegistrationData } from "./registration-types"; + +export const countries: Country[] = [ + { code: 'by', name: 'Беларусь', phoneCode: '+375' }, + { code: 'ru', name: 'Россия', phoneCode: '+7' }, + { code: 'us', name: 'США', phoneCode: '+1' }, + { code: 'de', name: 'Германия', phoneCode: '+49' }, + { code: 'fr', name: 'Франция', phoneCode: '+33' }, + { code: 'it', name: 'Италия', phoneCode: '+39' }, + { code: 'es', name: 'Испания', phoneCode: '+34' }, + { code: 'jp', name: 'Япония', phoneCode: '+81' }, + { code: 'cn', name: 'Китай', phoneCode: '+86' } +]; + +export const mockUser: RegistrationData = { + method: 'social', + basicInfo: { + email: 'john@doe.com', + name: 'John Doe', + country: 'by', + phone: '(029) 111-22-33' + }, + additionalInfo: { + address: { + country: 'Беларусь', + city: 'Минск', + street: 'Жукова' + }, + birthDate: new Date('1990-01-01'), + gender: 'другой' + } +} \ No newline at end of file diff --git a/src/app/models/registration-types.ts b/src/app/models/registration-types.ts index dba7734..27cbfaf 100644 --- a/src/app/models/registration-types.ts +++ b/src/app/models/registration-types.ts @@ -2,8 +2,7 @@ export type RegistrationData = { method?: 'email' | 'social', basicInfo?: BasicInfo, additionalInfo?: AdditionalInfo, - confirmation?: Confirmation, - socialProvider?: string + confirmation?: Confirmation } export type BasicInfo = { @@ -37,4 +36,10 @@ export type Confirmation = { subscribe: boolean } -export type RegistrationStep = 'method' | 'basic' | 'additional' | 'confirmation'; \ No newline at end of file +export type RegistrationStep = 'method' | 'basic' | 'additional' | 'confirmation'; + +export type Country = { + code: string; + name: string; + phoneCode: string; +} \ No newline at end of file diff --git a/src/app/services/registration.ts b/src/app/services/registration.ts index 24e9acf..5cc720b 100644 --- a/src/app/services/registration.ts +++ b/src/app/services/registration.ts @@ -11,8 +11,6 @@ export class Registration { private data: RegistrationData = {}; private dataSubject = new BehaviorSubject(this.loadFromStorage()); - public data$ = this.dataSubject.asObservable(); - public updateData(updates: Partial): void { this.data = { ...this.data, ...updates }; this.saveToStorage(); From bec6f4b0e3eec380d6fee09ecc4210e97f9ba364 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 21 Oct 2025 15:09:07 +0300 Subject: [PATCH 07/32] feat: variables.scss + input.scss --- .../country-select/country-select.scss | 51 +------------------ .../components/custom-input/custom-input.scss | 50 +----------------- src/app/styles/input.scss | 48 +++++++++++++++++ src/app/styles/variables.scss | 3 ++ 4 files changed, 53 insertions(+), 99 deletions(-) create mode 100644 src/app/styles/input.scss create mode 100644 src/app/styles/variables.scss diff --git a/src/app/components/country-select/country-select.scss b/src/app/components/country-select/country-select.scss index 7d514e6..96334f8 100644 --- a/src/app/components/country-select/country-select.scss +++ b/src/app/components/country-select/country-select.scss @@ -1,50 +1 @@ -$error_collor: #d32f2f; -$color_1: #e0e0e0; -$color_2: #2196f3; - -.form-field { - margin-bottom: 1.5rem; - - label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: #333; - } - - select { - width: 100%; - padding: 0.75rem; - border: 2px solid $color_1; - border-radius: 6px; - font-size: 1rem; - background: white; - transition: border-color 0.2s ease; - - &:focus { - outline: none; - border-color: $color_2; - } - - &.error { - border-color: $error_collor; - } - - &:disabled { - background-color: $color_1; - cursor: not-allowed; - } - } - - &.invalid { - label { - color: $error_collor; - } - } -} - -.error-message { - color: $error_collor; - font-size: 0.875rem; - margin-top: 0.25rem; -} \ No newline at end of file +@use '../../styles/input'; \ No newline at end of file diff --git a/src/app/components/custom-input/custom-input.scss b/src/app/components/custom-input/custom-input.scss index fedf265..96334f8 100644 --- a/src/app/components/custom-input/custom-input.scss +++ b/src/app/components/custom-input/custom-input.scss @@ -1,49 +1 @@ -$error_collor: #d32f2f; -$color_1: #e0e0e0; -$color_2: #2196f3; - -.form-field { - margin-bottom: 1.5rem; - - label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: #333; - } - - input { - width: 100%; - padding: 0.75rem; - border: 2px solid $color_1; - border-radius: 6px; - font-size: 1rem; - transition: border-color 0.2s ease; - - &:focus { - outline: none; - border-color: $color_2; - } - - &.error { - border-color: $error_collor; - } - - &:disabled { - background-color: #f5f5f5; - cursor: not-allowed; - } - } - - &.invalid { - label { - color: $error_collor; - } - } -} - -.error-message { - color: $error_collor; - font-size: 0.875rem; - margin-top: 0.25rem; -} \ No newline at end of file +@use '../../styles/input'; \ No newline at end of file diff --git a/src/app/styles/input.scss b/src/app/styles/input.scss new file mode 100644 index 0000000..681171d --- /dev/null +++ b/src/app/styles/input.scss @@ -0,0 +1,48 @@ +@use 'variables' as v; + +.form-field { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; + } + + select, input { + width: 100%; + padding: 0.75rem; + border: 2px solid v.$color_1; + border-radius: 6px; + font-size: 1rem; + background: white; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: v.$color_2; + } + + &.error { + border-color: v.$error_color; + } + + &:disabled { + background-color: v.$color_1; + cursor: not-allowed; + } + } + + &.invalid { + label { + color: v.$error_color; + } + } +} + +.error-message { + color: v.$error_color; + font-size: 0.875rem; + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/src/app/styles/variables.scss b/src/app/styles/variables.scss new file mode 100644 index 0000000..61568a8 --- /dev/null +++ b/src/app/styles/variables.scss @@ -0,0 +1,3 @@ +$error_color: #d32f2f; +$color_1: #e0e0e0; +$color_2: #2196f3; \ No newline at end of file From ef04a15a97376c138cb21bb5f7e33f8f15312d9e Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 21 Oct 2025 15:41:15 +0300 Subject: [PATCH 08/32] feat: scss partials --- src/app/components/sign-up/sign-up.scss | 36 ++++++++++--------- src/app/components/step-basic/step-basic.scss | 22 ++++++------ src/app/styles/input.scss | 4 +-- src/app/styles/variables.scss | 8 ++++- 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/app/components/sign-up/sign-up.scss b/src/app/components/sign-up/sign-up.scss index 0ad0ef9..5ecb885 100644 --- a/src/app/components/sign-up/sign-up.scss +++ b/src/app/components/sign-up/sign-up.scss @@ -1,3 +1,5 @@ +@use '../../styles/variables' as v; + .step-container { max-width: 500px; margin: 0 auto; @@ -7,7 +9,7 @@ h2 { text-align: center; margin-bottom: 2rem; - color: #333; + color: v.$label_color; } .method-options { @@ -18,27 +20,27 @@ h2 { } .method-card { - border: 2px solid #e0e0e0; + border: 2px solid v.$color_1; border-radius: 12px; padding: 1.5rem; text-align: center; cursor: pointer; transition: all 0.3s ease; - background: white; + background: v.$background; h3 { margin: 0.5rem 0; - color: #333; + color: v.$label_color; } p { margin: 0; - color: #666; + color: v.$label_color_2; font-size: 0.9rem; } &:hover { - border-color: #2196f3; + border-color: v.$color_2; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(33, 150, 243, 0.1); } @@ -56,7 +58,7 @@ h2 { .social-buttons { margin: 2rem 0; padding: 1.5rem; - border: 1px solid #e0e0e0; + border: 1px solid v.$color_1; border-radius: 8px; background: #f9f9f9; @@ -78,27 +80,27 @@ h2 { align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; - border: 1px solid #ddd; + border: 1px solid v.$sec_border; border-radius: 6px; - background: white; + background: v.$background; cursor: pointer; transition: all 0.2s ease; &:hover { - background: #f5f5f5; + background: v.$sec_background; transform: translateY(-1px); } &.google:hover { - border-color: #db4437; + border-color: v.$error_color; } &.meta:hover { - border-color: #1877f2; + border-color: v.$color_2; } &.github:hover { - border-color: #333; + border-color: v.$label_color; } } @@ -112,8 +114,8 @@ h2 { } .btn-primary { - background: #2196f3; - color: white; + background: v.$color_2; + color: v.$background; border: none; padding: 0.75rem 2rem; border-radius: 6px; @@ -122,11 +124,11 @@ h2 { transition: background 0.2s ease; &:hover:not(:disabled) { - background: #1976d2; + background: v.$color_2; } &:disabled { - background: #ccc; + background: v.$disabled_color; cursor: not-allowed; } } diff --git a/src/app/components/step-basic/step-basic.scss b/src/app/components/step-basic/step-basic.scss index bc6a09f..b312308 100644 --- a/src/app/components/step-basic/step-basic.scss +++ b/src/app/components/step-basic/step-basic.scss @@ -1,3 +1,5 @@ +@use '../../styles/variables' as v; + .step-container { max-width: 500px; margin: 0 auto; @@ -7,7 +9,7 @@ h2 { text-align: center; margin-bottom: 2rem; - color: #333; + color: v.$label_color; } form { @@ -24,8 +26,8 @@ form { } .btn-primary { - background: #2196f3; - color: white; + background: v.$color_2; + color: v.$background; border: none; padding: 0.75rem 2rem; border-radius: 6px; @@ -35,19 +37,19 @@ form { flex: 1; &:hover:not(:disabled) { - background: #1976d2; + background: v.$color_2; } &:disabled { - background: #ccc; + background: v.$disabled_color; cursor: not-allowed; } } .btn-secondary { - background: #f5f5f5; - color: #333; - border: 1px solid #ddd; + background: v.$sec_background; + color: v.$label_color; + border: 1px solid v.$sec_border; padding: 0.75rem 2rem; border-radius: 6px; font-size: 1rem; @@ -56,14 +58,14 @@ form { flex: 1; &:hover { - background: #e0e0e0; + background: v.$color_1; } } .form-debug { margin-top: 2rem; padding: 1rem; - background: #f5f5f5; + background: v.$sec_background; border-radius: 4px; font-family: monospace; font-size: 0.8rem; diff --git a/src/app/styles/input.scss b/src/app/styles/input.scss index 681171d..9019912 100644 --- a/src/app/styles/input.scss +++ b/src/app/styles/input.scss @@ -7,7 +7,7 @@ display: block; margin-bottom: 0.5rem; font-weight: 500; - color: #333; + color: v.$label_color; } select, input { @@ -16,7 +16,7 @@ border: 2px solid v.$color_1; border-radius: 6px; font-size: 1rem; - background: white; + background: v.$background; transition: border-color 0.2s ease; &:focus { diff --git a/src/app/styles/variables.scss b/src/app/styles/variables.scss index 61568a8..1c9d020 100644 --- a/src/app/styles/variables.scss +++ b/src/app/styles/variables.scss @@ -1,3 +1,9 @@ $error_color: #d32f2f; $color_1: #e0e0e0; -$color_2: #2196f3; \ No newline at end of file +$color_2: #2196f3; +$label_color: #333; +$label_color_2: #666; +$background: white; +$disabled_color: #ccc; +$sec_background: #f5f5f5; +$sec_border: #ddd; \ No newline at end of file From e213dde18e83983f0345ac29ee182043d2b3b290 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 21 Oct 2025 16:14:42 +0300 Subject: [PATCH 09/32] feat: some comments --- src/app/app.routes.ts | 2 +- .../components/country-select/country-select.ts | 2 ++ src/app/components/custom-input/custom-input.ts | 6 ++++++ src/app/components/sign-up/sign-up.ts | 13 ++++++++++--- src/app/components/step-basic/step-basic.ts | 9 ++++++++- src/app/models/mock-data.ts | 2 ++ src/app/models/registration-types.ts | 4 ++++ src/app/services/registration.ts | 15 +++++---------- 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3b80859..3f93392 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,7 +4,7 @@ import { StepBasic } from './components/step-basic/step-basic'; export const routes: Routes = [ { path: '', redirectTo:'/signup', pathMatch: 'full'}, - { + { //method -> basic -> additional -> rules path: 'signup', children: [ { diff --git a/src/app/components/country-select/country-select.ts b/src/app/components/country-select/country-select.ts index 323fb10..0aa6c40 100644 --- a/src/app/components/country-select/country-select.ts +++ b/src/app/components/country-select/country-select.ts @@ -3,6 +3,7 @@ import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR import { countries } from '../../models/mock-data'; import { Country } from '../../models/registration-types'; +// Отдельный компонент для выбора страны @Component({ selector: 'app-country-select', imports: [ReactiveFormsModule], @@ -42,6 +43,7 @@ export class CountrySelect implements ControlValueAccessor, Validator { const value = (event.target as HTMLSelectElement).value; this.value = value; this.onChange(value); + // Прокидываем строку в родителя (в step-basic) для последующего отображения input'a для телефона this.countryChange.emit(value); this.validateControl(); } diff --git a/src/app/components/custom-input/custom-input.ts b/src/app/components/custom-input/custom-input.ts index 63fa517..61f4299 100644 --- a/src/app/components/custom-input/custom-input.ts +++ b/src/app/components/custom-input/custom-input.ts @@ -3,6 +3,7 @@ import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR import { input } from '@angular/core'; import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; +// Отдельный компонент для кастомных форм @Component({ selector: 'app-custom-input', imports: [ReactiveFormsModule, NgxMaskDirective], @@ -63,14 +64,17 @@ export class CustomInput implements ControlValueAccessor, Validator { this.disabled = isDisabled; } + // Валидация public validate(control: AbstractControl): ValidationErrors | null { const value = control.value; + // Проверка для required if (this.required() && !value) { this.isInvalid = true; return { required: true }; } + // Проверка для minLength if (value && this.minLength() && value.length < this.minLength()) { this.isInvalid = true; return { @@ -81,11 +85,13 @@ export class CustomInput implements ControlValueAccessor, Validator { }; } + // Проверка регексов if (value && this.pattern() && !new RegExp(this.pattern()).test(value)) { this.isInvalid = true; return { pattern: true }; } + // Если нигде не было ошибок, то this.inValid -> false this.isInvalid = false; return null; } diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/components/sign-up/sign-up.ts index 7ba0220..aefe476 100644 --- a/src/app/components/sign-up/sign-up.ts +++ b/src/app/components/sign-up/sign-up.ts @@ -27,14 +27,17 @@ export class SignUp implements OnInit, OnDestroy { public ngOnInit(): void { this.dataService.clearData(); + // Отслеживаем изменения формы this.methodForm.valueChanges - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntil(this.destroy$)) // не придумал как сделать с '| async' .subscribe(value => { this.dataService.updateData(value); }) } private createForm(): FormGroup { + // Форма с единственным полем, выбора метода (через почту или сторонние сервисы) + // Возможно излишне, мб поменять попроще return this.fb.group({ method: ['', Validators.required] }); @@ -47,6 +50,7 @@ export class SignUp implements OnInit, OnDestroy { } public onSubmit(): void { + // Переходим к шагу basic если валидна (случаев для невалидности по сути нет, т.к. кнопки нет, но пусть пока будет) if (this.methodForm.valid) { this.dataService.updateData(this.methodForm.value); @@ -59,6 +63,7 @@ export class SignUp implements OnInit, OnDestroy { } public mockSocialLogin(): void { + // Переходим сразу к additional, если через соц сети, мб переделать чтоб тоже по сабмиту const mockData: RegistrationData = mockUser; this.dataService.updateData(mockData); @@ -67,9 +72,11 @@ export class SignUp implements OnInit, OnDestroy { } private markFormGroupTouched(): void { - Object.values(this.methodForm.controls).forEach(control => { + // Помечаем форму, типа взаимодействовали + Object.keys(this.methodForm.controls).forEach(key => { + const control = this.methodForm.get(key); control?.markAsTouched(); - }) + }); } public ngOnDestroy(): void { diff --git a/src/app/components/step-basic/step-basic.ts b/src/app/components/step-basic/step-basic.ts index 1bf9595..8f377e0 100644 --- a/src/app/components/step-basic/step-basic.ts +++ b/src/app/components/step-basic/step-basic.ts @@ -26,6 +26,7 @@ export class StepBasic implements OnInit, OnDestroy { } public ngOnInit(): void { + // Подгружаем значения с ls const currentData = this.dataService.getCurrentData(); if (currentData.basicInfo) { @@ -36,6 +37,7 @@ export class StepBasic implements OnInit, OnDestroy { } } + // Отслеживаем изменения формы, дебаунс для того чтобы не отслеживать постоянно, а только когда завершили ввод чего-то this.basicInfoForm.valueChanges .pipe(takeUntil(this.destroy$), debounceTime(1000)) @@ -43,6 +45,7 @@ export class StepBasic implements OnInit, OnDestroy { this.dataService.updateData({ basicInfo: value }); }); + // Отслеживаем изменения country, чтоб отображать ввод телефона this.basicInfoForm.get('country')?.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe(country => { @@ -55,6 +58,7 @@ export class StepBasic implements OnInit, OnDestroy { } private createForm(): FormGroup { + // валидация email, name через регехи и прочая валидация const customEmailPattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$'; const namePattern = '^[a-zA-Zа-яА-Я0-9]+$'; return this.fb.group({ @@ -66,11 +70,12 @@ export class StepBasic implements OnInit, OnDestroy { } public onCountryChange(countryCode: string): void { + // по смене страны что делать с отображением номера -> this.showPhoneField = !!countryCode; - console.log('Selected country:', countryCode); } public onSubmit(): void { + // переход к additional if (this.basicInfoForm.valid) { this.dataService.updateData({ basicInfo: this.basicInfoForm.value }); this.router.navigate(['/signup', 'additional']); @@ -79,6 +84,7 @@ export class StepBasic implements OnInit, OnDestroy { } } + // возвращаемся к началу public goBack(): void { this.router.navigate(['/signup', 'method']); } @@ -95,6 +101,7 @@ export class StepBasic implements OnInit, OnDestroy { this.destroy$.complete(); } + // геттеры возможно пригодятся public get email() { return this.basicInfoForm.get('email'); } public get name() { return this.basicInfoForm.get('name'); } public get country() { return this.basicInfoForm.get('country'); } diff --git a/src/app/models/mock-data.ts b/src/app/models/mock-data.ts index aaa1845..3b4173d 100644 --- a/src/app/models/mock-data.ts +++ b/src/app/models/mock-data.ts @@ -1,5 +1,6 @@ import { Country, RegistrationData } from "./registration-types"; +// список стран для выбора export const countries: Country[] = [ { code: 'by', name: 'Беларусь', phoneCode: '+375' }, { code: 'ru', name: 'Россия', phoneCode: '+7' }, @@ -12,6 +13,7 @@ export const countries: Country[] = [ { code: 'cn', name: 'Китай', phoneCode: '+86' } ]; +// моковый юсер export const mockUser: RegistrationData = { method: 'social', basicInfo: { diff --git a/src/app/models/registration-types.ts b/src/app/models/registration-types.ts index 27cbfaf..bd6ff5d 100644 --- a/src/app/models/registration-types.ts +++ b/src/app/models/registration-types.ts @@ -1,3 +1,4 @@ +// Данные общие export type RegistrationData = { method?: 'email' | 'social', basicInfo?: BasicInfo, @@ -5,6 +6,7 @@ export type RegistrationData = { confirmation?: Confirmation } +// Первый-второй шаг export type BasicInfo = { email: string, name: string, @@ -12,6 +14,7 @@ export type BasicInfo = { phone?: string } +// Третий шаг export type AdditionalInfo = { address: Address, birthDate: Date, @@ -30,6 +33,7 @@ export type ParentInfo = { email: string } +// Четвертый шаг export type Confirmation = { acceptTerms: boolean, acceptPrivacy: boolean, diff --git a/src/app/services/registration.ts b/src/app/services/registration.ts index 5cc720b..6909e58 100644 --- a/src/app/services/registration.ts +++ b/src/app/services/registration.ts @@ -11,6 +11,7 @@ export class Registration { private data: RegistrationData = {}; private dataSubject = new BehaviorSubject(this.loadFromStorage()); + // Обновление данных в ls public updateData(updates: Partial): void { this.data = { ...this.data, ...updates }; this.saveToStorage(); @@ -18,29 +19,23 @@ export class Registration { console.log(this.data) } + // Получение текущих данных public getCurrentData(): RegistrationData { return { ...this.data }; } + // Сохранить в ls private saveToStorage(): void { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.data)); } - - public getFormData(): Partial { - return this.data; - } - public resetFormData(): void { - this.data = {}; - this.saveToStorage(); - this.dataSubject.next(this.data); - } - + // Подгруаем с ls private loadFromStorage(): RegistrationData { const stored = localStorage.getItem(this.STORAGE_KEY); return stored ? JSON.parse(stored) : {}; } + // чистим public clearData(): void { localStorage.removeItem(this.STORAGE_KEY); this.data = {}; From c972b3a78c09db6f49af2a9c56c2ddc76798f726 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 27 Oct 2025 13:36:49 +0300 Subject: [PATCH 10/32] fix: while none data - no error + remove custom input and select --- .../country-select/country-select.html | 23 ---- .../country-select/country-select.scss | 1 - .../country-select/country-select.spec.ts | 23 ---- .../country-select/country-select.ts | 86 -------------- .../components/custom-input/custom-input.html | 33 ------ .../components/custom-input/custom-input.scss | 1 - .../custom-input/custom-input.spec.ts | 23 ---- .../components/custom-input/custom-input.ts | 106 ------------------ src/app/components/step-basic/step-basic.html | 96 +++++++++------- src/app/components/step-basic/step-basic.scss | 2 + src/app/components/step-basic/step-basic.ts | 12 +- src/app/models/mock-data.ts | 30 ++++- src/app/models/registration-types.ts | 7 ++ 13 files changed, 100 insertions(+), 343 deletions(-) delete mode 100644 src/app/components/country-select/country-select.html delete mode 100644 src/app/components/country-select/country-select.scss delete mode 100644 src/app/components/country-select/country-select.spec.ts delete mode 100644 src/app/components/country-select/country-select.ts delete mode 100644 src/app/components/custom-input/custom-input.html delete mode 100644 src/app/components/custom-input/custom-input.scss delete mode 100644 src/app/components/custom-input/custom-input.spec.ts delete mode 100644 src/app/components/custom-input/custom-input.ts diff --git a/src/app/components/country-select/country-select.html b/src/app/components/country-select/country-select.html deleted file mode 100644 index a45556f..0000000 --- a/src/app/components/country-select/country-select.html +++ /dev/null @@ -1,23 +0,0 @@ -
- - - @if (isInvalid && errorMessage()) { -
- {{ errorMessage() }} -
- } -
\ No newline at end of file diff --git a/src/app/components/country-select/country-select.scss b/src/app/components/country-select/country-select.scss deleted file mode 100644 index 96334f8..0000000 --- a/src/app/components/country-select/country-select.scss +++ /dev/null @@ -1 +0,0 @@ -@use '../../styles/input'; \ No newline at end of file diff --git a/src/app/components/country-select/country-select.spec.ts b/src/app/components/country-select/country-select.spec.ts deleted file mode 100644 index bfb5442..0000000 --- a/src/app/components/country-select/country-select.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { CountrySelect } from './country-select'; - -describe('CountrySelect', () => { - let component: CountrySelect; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CountrySelect] - }) - .compileComponents(); - - fixture = TestBed.createComponent(CountrySelect); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/country-select/country-select.ts b/src/app/components/country-select/country-select.ts deleted file mode 100644 index 0aa6c40..0000000 --- a/src/app/components/country-select/country-select.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Component, forwardRef, input, output } from '@angular/core'; -import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; -import { countries } from '../../models/mock-data'; -import { Country } from '../../models/registration-types'; - -// Отдельный компонент для выбора страны -@Component({ - selector: 'app-country-select', - imports: [ReactiveFormsModule], - templateUrl: './country-select.html', - styleUrl: './country-select.scss', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CountrySelect), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CountrySelect), - multi: true - } - ] -}) -export class CountrySelect implements ControlValueAccessor, Validator { - public label = input('Страна'); - public id = input('country'); - public errorMessage = input('Выберите страну'); - public required = input(false); - public countryChange = output(); - - public value = ''; - public disabled = false; - public isInvalid = false; - - public countries: Country[] = countries; - - public onChange = (value: string) => {}; - public onTouched = () => {}; - public onValidatorChange = () => {}; - - public onChangeEvent(event: Event): void { - const value = (event.target as HTMLSelectElement).value; - this.value = value; - this.onChange(value); - // Прокидываем строку в родителя (в step-basic) для последующего отображения input'a для телефона - this.countryChange.emit(value); - this.validateControl(); - } - - public writeValue(value: string): void { - this.value = value || ''; - } - - public registerOnChange(fn: any): void { - this.onChange = fn; - } - - public registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - public setDisabledState(isDisabled: boolean): void { - this.disabled = isDisabled; - } - - public validate(control: AbstractControl): ValidationErrors | null { - const value = control.value; - - if (this.required() && !value) { - this.isInvalid = true; - return { required: true }; - } - - this.isInvalid = false; - return null; - } - - registerOnValidatorChange(fn: () => void): void { - this.onValidatorChange = fn; - } - - private validateControl(): void { - this.onValidatorChange(); - } -} diff --git a/src/app/components/custom-input/custom-input.html b/src/app/components/custom-input/custom-input.html deleted file mode 100644 index d518d46..0000000 --- a/src/app/components/custom-input/custom-input.html +++ /dev/null @@ -1,33 +0,0 @@ -
- - @if (type() === 'tel') { - - } - @else { - - } - @if (isInvalid && errorMessage()) { -
- {{ errorMessage() }} -
- } -
\ No newline at end of file diff --git a/src/app/components/custom-input/custom-input.scss b/src/app/components/custom-input/custom-input.scss deleted file mode 100644 index 96334f8..0000000 --- a/src/app/components/custom-input/custom-input.scss +++ /dev/null @@ -1 +0,0 @@ -@use '../../styles/input'; \ No newline at end of file diff --git a/src/app/components/custom-input/custom-input.spec.ts b/src/app/components/custom-input/custom-input.spec.ts deleted file mode 100644 index ebd5b38..0000000 --- a/src/app/components/custom-input/custom-input.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { CustomInput } from './custom-input'; - -describe('CustomInput', () => { - let component: CustomInput; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CustomInput] - }) - .compileComponents(); - - fixture = TestBed.createComponent(CustomInput); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/components/custom-input/custom-input.ts b/src/app/components/custom-input/custom-input.ts deleted file mode 100644 index 61f4299..0000000 --- a/src/app/components/custom-input/custom-input.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Component, forwardRef } from '@angular/core'; -import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator } from '@angular/forms'; -import { input } from '@angular/core'; -import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; - -// Отдельный компонент для кастомных форм -@Component({ - selector: 'app-custom-input', - imports: [ReactiveFormsModule, NgxMaskDirective], - templateUrl: './custom-input.html', - styleUrl: './custom-input.scss', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CustomInput), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CustomInput), - multi: true - }, - provideNgxMask() - ] -}) -export class CustomInput implements ControlValueAccessor, Validator { - public label = input(''); - public type = input('text'); - public id = input(''); - public placeholder = input(''); - public errorMessage = input(''); - public required = input(false); - public minLength = input(); - public pattern = input(); - - public value = ''; - public disabled = false; - public isInvalid = false; - - private onChange = (value: string) => {}; - public onTouched = () => {}; - private onValidatorChange = () => {}; - - public onInput(event: Event): void { - const value = (event.target as HTMLInputElement).value; - this.value = value; - this.onChange(value); - this.validateControl(); - } - - public writeValue(value: string): void { - this.value = value || ''; - } - - public registerOnChange(fn: any): void { - this.onChange = fn; - } - - public registerOnTouched(fn: any): void { - this.onTouched = fn; - } - - public setDisabledState(isDisabled: boolean): void { - this.disabled = isDisabled; - } - - // Валидация - public validate(control: AbstractControl): ValidationErrors | null { - const value = control.value; - - // Проверка для required - if (this.required() && !value) { - this.isInvalid = true; - return { required: true }; - } - - // Проверка для minLength - if (value && this.minLength() && value.length < this.minLength()) { - this.isInvalid = true; - return { - minLength: { - requiredLength: this.minLength, - actualLength: value.length - } - }; - } - - // Проверка регексов - if (value && this.pattern() && !new RegExp(this.pattern()).test(value)) { - this.isInvalid = true; - return { pattern: true }; - } - - // Если нигде не было ошибок, то this.inValid -> false - this.isInvalid = false; - return null; - } - - public registerOnValidatorChange(fn: () => void): void { - this.onValidatorChange = fn; - } - - private validateControl(): void { - this.onValidatorChange(); - } -} diff --git a/src/app/components/step-basic/step-basic.html b/src/app/components/step-basic/step-basic.html index 13a3fb5..afed28d 100644 --- a/src/app/components/step-basic/step-basic.html +++ b/src/app/components/step-basic/step-basic.html @@ -1,51 +1,61 @@

Основные данные

-
- - - - - - - - - - @if (showPhoneField) { - - + @for (field of fields; track field) { + @if (field.type !== 'tel' || (field.type === 'tel' && showPhoneField)) { +
+ + @if (field.type === 'select') { + + } + @else { + + } + + @if (basicInfoForm.get(field.name)?.invalid && + (basicInfoForm.get(field.name)?.dirty || + basicInfoForm.get(field.name)?.touched)) { +
+ {{ field.errorMes }} +
+ } +
+ } } - - +
\ No newline at end of file diff --git a/src/app/components/step-basic/step-basic.scss b/src/app/components/step-basic/step-basic.scss index 8c7e1dc..e6754bc 100644 --- a/src/app/components/step-basic/step-basic.scss +++ b/src/app/components/step-basic/step-basic.scss @@ -1,5 +1,4 @@ @use '../../styles/variables' as v; -@use '../../styles/input'; .step-container { diff --git a/src/app/components/step-basic/step-basic.ts b/src/app/components/step-basic/step-basic.ts index 5bbdf37..9280178 100644 --- a/src/app/components/step-basic/step-basic.ts +++ b/src/app/components/step-basic/step-basic.ts @@ -6,10 +6,11 @@ import { debounceTime, Subject, takeUntil } from 'rxjs'; import { stepBasicFields, countries } from '../../models/mock-data'; import { LowerCasePipe } from '@angular/common'; import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; +import { CustomInput } from '../custom-input/custom-input'; @Component({ selector: 'app-step-basic', - imports: [ReactiveFormsModule, LowerCasePipe, NgxMaskDirective], + imports: [ReactiveFormsModule, LowerCasePipe, NgxMaskDirective, CustomInput], templateUrl: './step-basic.html', styleUrl: './step-basic.scss', providers: [ From 243c40e57419c39efc447a5bd124f41aca3c0d3b Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 28 Oct 2025 14:53:35 +0300 Subject: [PATCH 12/32] feat: structure fix --- src/app/app.routes.ts | 4 ++-- src/app/{components => features}/sign-up/sign-up.html | 0 src/app/{components => features}/sign-up/sign-up.scss | 0 src/app/{components => features}/sign-up/sign-up.spec.ts | 0 src/app/{components => features}/sign-up/sign-up.ts | 4 ++-- src/app/{components => features}/step-basic/step-basic.html | 0 src/app/{components => features}/step-basic/step-basic.scss | 0 .../{components => features}/step-basic/step-basic.spec.ts | 0 src/app/{components => features}/step-basic/step-basic.ts | 2 +- .../{ => shared}/components/custom-input/custom-input.html | 0 .../{ => shared}/components/custom-input/custom-input.scss | 0 .../{ => shared}/components/custom-input/custom-input.spec.ts | 0 src/app/{ => shared}/components/custom-input/custom-input.ts | 0 src/app/{ => shared}/models/mock-data.ts | 0 src/app/{ => shared}/models/registration-types.ts | 0 src/app/{ => shared}/services/registration.spec.ts | 0 src/app/{ => shared}/services/registration.ts | 2 +- src/app/{ => shared}/styles/input.scss | 0 src/app/{ => shared}/styles/variables.scss | 0 19 files changed, 6 insertions(+), 6 deletions(-) rename src/app/{components => features}/sign-up/sign-up.html (100%) rename src/app/{components => features}/sign-up/sign-up.scss (100%) rename src/app/{components => features}/sign-up/sign-up.spec.ts (100%) rename src/app/{components => features}/sign-up/sign-up.ts (95%) rename src/app/{components => features}/step-basic/step-basic.html (100%) rename src/app/{components => features}/step-basic/step-basic.scss (100%) rename src/app/{components => features}/step-basic/step-basic.spec.ts (100%) rename src/app/{components => features}/step-basic/step-basic.ts (98%) rename src/app/{ => shared}/components/custom-input/custom-input.html (100%) rename src/app/{ => shared}/components/custom-input/custom-input.scss (100%) rename src/app/{ => shared}/components/custom-input/custom-input.spec.ts (100%) rename src/app/{ => shared}/components/custom-input/custom-input.ts (100%) rename src/app/{ => shared}/models/mock-data.ts (100%) rename src/app/{ => shared}/models/registration-types.ts (100%) rename src/app/{ => shared}/services/registration.spec.ts (100%) rename src/app/{ => shared}/services/registration.ts (94%) rename src/app/{ => shared}/styles/input.scss (100%) rename src/app/{ => shared}/styles/variables.scss (100%) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3f93392..b46b988 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,6 @@ import { Routes } from '@angular/router'; -import { SignUp } from './components/sign-up/sign-up'; -import { StepBasic } from './components/step-basic/step-basic'; +import { SignUp } from './features/sign-up/sign-up'; +import { StepBasic } from './features/step-basic/step-basic'; export const routes: Routes = [ { path: '', redirectTo:'/signup', pathMatch: 'full'}, diff --git a/src/app/components/sign-up/sign-up.html b/src/app/features/sign-up/sign-up.html similarity index 100% rename from src/app/components/sign-up/sign-up.html rename to src/app/features/sign-up/sign-up.html diff --git a/src/app/components/sign-up/sign-up.scss b/src/app/features/sign-up/sign-up.scss similarity index 100% rename from src/app/components/sign-up/sign-up.scss rename to src/app/features/sign-up/sign-up.scss diff --git a/src/app/components/sign-up/sign-up.spec.ts b/src/app/features/sign-up/sign-up.spec.ts similarity index 100% rename from src/app/components/sign-up/sign-up.spec.ts rename to src/app/features/sign-up/sign-up.spec.ts diff --git a/src/app/components/sign-up/sign-up.ts b/src/app/features/sign-up/sign-up.ts similarity index 95% rename from src/app/components/sign-up/sign-up.ts rename to src/app/features/sign-up/sign-up.ts index aefe476..9026499 100644 --- a/src/app/components/sign-up/sign-up.ts +++ b/src/app/features/sign-up/sign-up.ts @@ -1,9 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Registration } from '../../services/registration'; -import { RegistrationData } from '../../models/registration-types'; +import { RegistrationData } from '../../shared/models/registration-types'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { mockUser } from '../../models/mock-data'; +import { mockUser } from '../../shared/models/mock-data'; import { Subject, takeUntil } from 'rxjs'; @Component({ diff --git a/src/app/components/step-basic/step-basic.html b/src/app/features/step-basic/step-basic.html similarity index 100% rename from src/app/components/step-basic/step-basic.html rename to src/app/features/step-basic/step-basic.html diff --git a/src/app/components/step-basic/step-basic.scss b/src/app/features/step-basic/step-basic.scss similarity index 100% rename from src/app/components/step-basic/step-basic.scss rename to src/app/features/step-basic/step-basic.scss diff --git a/src/app/components/step-basic/step-basic.spec.ts b/src/app/features/step-basic/step-basic.spec.ts similarity index 100% rename from src/app/components/step-basic/step-basic.spec.ts rename to src/app/features/step-basic/step-basic.spec.ts diff --git a/src/app/components/step-basic/step-basic.ts b/src/app/features/step-basic/step-basic.ts similarity index 98% rename from src/app/components/step-basic/step-basic.ts rename to src/app/features/step-basic/step-basic.ts index 9280178..bda61db 100644 --- a/src/app/components/step-basic/step-basic.ts +++ b/src/app/features/step-basic/step-basic.ts @@ -3,7 +3,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula import { Registration } from '../../services/registration'; import { Router } from '@angular/router'; import { debounceTime, Subject, takeUntil } from 'rxjs'; -import { stepBasicFields, countries } from '../../models/mock-data'; +import { stepBasicFields, countries } from '../../shared/models/mock-data'; import { LowerCasePipe } from '@angular/common'; import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; import { CustomInput } from '../custom-input/custom-input'; diff --git a/src/app/components/custom-input/custom-input.html b/src/app/shared/components/custom-input/custom-input.html similarity index 100% rename from src/app/components/custom-input/custom-input.html rename to src/app/shared/components/custom-input/custom-input.html diff --git a/src/app/components/custom-input/custom-input.scss b/src/app/shared/components/custom-input/custom-input.scss similarity index 100% rename from src/app/components/custom-input/custom-input.scss rename to src/app/shared/components/custom-input/custom-input.scss diff --git a/src/app/components/custom-input/custom-input.spec.ts b/src/app/shared/components/custom-input/custom-input.spec.ts similarity index 100% rename from src/app/components/custom-input/custom-input.spec.ts rename to src/app/shared/components/custom-input/custom-input.spec.ts diff --git a/src/app/components/custom-input/custom-input.ts b/src/app/shared/components/custom-input/custom-input.ts similarity index 100% rename from src/app/components/custom-input/custom-input.ts rename to src/app/shared/components/custom-input/custom-input.ts diff --git a/src/app/models/mock-data.ts b/src/app/shared/models/mock-data.ts similarity index 100% rename from src/app/models/mock-data.ts rename to src/app/shared/models/mock-data.ts diff --git a/src/app/models/registration-types.ts b/src/app/shared/models/registration-types.ts similarity index 100% rename from src/app/models/registration-types.ts rename to src/app/shared/models/registration-types.ts diff --git a/src/app/services/registration.spec.ts b/src/app/shared/services/registration.spec.ts similarity index 100% rename from src/app/services/registration.spec.ts rename to src/app/shared/services/registration.spec.ts diff --git a/src/app/services/registration.ts b/src/app/shared/services/registration.ts similarity index 94% rename from src/app/services/registration.ts rename to src/app/shared/services/registration.ts index 6909e58..1df2619 100644 --- a/src/app/services/registration.ts +++ b/src/app/shared/services/registration.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RegistrationData } from '../models/registration-types'; +import { RegistrationData } from '../shared/models/registration-types'; import { BehaviorSubject } from 'rxjs'; @Injectable({ diff --git a/src/app/styles/input.scss b/src/app/shared/styles/input.scss similarity index 100% rename from src/app/styles/input.scss rename to src/app/shared/styles/input.scss diff --git a/src/app/styles/variables.scss b/src/app/shared/styles/variables.scss similarity index 100% rename from src/app/styles/variables.scss rename to src/app/shared/styles/variables.scss From 008ad57a3df39e73c7ea7500c7cb8b8fb38ca855 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 28 Oct 2025 14:59:59 +0300 Subject: [PATCH 13/32] feat: imports fix --- src/app/features/sign-up/sign-up.scss | 2 +- src/app/features/sign-up/sign-up.ts | 2 +- src/app/features/step-basic/step-basic.scss | 2 +- src/app/features/step-basic/step-basic.ts | 4 ++-- src/app/shared/services/registration.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/sign-up/sign-up.scss b/src/app/features/sign-up/sign-up.scss index 5ecb885..53c1fe4 100644 --- a/src/app/features/sign-up/sign-up.scss +++ b/src/app/features/sign-up/sign-up.scss @@ -1,4 +1,4 @@ -@use '../../styles/variables' as v; +@use '../../shared/styles/variables' as v; .step-container { max-width: 500px; diff --git a/src/app/features/sign-up/sign-up.ts b/src/app/features/sign-up/sign-up.ts index 9026499..cf5425d 100644 --- a/src/app/features/sign-up/sign-up.ts +++ b/src/app/features/sign-up/sign-up.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -import { Registration } from '../../services/registration'; +import { Registration } from '../../shared/services/registration'; import { RegistrationData } from '../../shared/models/registration-types'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { mockUser } from '../../shared/models/mock-data'; diff --git a/src/app/features/step-basic/step-basic.scss b/src/app/features/step-basic/step-basic.scss index e6754bc..06ad34d 100644 --- a/src/app/features/step-basic/step-basic.scss +++ b/src/app/features/step-basic/step-basic.scss @@ -1,4 +1,4 @@ -@use '../../styles/variables' as v; +@use '../../shared/styles/variables' as v; .step-container { diff --git a/src/app/features/step-basic/step-basic.ts b/src/app/features/step-basic/step-basic.ts index bda61db..d129a12 100644 --- a/src/app/features/step-basic/step-basic.ts +++ b/src/app/features/step-basic/step-basic.ts @@ -1,12 +1,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Registration } from '../../services/registration'; +import { Registration } from '../../shared/services/registration'; import { Router } from '@angular/router'; import { debounceTime, Subject, takeUntil } from 'rxjs'; import { stepBasicFields, countries } from '../../shared/models/mock-data'; import { LowerCasePipe } from '@angular/common'; import { NgxMaskDirective, provideNgxMask } from 'ngx-mask'; -import { CustomInput } from '../custom-input/custom-input'; +import { CustomInput } from '../../shared/components/custom-input/custom-input'; @Component({ selector: 'app-step-basic', diff --git a/src/app/shared/services/registration.ts b/src/app/shared/services/registration.ts index 1df2619..6909e58 100644 --- a/src/app/shared/services/registration.ts +++ b/src/app/shared/services/registration.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RegistrationData } from '../shared/models/registration-types'; +import { RegistrationData } from '../models/registration-types'; import { BehaviorSubject } from 'rxjs'; @Injectable({ From 3b1d34970cd360c44db7d24d4de335c6c60d9219 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 28 Oct 2025 19:00:53 +0300 Subject: [PATCH 14/32] feat: errors list --- src/app/features/step-basic/step-basic.html | 64 ++++++++++++++----- src/app/features/step-basic/step-basic.ts | 35 +++++++++- .../components/custom-input/custom-input.html | 2 +- src/app/shared/models/errors.ts | 5 ++ src/app/shared/models/mock-data.ts | 30 +-------- src/app/shared/models/registration-types.ts | 7 -- 6 files changed, 87 insertions(+), 56 deletions(-) create mode 100644 src/app/shared/models/errors.ts diff --git a/src/app/features/step-basic/step-basic.html b/src/app/features/step-basic/step-basic.html index 69092cb..272575f 100644 --- a/src/app/features/step-basic/step-basic.html +++ b/src/app/features/step-basic/step-basic.html @@ -1,24 +1,54 @@

Основные данные

- @for (field of fields; track field) { - @if (field.type !== 'tel' || (field.type==='tel' && showPhoneField)) { - - } + + + + + + + @if (showPhoneField) { + } + -
- } - @if (methodForm.get("method")?.value === "email") { - - } - +

Выберите способ регистрации

+
+
+
+
📧
+

Email

+

Регистрация с помощью email

+
+
+
🌐
+

Социальные сети

+

Быстрая регистрация через социальные сети

+
+
+ + @if (methodForm.get('method')?.value === 'social') { + + } @if (methodForm.get('method')?.value === 'email') { + + } +
diff --git a/src/app/features/sign-up/sign-up.ts b/src/app/features/sign-up/sign-up.ts index cf5425d..7dd7a67 100644 --- a/src/app/features/sign-up/sign-up.ts +++ b/src/app/features/sign-up/sign-up.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Registration } from '../../shared/services/registration'; import { RegistrationData } from '../../shared/models/registration-types'; @@ -16,12 +16,11 @@ export class SignUp implements OnInit, OnDestroy { public methodForm: FormGroup; public selectedMethod: 'email' | 'social' | null = null; private destroy$ = new Subject(); + private fb = inject(FormBuilder); + private dataService = inject(Registration); + private router = inject(Router); - constructor( - private fb: FormBuilder, - private dataService: Registration, - private router: Router - ) { + constructor() { this.methodForm = this.createForm(); } diff --git a/src/app/features/step-basic/step-basic.html b/src/app/features/step-basic/step-basic.html index 272575f..1041341 100644 --- a/src/app/features/step-basic/step-basic.html +++ b/src/app/features/step-basic/step-basic.html @@ -1,68 +1,63 @@
-

Основные данные

-
- +

Основные данные

+ + - + - + - @if (showPhoneField) { - - } - - -
-
\ No newline at end of file + @if (showPhoneField) { + + } + + + +
diff --git a/src/app/features/step-basic/step-basic.ts b/src/app/features/step-basic/step-basic.ts index 10a6d23..9fe2e25 100644 --- a/src/app/features/step-basic/step-basic.ts +++ b/src/app/features/step-basic/step-basic.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Registration } from '../../shared/services/registration'; import { Router } from '@angular/router'; @@ -24,12 +24,11 @@ export class StepBasic implements OnInit, OnDestroy { public countries = countries; private destroy$ = new Subject(); private errors = formErrors; + private fb = inject(FormBuilder); + private dataService = inject(Registration); + private router = inject(Router); - constructor( - private fb: FormBuilder, - private dataService: Registration, - private router: Router - ) { + constructor() { this.basicInfoForm = this.createForm(); } diff --git a/src/app/shared/components/custom-input/custom-input.html b/src/app/shared/components/custom-input/custom-input.html index 40fe0f8..63bdc24 100644 --- a/src/app/shared/components/custom-input/custom-input.html +++ b/src/app/shared/components/custom-input/custom-input.html @@ -1,31 +1,26 @@
- - @if (type() === 'select') { - - } - @else { - - } - - @if (invalid()) { -
- {{ errorMes() }} -
- } -
\ No newline at end of file + + @if (type() === 'select') { + + } @else { + + } + + @if (invalid()) { +
+ {{ errorMes() }} +
+ } + diff --git a/src/app/shared/components/custom-input/custom-input.ts b/src/app/shared/components/custom-input/custom-input.ts index 26adf4b..5f33670 100644 --- a/src/app/shared/components/custom-input/custom-input.ts +++ b/src/app/shared/components/custom-input/custom-input.ts @@ -30,25 +30,25 @@ export class CustomInput implements ControlValueAccessor { public countries = countries; - public onChange: any = () => {}; - public onTouched: any = () => {}; + public onChange: ((value: string) => void) | null = null; + public onTouched: (() => void) | null = null; public onInputChange(event: Event) { const value = (event.target as HTMLInputElement).value; this.value = value; - this.onChange(value); - this.onTouched(); + this.onChange?.(value); + this.onTouched?.(); } public writeValue(value: string): void { this.value = value || ''; } - public registerOnChange(fn: any): void { + public registerOnChange(fn: (value: string) => void): void { this.onChange = fn; } - public registerOnTouched(fn: any): void { + public registerOnTouched(fn: () => void): void { this.onTouched = fn; } } diff --git a/src/app/shared/models/errors.ts b/src/app/shared/models/errors.ts index 315ca32..a3cdf0c 100644 --- a/src/app/shared/models/errors.ts +++ b/src/app/shared/models/errors.ts @@ -1,4 +1,4 @@ -export const formErrors: { [key: string]: string } = { +export const formErrors: Record = { required: "Поле является обязательным", minlength: "Количество символов должно быть не менее ", pattern: "Неверный формат" diff --git a/src/app/shared/models/registration-types.ts b/src/app/shared/models/registration-types.ts index df20d17..025cd73 100644 --- a/src/app/shared/models/registration-types.ts +++ b/src/app/shared/models/registration-types.ts @@ -1,5 +1,5 @@ // Данные общие -export type RegistrationData = { +export interface RegistrationData { method?: 'email' | 'social', basicInfo?: BasicInfo, additionalInfo?: AdditionalInfo, @@ -7,7 +7,7 @@ export type RegistrationData = { } // Первый-второй шаг -export type BasicInfo = { +export interface BasicInfo { email: string, name: string, country: string, @@ -15,26 +15,26 @@ export type BasicInfo = { } // Третий шаг -export type AdditionalInfo = { +export interface AdditionalInfo { address: Address, birthDate: Date, gender: string, parentInfo?: ParentInfo } -export type Address = { +export interface Address { country: string, city: string, street: string } -export type ParentInfo = { +export interface ParentInfo { name: string, email: string } // Четвертый шаг -export type Confirmation = { +export interface Confirmation { acceptTerms: boolean, acceptPrivacy: boolean, subscribe: boolean @@ -42,7 +42,7 @@ export type Confirmation = { export type RegistrationStep = 'method' | 'basic' | 'additional' | 'confirmation'; -export type Country = { +export interface Country { code: string; name: string; phoneCode: string; diff --git a/src/app/shared/styles/variables.scss b/src/app/shared/styles/variables.scss index 1c9d020..dec0cd7 100644 --- a/src/app/shared/styles/variables.scss +++ b/src/app/shared/styles/variables.scss @@ -1,9 +1,2 @@ -$error_color: #d32f2f; -$color_1: #e0e0e0; -$color_2: #2196f3; -$label_color: #333; -$label_color_2: #666; -$background: white; -$disabled_color: #ccc; -$sec_background: #f5f5f5; -$sec_border: #ddd; \ No newline at end of file +$error_color: #d32f2f; $color_1: #e0e0e0; $color_2: #2196f3; $label_color: #333; $label_color_2: +#666; $background: white; $disabled_color: #ccc; $sec_background: #f5f5f5; $sec_border: #ddd; diff --git a/src/index.html b/src/index.html index b796457..5a23829 100644 --- a/src/index.html +++ b/src/index.html @@ -1,15 +1,18 @@ - - - MultiForm - - - - - - - - - + + + MultiForm + + + + + + + + + From d134d357653082702aa65cbcb5afc66254ec7bd6 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 29 Oct 2025 21:59:11 +0300 Subject: [PATCH 16/32] feat: additional basics, validators, layout --- src/app/app.routes.ts | 6 +- .../step-additional/step-additional.html | 91 ++++++++++++++++ .../step-additional/step-additional.scss | 1 + .../step-additional/step-additional.spec.ts | 23 ++++ .../step-additional/step-additional.ts | 91 ++++++++++++++++ src/app/features/step-basic/step-basic.scss | 101 +----------------- src/app/shared/models/mock-data.ts | 9 -- src/app/shared/styles/step.scss | 100 +++++++++++++++++ 8 files changed, 312 insertions(+), 110 deletions(-) create mode 100644 src/app/features/step-additional/step-additional.html create mode 100644 src/app/features/step-additional/step-additional.scss create mode 100644 src/app/features/step-additional/step-additional.spec.ts create mode 100644 src/app/features/step-additional/step-additional.ts create mode 100644 src/app/shared/styles/step.scss diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c806832..f388142 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { SignUp } from './features/sign-up/sign-up'; import { StepBasic } from './features/step-basic/step-basic'; +import { StepAdditional } from './features/step-additional/step-additional'; export const routes: Routes = [ { path: '', redirectTo:'/signup', pathMatch: 'full'}, @@ -19,9 +20,12 @@ export const routes: Routes = [ { path: 'basic', component: StepBasic + }, + { + path: 'additional', + component: StepAdditional } ] } - // { path: 'additional', component: Additional }, // { path: 'rules', component: Rules } ]; diff --git a/src/app/features/step-additional/step-additional.html b/src/app/features/step-additional/step-additional.html new file mode 100644 index 0000000..8ad1b2b --- /dev/null +++ b/src/app/features/step-additional/step-additional.html @@ -0,0 +1,91 @@ +
+

Дополнительные данные

+
+ + + + + + + + + + + + +
+
diff --git a/src/app/features/step-additional/step-additional.scss b/src/app/features/step-additional/step-additional.scss new file mode 100644 index 0000000..42b28be --- /dev/null +++ b/src/app/features/step-additional/step-additional.scss @@ -0,0 +1 @@ +@use '../../shared/styles/step'; \ No newline at end of file diff --git a/src/app/features/step-additional/step-additional.spec.ts b/src/app/features/step-additional/step-additional.spec.ts new file mode 100644 index 0000000..dfb67b1 --- /dev/null +++ b/src/app/features/step-additional/step-additional.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StepAdditional } from './step-additional'; + +describe('StepAdditional', () => { + let component: StepAdditional; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StepAdditional] + }) + .compileComponents(); + + fixture = TestBed.createComponent(StepAdditional); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/step-additional/step-additional.ts b/src/app/features/step-additional/step-additional.ts new file mode 100644 index 0000000..9140916 --- /dev/null +++ b/src/app/features/step-additional/step-additional.ts @@ -0,0 +1,91 @@ +import { Component, inject } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Registration } from '../../shared/services/registration'; +import { Router } from '@angular/router'; +import { CustomInput } from '../../shared/components/custom-input/custom-input'; +import { formErrors } from '../../shared/models/errors'; + +@Component({ + selector: 'app-step-additional', + imports: [ReactiveFormsModule, CustomInput], + templateUrl: './step-additional.html', + styleUrl: './step-additional.scss' +}) +export class StepAdditional { + public additionalInfoForm: FormGroup; + + private fb = inject(FormBuilder); + private dataService = inject(Registration); + private router = inject(Router); + private errors = formErrors; + + constructor() { + this.additionalInfoForm = this.createForm(); + } + + private createForm(): FormGroup { + const customEmailPattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$'; + const namePattern = '^[a-zA-Zа-яА-Я0-9]+$'; + return this.fb.group({ + addressCountry: ['', [Validators.required]], + addressCity: ['', [Validators.required]], + addressStreet: ['', [Validators.required]], + birthDate: ['', [Validators.required]], + sex: ['', Validators.required], + parentName: ['', [Validators.required, Validators.minLength(2), Validators.pattern(namePattern)]], + parentEmail: ['', [Validators.required, Validators.pattern(customEmailPattern)]] + }); + } + + getErrorMessage(fieldName: string): string { + const control = this.additionalInfoForm.get(fieldName); + if (!control || !control.errors) return ''; + + const errors = control.errors; + + for (const errorKey in errors) { + if (this.errors[errorKey]) { + let message = this.errors[errorKey]; + switch (errorKey) { + case 'minlength': + message += errors[errorKey].requiredLength; + break; + case 'pattern': + switch (fieldName) { + case ('parentName'): + message += '. Имя не должно содержать спец. символы'; + break; + case ('parentEmail'): + message += ' email'; + break; + } + } + return message; + } + } + + return 'Некорректное значение'; + } + + public onSubmit(): void { + // переход к additional + if (this.additionalInfoForm.valid) { + this.dataService.updateData({ basicInfo: this.additionalInfoForm.value }); + this.router.navigate(['/signup', 'confirmation']); + } else { + this.markFormGroupTouched(); + } + } + + // возвращаемся к началу + public goBack(): void { + this.router.navigate(['/signup', 'basic']); + } + + private markFormGroupTouched(): void { + Object.keys(this.additionalInfoForm.controls).forEach(key => { + const control = this.additionalInfoForm.get(key); + control?.markAsTouched(); + }); + } +} diff --git a/src/app/features/step-basic/step-basic.scss b/src/app/features/step-basic/step-basic.scss index 06ad34d..7310fd7 100644 --- a/src/app/features/step-basic/step-basic.scss +++ b/src/app/features/step-basic/step-basic.scss @@ -1,100 +1 @@ -@use '../../shared/styles/variables' as v; - - -.step-container { - max-width: 500px; - margin: 0 auto; - padding: 1rem; -} - -h2 { - text-align: center; - margin-bottom: 2rem; - color: v.$label_color; -} - -form { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.navigation-buttons { - display: flex; - justify-content: space-between; - margin-top: 2rem; - gap: 1rem; -} - -.btn-primary { - background: v.$color_2; - color: v.$background; - border: none; - padding: 0.75rem 2rem; - border-radius: 6px; - font-size: 1rem; - cursor: pointer; - transition: background 0.2s ease; - flex: 1; - - &:hover:not(:disabled) { - background: v.$color_2; - } - - &:disabled { - background: v.$disabled_color; - cursor: not-allowed; - } -} - -.btn-secondary { - background: v.$sec_background; - color: v.$label_color; - border: 1px solid v.$sec_border; - padding: 0.75rem 2rem; - border-radius: 6px; - font-size: 1rem; - cursor: pointer; - transition: all 0.2s ease; - flex: 1; - - &:hover { - background: v.$color_1; - } -} - -.form-debug { - margin-top: 2rem; - padding: 1rem; - background: v.$sec_background; - border-radius: 4px; - font-family: monospace; - font-size: 0.8rem; -} - -app-custom-input { - &:last-of-type { - animation: slideDown 0.3s ease; - } -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 768px) { - .step-container { - padding: 0.5rem; - } - - .navigation-buttons { - flex-direction: column; - } -} \ No newline at end of file +@use '../../shared/styles/step' \ No newline at end of file diff --git a/src/app/shared/models/mock-data.ts b/src/app/shared/models/mock-data.ts index 3b4173d..aa3ac35 100644 --- a/src/app/shared/models/mock-data.ts +++ b/src/app/shared/models/mock-data.ts @@ -21,14 +21,5 @@ export const mockUser: RegistrationData = { name: 'John Doe', country: 'by', phone: '(029) 111-22-33' - }, - additionalInfo: { - address: { - country: 'Беларусь', - city: 'Минск', - street: 'Жукова' - }, - birthDate: new Date('1990-01-01'), - gender: 'другой' } } \ No newline at end of file diff --git a/src/app/shared/styles/step.scss b/src/app/shared/styles/step.scss new file mode 100644 index 0000000..72e9bce --- /dev/null +++ b/src/app/shared/styles/step.scss @@ -0,0 +1,100 @@ +@use './variables' as v; + + +.step-container { + max-width: 500px; + margin: 0 auto; + padding: 1rem; +} + +h2 { + text-align: center; + margin-bottom: 2rem; + color: v.$label_color; +} + +form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.navigation-buttons { + display: flex; + justify-content: space-between; + margin-top: 2rem; + gap: 1rem; +} + +.btn-primary { + background: v.$color_2; + color: v.$background; + border: none; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: background 0.2s ease; + flex: 1; + + &:hover:not(:disabled) { + background: v.$color_2; + } + + &:disabled { + background: v.$disabled_color; + cursor: not-allowed; + } +} + +.btn-secondary { + background: v.$sec_background; + color: v.$label_color; + border: 1px solid v.$sec_border; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; + flex: 1; + + &:hover { + background: v.$color_1; + } +} + +.form-debug { + margin-top: 2rem; + padding: 1rem; + background: v.$sec_background; + border-radius: 4px; + font-family: monospace; + font-size: 0.8rem; +} + +app-custom-input { + &:last-of-type { + animation: slideDown 0.3s ease; + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .step-container { + padding: 0.5rem; + } + + .navigation-buttons { + flex-direction: column; + } +} \ No newline at end of file From e55a2a31a405825e74b0127f8703fc30bf1cbe6e Mon Sep 17 00:00:00 2001 From: Vlad Date: Thu, 30 Oct 2025 13:42:58 +0300 Subject: [PATCH 17/32] feat: date check --- .../step-additional/step-additional.html | 56 +++++++++--------- .../step-additional/step-additional.ts | 57 +++++++++++++++++-- src/app/shared/models/registration-types.ts | 18 ++---- 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/src/app/features/step-additional/step-additional.html b/src/app/features/step-additional/step-additional.html index 8ad1b2b..032adc1 100644 --- a/src/app/features/step-additional/step-additional.html +++ b/src/app/features/step-additional/step-additional.html @@ -48,39 +48,41 @@

Дополнительные данные

> - - + @if (showParentFields) { + + + } \ No newline at end of file diff --git a/src/app/features/sign-up/sign-up.ts b/src/app/features/sign-up/sign-up.ts index 73387a6..07f1df1 100644 --- a/src/app/features/sign-up/sign-up.ts +++ b/src/app/features/sign-up/sign-up.ts @@ -1,55 +1,24 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { mockUser } from '../../shared/models/mock-data'; -import { Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { Registration } from '../../shared/services/registration'; -import { debounceTime } from 'rxjs'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-sign-up', - imports: [ReactiveFormsModule], + imports: [RouterLink], templateUrl: './sign-up.html', styleUrl: './sign-up.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class SignUp implements OnInit { - public form!: FormGroup; - private fb = inject(FormBuilder); - - private destroyRef = inject(DestroyRef); + public method = signal(''); + private dataService = inject(Registration); - private router = inject(Router); + + private methodSet = effect(() => { + this.dataService.updateData({ method: this.method() }); + }); public ngOnInit(): void { - this.form = this.fb.group({ - method: ['', Validators.required] - }); this.dataService.clearData(); - this.form.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(500) - ).subscribe(value => this.dataService.updateData(value)); - } - - public selectMethod(method: 'email' | 'social'): void { - this.form?.patchValue({ - method - }); - } - - public onSubmit(): void { - if (this.form.valid) { - if (this.form.get('method')?.value === 'email') { - this.dataService.updateData(this.form.value); - this.router.navigate(['/signup', 'basic']); - } - else { - this.dataService.updateData(mockUser); - this.router.navigate(['/signup', 'additional']); - } - } else { - this.form.markAllAsTouched(); - } } } diff --git a/src/app/features/step-additional/step-additional.ts b/src/app/features/step-additional/step-additional.ts index ad5ca94..0bccf82 100644 --- a/src/app/features/step-additional/step-additional.ts +++ b/src/app/features/step-additional/step-additional.ts @@ -42,8 +42,8 @@ export class StepAdditional implements OnInit { }); const currentData = this.dataService.getCurrentData(); // Возврат к другой странице, если не тот метод или невалидность предыдущих форм - if (!currentData.method) this.router.navigate(['/signup', 'method']); - if (!currentData.basicInfo?.valid) this.location.back(); + // if (!currentData.method) this.router.navigate(['/signup', 'method']); + // if (!currentData.basicInfo?.valid) this.location.back(); if (currentData.additionalInfo) { this.form.patchValue({...currentData.additionalInfo}); diff --git a/src/app/features/step-basic/step-basic.ts b/src/app/features/step-basic/step-basic.ts index b094658..931cbaf 100644 --- a/src/app/features/step-basic/step-basic.ts +++ b/src/app/features/step-basic/step-basic.ts @@ -36,7 +36,7 @@ export class StepBasic implements OnInit { const currentData = this.dataService.getCurrentData(); // Возврат к другой странице, если не тот метод - if (currentData.method !== 'email') this.router.navigate(['/signup', 'method']); + //if (currentData.method !== 'email') this.router.navigate(['/signup', 'method']); if (currentData.basicInfo) { this.form.patchValue({...currentData.basicInfo}); @@ -67,7 +67,9 @@ export class StepBasic implements OnInit { public onSubmit(): void { if (this.form.valid) { this.dataService.updateData({ basicInfo: { ...this.form.value, valid: true } }); - this.router.navigate(['/signup', 'additional']); + const currentUrl = this.router.url.split('/'); + currentUrl.pop(); + this.router.navigate([currentUrl.join('/'), 'additional']); } else { this.form.markAllAsTouched(); } diff --git a/src/app/features/step-rules/step-rules.ts b/src/app/features/step-rules/step-rules.ts index dba9786..24c19cb 100644 --- a/src/app/features/step-rules/step-rules.ts +++ b/src/app/features/step-rules/step-rules.ts @@ -34,8 +34,8 @@ export class StepRules implements OnInit { const currentData = this.dataService.getCurrentData(); // Возврат к другой странице, если не тот метод или невалидность предыдущих форм - if (!currentData.method) this.router.navigate(['/signup', 'method']); - if (!currentData.additionalInfo?.valid || !currentData.basicInfo?.valid) this.location.back(); + // if (!currentData.method) this.router.navigate(['/signup', 'method']); + // if (!currentData.additionalInfo?.valid || !currentData.basicInfo?.valid) this.location.back(); if (currentData.confirmation) { this.form.patchValue({...currentData.confirmation}); diff --git a/src/app/shared/guards/email-guard.spec.ts b/src/app/shared/guards/email-guard.spec.ts new file mode 100644 index 0000000..06dc8bd --- /dev/null +++ b/src/app/shared/guards/email-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { emailGuard } from './email-guard'; + +describe('emailGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => emailGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/src/app/shared/guards/email-guard.ts b/src/app/shared/guards/email-guard.ts new file mode 100644 index 0000000..e0131ea --- /dev/null +++ b/src/app/shared/guards/email-guard.ts @@ -0,0 +1,21 @@ +import { inject, Injectable } from "@angular/core"; +import { CanActivate, Router, UrlTree } from "@angular/router"; +import { Registration } from "../services/registration"; +import { Observable } from "rxjs"; + + +@Injectable({ + providedIn: 'root' +}) +export class emailGuard implements CanActivate { + private readonly registrationService = inject(Registration); + private readonly router = inject(Router); + + canActivate(): Observable | Promise | boolean | UrlTree { + const currentData = this.registrationService.getCurrentData(); + if (currentData.method !== 'email') { + return this.router.navigateByUrl('signup/socials/additional'); + } + return true; + } +} \ No newline at end of file diff --git a/src/app/shared/guards/socials-guard.spec.ts b/src/app/shared/guards/socials-guard.spec.ts new file mode 100644 index 0000000..c9591a3 --- /dev/null +++ b/src/app/shared/guards/socials-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { socialsGuard } from './socials-guard'; + +describe('socialsGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => socialsGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/src/app/shared/guards/socials-guard.ts b/src/app/shared/guards/socials-guard.ts new file mode 100644 index 0000000..0e7dc50 --- /dev/null +++ b/src/app/shared/guards/socials-guard.ts @@ -0,0 +1,21 @@ +import { inject, Injectable } from "@angular/core"; +import { CanActivate, Router, UrlTree } from "@angular/router"; +import { Registration } from "../services/registration"; +import { Observable } from "rxjs"; + + +@Injectable({ + providedIn: 'root' +}) +export class socialsGuard implements CanActivate { + private readonly registrationService = inject(Registration); + private readonly router = inject(Router); + + canActivate(): Observable | Promise | boolean | UrlTree { + const currentData = this.registrationService.getCurrentData(); + if (currentData.method !== 'social') { + return this.router.navigateByUrl('signup/email/basic'); + } + return true; + } +} \ No newline at end of file diff --git a/src/app/shared/models/mock-data.ts b/src/app/shared/models/mock-data.ts index 7879443..ffef8b7 100644 --- a/src/app/shared/models/mock-data.ts +++ b/src/app/shared/models/mock-data.ts @@ -22,7 +22,7 @@ export const genders: string[] = [ ] export const mockUser: RegistrationData = { - method: 'social', + method: 'socials', basicInfo: { email: 'john@doe.com', name: 'John', diff --git a/src/app/shared/models/registration-types.ts b/src/app/shared/models/registration-types.ts index a905a8d..c2dce7e 100644 --- a/src/app/shared/models/registration-types.ts +++ b/src/app/shared/models/registration-types.ts @@ -1,6 +1,6 @@ // Данные общие export interface RegistrationData { - method?: 'email' | 'social', + method?: string, basicInfo?: BasicInfo, additionalInfo?: AdditionalInfo, confirmation?: Confirmation From 0d5c405c821f151ead9d6df8a11777a57ab14b37 Mon Sep 17 00:00:00 2001 From: Vlad Date: Tue, 4 Nov 2025 19:14:18 +0300 Subject: [PATCH 29/32] feat: formvalid guard --- src/app/app.routes.ts | 16 +++++++----- .../step-additional/step-additional.ts | 26 +++++++++++-------- src/app/features/step-basic/step-basic.ts | 12 ++++----- src/app/features/step-rules/step-rules.ts | 13 +++------- src/app/shared/guards/email-guard.spec.ts | 17 ------------ src/app/shared/guards/form-validate-guard.ts | 21 +++++++++++++++ src/app/shared/guards/socials-guard.spec.ts | 17 ------------ src/app/shared/services/registration.ts | 14 ++++++++++ 8 files changed, 69 insertions(+), 67 deletions(-) delete mode 100644 src/app/shared/guards/email-guard.spec.ts create mode 100644 src/app/shared/guards/form-validate-guard.ts delete mode 100644 src/app/shared/guards/socials-guard.spec.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f36938d..3793dfd 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { emailGuard } from './shared/guards/email-guard'; import { socialsGuard } from './shared/guards/socials-guard'; +import { FormValidateGuard } from './shared/guards/form-validate-guard'; export const routes: Routes = [ { @@ -16,29 +17,32 @@ export const routes: Routes = [ children: [ { path: 'basic', - loadComponent: () => import('./features/step-basic/step-basic').then(m => m.StepBasic) + loadComponent: () => import('./features/step-basic/step-basic').then(m => m.StepBasic), }, { path: 'additional', - loadComponent: () => import('./features/step-additional/step-additional').then(m => m.StepAdditional) + loadComponent: () => import('./features/step-additional/step-additional').then(m => m.StepAdditional), + canActivate: [FormValidateGuard] }, { path: 'rules', - loadComponent: () => import('./features/step-rules/step-rules').then(m => m.StepRules) + loadComponent: () => import('./features/step-rules/step-rules').then(m => m.StepRules), + canActivate: [FormValidateGuard] } ] }, { path: 'socials', - canActivate: [socialsGuard], + canActivate: [socialsGuard], children: [ { path: 'additional', - loadComponent: () => import('./features/step-additional/step-additional').then(m => m.StepAdditional) + loadComponent: () => import('./features/step-additional/step-additional').then(m => m.StepAdditional), }, { path: 'rules', - loadComponent: () => import('./features/step-rules/step-rules').then(m => m.StepRules) + loadComponent: () => import('./features/step-rules/step-rules').then(m => m.StepRules), + canActivate: [FormValidateGuard] } ] } diff --git a/src/app/features/step-additional/step-additional.ts b/src/app/features/step-additional/step-additional.ts index 0bccf82..a343679 100644 --- a/src/app/features/step-additional/step-additional.ts +++ b/src/app/features/step-additional/step-additional.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit } from ' import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { CustomInput } from '../../shared/components/custom-input/custom-input'; import { debounceTime } from 'rxjs'; -import { DatePipe, Location } from '@angular/common'; +import { DatePipe } from '@angular/common'; import { DateValidators } from '../../shared/models/date-validators'; import { emailPattern, namePattern } from '../../shared/models/mock-data'; import { Registration } from '../../shared/services/registration'; @@ -24,7 +24,6 @@ export class StepAdditional implements OnInit { public maxDate = new Date(); private fb = inject(FormBuilder); - private location = inject(Location); private destroyRef = inject(DestroyRef); private dataService = inject(Registration); private router = inject(Router); @@ -41,9 +40,6 @@ export class StepAdditional implements OnInit { parentEmail: [''] }); const currentData = this.dataService.getCurrentData(); - // Возврат к другой странице, если не тот метод или невалидность предыдущих форм - // if (!currentData.method) this.router.navigate(['/signup', 'method']); - // if (!currentData.basicInfo?.valid) this.location.back(); if (currentData.additionalInfo) { this.form.patchValue({...currentData.additionalInfo}); @@ -54,12 +50,13 @@ export class StepAdditional implements OnInit { } } - this.dataService.updateData({ additionalInfo: { ...this.form.value, valid: false } }); - this.form.valueChanges.pipe( takeUntilDestroyed(this.destroyRef), debounceTime(500) - ).subscribe(value => this.dataService.updateData({ additionalInfo: value })); + ).subscribe(value => { + value.valid = this.form.valid; + this.dataService.updateData({ additionalInfo: value }) + }); this.form.get('birthDate')?.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) @@ -84,15 +81,18 @@ export class StepAdditional implements OnInit { public onSubmit(): void { if (this.form.valid) { - this.dataService.updateData({ additionalInfo: { ...this.form.value, valid: true } }); - this.router.navigate(['/signup', 'rules']); + const currentUrl = this.router.url.split('/'); + currentUrl.pop(); + this.router.navigate([currentUrl.join('/'), 'rules']); } else { this.form.markAllAsTouched(); } } public goBack(): void { - this.router.navigate(['/signup', 'basic']); + const currentUrl = this.router.url.split('/'); + currentUrl.pop(); + this.router.navigate([currentUrl.join('/'), 'basic']); } public getErrorMessage(fieldName: string): string { @@ -103,6 +103,10 @@ export class StepAdditional implements OnInit { return this.errorService.isFieldInvalid(this.form.get(fieldName)); } + public isFormValid(): boolean { + return this.form.valid; + } + private getAge(birthDate: Date): number { const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); diff --git a/src/app/features/step-basic/step-basic.ts b/src/app/features/step-basic/step-basic.ts index 931cbaf..2b212c5 100644 --- a/src/app/features/step-basic/step-basic.ts +++ b/src/app/features/step-basic/step-basic.ts @@ -34,9 +34,6 @@ export class StepBasic implements OnInit { phone: [''] }); const currentData = this.dataService.getCurrentData(); - - // Возврат к другой странице, если не тот метод - //if (currentData.method !== 'email') this.router.navigate(['/signup', 'method']); if (currentData.basicInfo) { this.form.patchValue({...currentData.basicInfo}); @@ -45,12 +42,14 @@ export class StepBasic implements OnInit { } } - this.dataService.updateData({ basicInfo: { ...this.form.value, valid: false } }); - this.form.valueChanges.pipe( takeUntilDestroyed(this.destroyRef), debounceTime(500) - ).subscribe(value => this.dataService.updateData({ basicInfo: value })); + ).subscribe(value => + { + value.valid = this.form.valid; + this.dataService.updateData({ basicInfo: value }) + }); // Отслеживаем изменения country, чтоб отображать ввод телефона this.form.get('country')?.valueChanges @@ -66,7 +65,6 @@ export class StepBasic implements OnInit { public onSubmit(): void { if (this.form.valid) { - this.dataService.updateData({ basicInfo: { ...this.form.value, valid: true } }); const currentUrl = this.router.url.split('/'); currentUrl.pop(); this.router.navigate([currentUrl.join('/'), 'additional']); diff --git a/src/app/features/step-rules/step-rules.ts b/src/app/features/step-rules/step-rules.ts index 24c19cb..c5cea58 100644 --- a/src/app/features/step-rules/step-rules.ts +++ b/src/app/features/step-rules/step-rules.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { CustomInput } from '../../shared/components/custom-input/custom-input'; -import { Location } from '@angular/common'; import { Registration } from '../../shared/services/registration'; import { Router } from '@angular/router'; import { FormError } from '../../shared/services/form-error'; @@ -19,7 +18,6 @@ export class StepRules implements OnInit { public form!: FormGroup; private fb = inject(FormBuilder); - private location = inject(Location); protected destroyRef = inject(DestroyRef) protected dataService = inject(Registration); protected router = inject(Router); @@ -32,10 +30,6 @@ export class StepRules implements OnInit { subscribe: [false] }); const currentData = this.dataService.getCurrentData(); - - // Возврат к другой странице, если не тот метод или невалидность предыдущих форм - // if (!currentData.method) this.router.navigate(['/signup', 'method']); - // if (!currentData.additionalInfo?.valid || !currentData.basicInfo?.valid) this.location.back(); if (currentData.confirmation) { this.form.patchValue({...currentData.confirmation}); @@ -49,15 +43,16 @@ export class StepRules implements OnInit { public onSubmit(): void { if (this.form.valid) { - this.dataService.updateData({ confirmation: this.form.value }); - this.router.navigate(['/signup', 'rules']); + alert("Успешно!"); } else { this.form.markAllAsTouched(); } } public goBack(): void { - this.router.navigate(['/signup', 'additional']); + const currentUrl = this.router.url.split('/'); + currentUrl.pop(); + this.router.navigate([currentUrl.join('/'), 'additional']); } public getErrorMessage(fieldName: string): string { diff --git a/src/app/shared/guards/email-guard.spec.ts b/src/app/shared/guards/email-guard.spec.ts deleted file mode 100644 index 06dc8bd..0000000 --- a/src/app/shared/guards/email-guard.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { CanActivateFn } from '@angular/router'; - -import { emailGuard } from './email-guard'; - -describe('emailGuard', () => { - const executeGuard: CanActivateFn = (...guardParameters) => - TestBed.runInInjectionContext(() => emailGuard(...guardParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it('should be created', () => { - expect(executeGuard).toBeTruthy(); - }); -}); diff --git a/src/app/shared/guards/form-validate-guard.ts b/src/app/shared/guards/form-validate-guard.ts new file mode 100644 index 0000000..0428ceb --- /dev/null +++ b/src/app/shared/guards/form-validate-guard.ts @@ -0,0 +1,21 @@ +import { Injectable, inject } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { Registration } from '../services/registration'; +import { Location } from '@angular/common'; + +@Injectable({ + providedIn: 'root' +}) +export class FormValidateGuard implements CanActivate { + private registrationService = inject(Registration); + private location = inject(Location); + + canActivate(): boolean { + const isPreviousFormValid = this.registrationService.isPreviousStepValid(); + if (!isPreviousFormValid) { + this.location.back(); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/src/app/shared/guards/socials-guard.spec.ts b/src/app/shared/guards/socials-guard.spec.ts deleted file mode 100644 index c9591a3..0000000 --- a/src/app/shared/guards/socials-guard.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { CanActivateFn } from '@angular/router'; - -import { socialsGuard } from './socials-guard'; - -describe('socialsGuard', () => { - const executeGuard: CanActivateFn = (...guardParameters) => - TestBed.runInInjectionContext(() => socialsGuard(...guardParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it('should be created', () => { - expect(executeGuard).toBeTruthy(); - }); -}); diff --git a/src/app/shared/services/registration.ts b/src/app/shared/services/registration.ts index 0033b98..a95ba9a 100644 --- a/src/app/shared/services/registration.ts +++ b/src/app/shared/services/registration.ts @@ -41,4 +41,18 @@ export class Registration { this.data = { }; this.dataSubject.next(this.data); } + + isPreviousStepValid(): boolean | undefined { + const currentUrl = window.location.href; + + if (currentUrl.includes('/additional')) { + return this.data.basicInfo?.valid; + } + + if (currentUrl.includes('/rules')) { + return this.data.additionalInfo?.valid; + } + + return true; + } } From 7555138b2df06bc31d5f2ea6e12b98a7a3fb39c6 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Nov 2025 09:06:05 +0300 Subject: [PATCH 30/32] feat: birthDateValidator --- .../step-additional/step-additional.html | 2 +- .../step-additional/step-additional.ts | 49 +++++-------------- .../shared/validators/birthDateValidator.ts | 39 +++++++++++++++ 3 files changed, 53 insertions(+), 37 deletions(-) create mode 100644 src/app/shared/validators/birthDateValidator.ts diff --git a/src/app/features/step-additional/step-additional.html b/src/app/features/step-additional/step-additional.html index d9b302e..3f4d861 100644 --- a/src/app/features/step-additional/step-additional.html +++ b/src/app/features/step-additional/step-additional.html @@ -53,7 +53,7 @@

Дополнительные данные

[required]="true" > - @if (showParentFields) { + @if (parentsRequired()) { { - if (!this.form.get('birthDate')?.invalid) { - this.showParentFields = this.getAge(new Date(birthDate)) < 18; - const parentName = this.form.get('parentName'); - const parentEmail = this.form.get('parentEmail'); - if (!this.showParentFields) { - this.form.patchValue({ parentName: '', parentEmail: '' }); - parentName?.clearValidators(); - parentEmail?.clearValidators(); - } else { - parentName?.setValidators([Validators.required, Validators.minLength(2), Validators.pattern(namePattern)]); - parentEmail?.setValidators([Validators.required, Validators.pattern(emailPattern)]); - } - parentName?.updateValueAndValidity(); - parentEmail?.updateValueAndValidity(); - } - }) } public onSubmit(): void { @@ -107,14 +82,16 @@ export class StepAdditional implements OnInit { return this.form.valid; } - private getAge(birthDate: Date): number { - const today = new Date(); - let age = today.getFullYear() - birthDate.getFullYear(); - const monthDiff = today.getMonth() - birthDate.getMonth(); - - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { - age--; + public parentsRequired(): boolean { + const parentEmail = this.form.get('parentEmail'); + const parentName = this.form.get('parentName'); + if (parentName?.validator && parentEmail?.validator) { + return true; + } + else { + parentEmail?.setValue(''); + parentName?.setValue(''); + return false; } - return age; } } diff --git a/src/app/shared/validators/birthDateValidator.ts b/src/app/shared/validators/birthDateValidator.ts new file mode 100644 index 0000000..c8be67c --- /dev/null +++ b/src/app/shared/validators/birthDateValidator.ts @@ -0,0 +1,39 @@ +import { AbstractControl, ValidationErrors, Validators } from "@angular/forms"; +import { emailPattern, namePattern } from "../models/mock-data"; + +export function birthDateValidator(form: AbstractControl): ValidationErrors | null { + const birthDate = form.get('birthDate')?.value; + const parentName = form.get('parentName'); + const parentEmail = form.get('parentEmail'); + + if (!birthDate) return null; + + const isMinor = getAge(new Date(birthDate)) < 18; + + if (isMinor) { + parentName?.setValidators([ + Validators.required, + Validators.minLength(2), + Validators.pattern(namePattern), + ]); + parentEmail?.setValidators([ + Validators.required, + Validators.pattern(emailPattern), + ]); + } else { + parentName?.clearValidators(); + parentEmail?.clearValidators(); + } + return null; +} + +export function getAge(birthDate: Date): number { + const today = new Date(); + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return age; +} \ No newline at end of file From 4c90e998fae1bb61b342197f364c412403d502f2 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Nov 2025 09:52:54 +0300 Subject: [PATCH 31/32] feat: signal showPhone, split debouncetime --- .../step-additional/step-additional.ts | 20 +++++++---- src/app/features/step-basic/step-basic.html | 2 +- src/app/features/step-basic/step-basic.ts | 33 ++++++++++--------- src/app/features/step-rules/step-rules.ts | 18 +++++++--- 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/src/app/features/step-additional/step-additional.ts b/src/app/features/step-additional/step-additional.ts index 83583c6..48997ff 100644 --- a/src/app/features/step-additional/step-additional.ts +++ b/src/app/features/step-additional/step-additional.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { CustomInput } from '../../shared/components/custom-input/custom-input'; -import { debounceTime } from 'rxjs'; +import { debounceTime, merge } from 'rxjs'; import { DatePipe } from '@angular/common'; import { DateValidators } from '../../shared/models/date-validators'; import { Registration } from '../../shared/services/registration'; @@ -45,13 +45,19 @@ export class StepAdditional implements OnInit { this.form.patchValue({...currentData.additionalInfo}); } - this.form.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(500) - ).subscribe(value => { + const fieldChanges = Object.keys(this.form.controls).map(fieldName => + this.form.get(fieldName)!.valueChanges.pipe( + debounceTime(500) + ) + ); + + merge(...fieldChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const value = { ...this.form.value }; value.valid = this.form.valid; - this.dataService.updateData({ additionalInfo: value }) - }); + this.dataService.updateData({ additionalInfo: value }); + }); } public onSubmit(): void { diff --git a/src/app/features/step-basic/step-basic.html b/src/app/features/step-basic/step-basic.html index dd5e42f..b72375c 100644 --- a/src/app/features/step-basic/step-basic.html +++ b/src/app/features/step-basic/step-basic.html @@ -31,7 +31,7 @@

Основные данные

[required]="true" >
- @if (showPhoneField) { + @if (showPhoneField()) { + this.form.get(fieldName)!.valueChanges.pipe( + debounceTime(500) + ) + ); - this.form.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(500) - ).subscribe(value => - { + merge(...fieldChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const value = { ...this.form.value }; value.valid = this.form.valid; - this.dataService.updateData({ basicInfo: value }) - }); + this.dataService.updateData({ basicInfo: value }); + }); - // Отслеживаем изменения country, чтоб отображать ввод телефона this.form.get('country')?.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(country => { - this.showPhoneField = !!country; - + this.showPhoneField.set(!!country); if (!country) { this.form.patchValue({ phone: '' }); } diff --git a/src/app/features/step-rules/step-rules.ts b/src/app/features/step-rules/step-rules.ts index c5cea58..42aa2e5 100644 --- a/src/app/features/step-rules/step-rules.ts +++ b/src/app/features/step-rules/step-rules.ts @@ -5,7 +5,7 @@ import { Registration } from '../../shared/services/registration'; import { Router } from '@angular/router'; import { FormError } from '../../shared/services/form-error'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { debounceTime } from 'rxjs'; +import { debounceTime, merge } from 'rxjs'; @Component({ selector: 'app-step-rules', @@ -35,10 +35,18 @@ export class StepRules implements OnInit { this.form.patchValue({...currentData.confirmation}); } - this.form.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(500) - ).subscribe(value => this.dataService.updateData({ confirmation: value })); + const fieldChanges = Object.keys(this.form.controls).map(fieldName => + this.form.get(fieldName)!.valueChanges.pipe( + debounceTime(500) + ) + ); + + merge(...fieldChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + const value = { ...this.form.value }; + this.dataService.updateData({ confirmation: value }); + }); } public onSubmit(): void { From 5634926de807431335169907b4928fbd7a60c10e Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 5 Nov 2025 10:07:31 +0300 Subject: [PATCH 32/32] feat: 404 page --- src/app/app.routes.ts | 5 ++++- src/app/features/not-found/not-found.html | 5 +++++ src/app/features/not-found/not-found.scss | 20 +++++++++++++++++ src/app/features/not-found/not-found.spec.ts | 23 ++++++++++++++++++++ src/app/features/not-found/not-found.ts | 13 +++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/app/features/not-found/not-found.html create mode 100644 src/app/features/not-found/not-found.scss create mode 100644 src/app/features/not-found/not-found.spec.ts create mode 100644 src/app/features/not-found/not-found.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 3793dfd..2b9b87d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -48,5 +48,8 @@ export const routes: Routes = [ } ] }, - { path: '**', redirectTo: 'signup/method' }, + { + path: '**', + loadComponent: () => import('./features/not-found/not-found').then(m => m.NotFound), + }, ]; diff --git a/src/app/features/not-found/not-found.html b/src/app/features/not-found/not-found.html new file mode 100644 index 0000000..9a65aa2 --- /dev/null +++ b/src/app/features/not-found/not-found.html @@ -0,0 +1,5 @@ +
+

404 - Страница не найдена

+

Извините, запрашиваемая страница не существует.

+ +
\ No newline at end of file diff --git a/src/app/features/not-found/not-found.scss b/src/app/features/not-found/not-found.scss new file mode 100644 index 0000000..247126f --- /dev/null +++ b/src/app/features/not-found/not-found.scss @@ -0,0 +1,20 @@ +@use '../../shared/styles/variables' as v; + +h2 { + margin-block: 2rem; + color: v.$label_color; +} + +button { + background: v.$color_2; + color: v.$background; + border: none; + padding: 0.75rem 2rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; +} + +.not-found-container { + text-align: center; +} \ No newline at end of file diff --git a/src/app/features/not-found/not-found.spec.ts b/src/app/features/not-found/not-found.spec.ts new file mode 100644 index 0000000..dbf160e --- /dev/null +++ b/src/app/features/not-found/not-found.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NotFound } from './not-found'; + +describe('NotFound', () => { + let component: NotFound; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NotFound] + }) + .compileComponents(); + + fixture = TestBed.createComponent(NotFound); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/not-found/not-found.ts b/src/app/features/not-found/not-found.ts new file mode 100644 index 0000000..82d9d58 --- /dev/null +++ b/src/app/features/not-found/not-found.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-not-found', + imports: [RouterLink], + templateUrl: './not-found.html', + styleUrl: './not-found.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NotFound { + +}