diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json index bb3f251..c041517 100644 --- a/.ai/mcp/mcp.json +++ b/.ai/mcp/mcp.json @@ -2,11 +2,7 @@ "mcpServers": { "angular-cli": { "command": "npx", - "args": [ - "-y", - "@angular/cli", - "mcp" - ] + "args": ["-y", "@angular/cli", "mcp"] } } -} \ No newline at end of file +} diff --git a/src/app/features/checkout/address/address.html b/src/app/features/checkout/address/address.html index ca829e0..27834c5 100644 --- a/src/app/features/checkout/address/address.html +++ b/src/app/features/checkout/address/address.html @@ -1,49 +1,18 @@ -
-
- -
-
- @for (address of addresses(); track address.id) { -
- - -
- } - -
-
- -
-
- Have any coupon ? -
- - -
-
- @if (addressIdControl.invalid && addressIdControl.touched) { -
- Please select an address -
- } - -
-
-
+
+ @for (address of addresses(); track address.id) { +
+ +
-
+ } + + diff --git a/src/app/features/checkout/address/address.ts b/src/app/features/checkout/address/address.ts index e74443c..27e1e0e 100644 --- a/src/app/features/checkout/address/address.ts +++ b/src/app/features/checkout/address/address.ts @@ -1,8 +1,6 @@ import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; import { AddressForm } from "../components/address-form/address-form"; -import { GoBack } from "@app/shared/components/go-back/go-back"; import { AddressSelect } from "../components/address-select/address-select"; -import { OrderSummery } from "../components/order-summery/order-summery"; import { AddressRequest, AddressResponse, @@ -10,26 +8,26 @@ import { } from "@app/features/checkout/services/address-service"; import { AuthService } from "@core/services/auth-service"; import { User } from "@core/models/user.model"; -import { of, switchMap } from "rxjs"; +import { finalize, of, switchMap } from "rxjs"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ReactiveFormsModule } from "@angular/forms"; @Component({ selector: "app-address", - imports: [AddressSelect, AddressForm, GoBack, OrderSummery, ReactiveFormsModule], + imports: [AddressSelect, AddressForm, ReactiveFormsModule], templateUrl: "./address.html", styleUrl: "./address.css", }) export class Address implements OnInit { addressService = inject(AddressService); authService = inject(AuthService); + addressIdControl = this.addressService.addressIdControl; + isProcessing = signal(false); // I am subscribing to the observable instead of using toSignal(), // i have to destroy the subscription manually. destroyRef = inject(DestroyRef); - protected addresses = signal([]); - protected addressIdControl = new FormControl(null, Validators.required); private user: User | undefined; ngOnInit(): void { @@ -53,24 +51,30 @@ export class Address implements OnInit { } protected createNewAddress(addressData: AddressRequest) { - this.addressService.createAddress(this.user!.id, addressData).subscribe({ - next: (address) => this.addresses.update((addresses) => [...addresses, address]), - }); + this.isProcessing.set(true); + this.addressService + .createAddress(this.user!.id, addressData) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isProcessing.set(false)), + ) + .subscribe({ + next: (address) => this.addresses.update((addresses) => [...addresses, address]), + }); } protected updateAddress(addressData: AddressResponse) { - console.log(addressData); - this.addressService.updateAddress(addressData.id, addressData).subscribe({ - next: (address) => - this.addresses.update((addresses) => - addresses.map((a) => (a.id === address.id ? address : a)), - ), - }); - } - - protected proceedToPayment() { - if (this.addressIdControl.invalid) { - this.addressIdControl.markAsTouched(); - return; - } + this.isProcessing.set(true); + this.addressService + .updateAddress(addressData.id, addressData) + .pipe( + takeUntilDestroyed(this.destroyRef), + finalize(() => this.isProcessing.set(false)), + ) + .subscribe({ + next: (address) => + this.addresses.update((addresses) => + addresses.map((a) => (a.id === address.id ? address : a)), + ), + }); } } diff --git a/src/app/features/checkout/checkout.html b/src/app/features/checkout/checkout.html index 8d4ae2c..4896f21 100644 --- a/src/app/features/checkout/checkout.html +++ b/src/app/features/checkout/checkout.html @@ -1,6 +1,46 @@
- +
- +
+ @if (currentStepNumber() > 0) { + + } + +
+
+ +
+
+
+
+
diff --git a/src/app/features/checkout/checkout.routes.ts b/src/app/features/checkout/checkout.routes.ts index 0817b56..4e28c7e 100644 --- a/src/app/features/checkout/checkout.routes.ts +++ b/src/app/features/checkout/checkout.routes.ts @@ -1,6 +1,7 @@ import { Routes } from "@angular/router"; import { Address } from "./address/address"; import { Checkout } from "./checkout"; +import { Payment } from "@app/features/checkout/payment/payment"; export const checkoutRoutes: Routes = [ { @@ -8,9 +9,13 @@ export const checkoutRoutes: Routes = [ component: Checkout, children: [ { - path: "address/:cartId", + path: "address", component: Address, }, + { + path: "payment", + component: Payment, + }, ], }, ]; diff --git a/src/app/features/checkout/checkout.ts b/src/app/features/checkout/checkout.ts index 50ee05a..872bd9a 100644 --- a/src/app/features/checkout/checkout.ts +++ b/src/app/features/checkout/checkout.ts @@ -1,18 +1,77 @@ -import { Component } from "@angular/core"; -import { RouterOutlet } from "@angular/router"; +import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Stepper, Steps } from "@app/shared/components/stepper/stepper"; +import { OrderSummery } from "@app/features/checkout/components/order-summery/order-summery"; +import { GoBack } from "@shared/components/go-back/go-back"; +import { AddressService } from "@app/features/checkout/services/address-service"; +import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner"; +import { OrderService } from "@app/features/checkout/services/order-service"; +import { CartService } from "@core/services/cart-service"; +import { CartModel } from "@core/models/cart.model"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { filter, finalize, map } from "rxjs"; @Component({ selector: "app-checkout", - imports: [RouterOutlet, Stepper], + providers: [AddressService], + imports: [RouterOutlet, Stepper, OrderSummery, GoBack, LoadingSpinner], templateUrl: "./checkout.html", styleUrl: "./checkout.css", }) -export class Checkout { +export class Checkout implements OnInit { steps: Steps[] = [ - { label: "Cart" }, - { label: "Address" }, - { label: "Payment" }, - { label: "Confirm" }, + { label: "Cart", route: "" }, + { label: "Address", route: "/checkout/address" }, + { label: "Payment", route: "/checkout/payment" }, + { label: "Confirm", route: "/checkout/address" }, ]; + destroyRef = inject(DestroyRef); + orderCreationLoading = signal(false); + private addressService = inject(AddressService); + addressIdControl = this.addressService.addressIdControl; + private orderService = inject(OrderService); + private cartService = inject(CartService); + private cart: CartModel | undefined; + private router = inject(Router); + + protected currentStepNumber = toSignal( + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), // Added TS type guard + map((event: NavigationEnd) => { + // Strip query params and fragments to ensure exact matching + const cleanUrl = event.urlAfterRedirects.split("?")[0].split("#")[0]; + + // Exact match comparison + const activeIndex = this.steps.findIndex( + (step) => cleanUrl === step.route || (step.route !== "" && cleanUrl.endsWith(step.route)), + ); + + return activeIndex !== -1 ? activeIndex : 0; + }), + ), + { initialValue: 1 }, + ); + + ngOnInit(): void { + this.cartService.cartItems$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((cart) => { + this.cart = cart; + }); + } + + protected proceedToPayment() { + if (this.addressIdControl.invalid) { + this.addressIdControl.markAsTouched(); + return; + } + this.orderCreationLoading.set(true); + this.orderService + .createOrder({ cartId: this.cart!.id, addressId: this.addressIdControl.value! }) + .pipe( + finalize(() => this.orderCreationLoading.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(async () => { + await this.router.navigate(["/checkout/payment"]); + }); + } } diff --git a/src/app/features/checkout/components/address-form/address-form.html b/src/app/features/checkout/components/address-form/address-form.html index ac5ddf5..a410bf8 100644 --- a/src/app/features/checkout/components/address-form/address-form.html +++ b/src/app/features/checkout/components/address-form/address-form.html @@ -1,58 +1,56 @@ -
+
-
First Name - - + +
Last Name - - + +
Street Address - - + +
City - - + +
State - - + +
Pin Code - - + +
- -
diff --git a/src/app/features/checkout/components/address-form/address-form.ts b/src/app/features/checkout/components/address-form/address-form.ts index 0250074..3deb3c4 100644 --- a/src/app/features/checkout/components/address-form/address-form.ts +++ b/src/app/features/checkout/components/address-form/address-form.ts @@ -1,28 +1,21 @@ -import { Component, EventEmitter, Input, Output, signal } from "@angular/core"; +import { Component, EventEmitter, input, Input, Output, signal } from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; import { Error } from "@app/shared/components/error/error"; import { AddressRequest, AddressResponse } from "@app/features/checkout/services/address-service"; +import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner"; @Component({ selector: "app-address-form", - imports: [ReactiveFormsModule, Error], + imports: [ReactiveFormsModule, Error, LoadingSpinner], templateUrl: "./address-form.html", styleUrl: "./address-form.css", }) export class AddressForm { - @Input() set initialData(address: AddressResponse) { - if (address) { - this.addressForm.patchValue(address); - this.address.set(address); - this.isEditing.set(true); - } - } @Output() submitAddress: EventEmitter = new EventEmitter(); @Output() updateAddress: EventEmitter = new EventEmitter(); @Output() editingCanceled: EventEmitter = new EventEmitter(); - protected isEditing = signal(false); - protected address = signal(null); + isProcessing = input(false); addressForm = new FormGroup({ firstName: new FormControl("", { @@ -38,6 +31,16 @@ export class AddressForm { validators: [Validators.required, Validators.pattern("^[0-9]{6}$")], }), }); + protected isEditing = signal(false); + protected address = signal(null); + + @Input() set initialData(address: AddressResponse) { + if (address) { + this.addressForm.patchValue(address); + this.address.set(address); + this.isEditing.set(true); + } + } submitForm() { if (this.addressForm.invalid) { @@ -46,11 +49,9 @@ export class AddressForm { } const emittedData = this.addressForm.getRawValue() as AddressRequest; - this.addressForm.reset(); if (this.isEditing()) { const mergedData = { ...this.address(), ...emittedData }; - console.log(mergedData); this.updateAddress.emit(mergedData as unknown as AddressResponse); } else { this.submitAddress.emit(emittedData); diff --git a/src/app/features/checkout/components/address-select/address-select.html b/src/app/features/checkout/components/address-select/address-select.html index fc22c74..286bc06 100644 --- a/src/app/features/checkout/components/address-select/address-select.html +++ b/src/app/features/checkout/components/address-select/address-select.html @@ -1,4 +1,11 @@ -@if (!isEditing()) { +@if (isEditing()) { + +} @else{

{{[address.firstName, address.lastName] | fullname}}

@@ -10,10 +17,4 @@
-} @else{ - } diff --git a/src/app/features/checkout/components/address-select/address-select.ts b/src/app/features/checkout/components/address-select/address-select.ts index 64cc7ea..f936082 100644 --- a/src/app/features/checkout/components/address-select/address-select.ts +++ b/src/app/features/checkout/components/address-select/address-select.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output, signal } from "@angular/core"; +import { Component, effect, EventEmitter, Input, input, Output, signal } from "@angular/core"; import { AddressResponse } from "@app/features/checkout/services/address-service"; import { FullnamePipe } from "@shared/pipes/fullname-pipe"; import { AddressForm } from "@app/features/checkout/components/address-form/address-form"; @@ -11,10 +11,25 @@ import { AddressForm } from "@app/features/checkout/components/address-form/addr }) export class AddressSelect { @Input() address!: AddressResponse; - @Output() addressUpdated: EventEmitter = new EventEmitter(); + @Output() addressUpdated = new EventEmitter(); + isProcessing = input(false); protected isEditing = signal(false); + // Track if THIS specific component triggered the API call + private isLocallySubmitted = false; + + constructor() { + effect(() => { + const processing = this.isProcessing(); + + if (!processing && this.isLocallySubmitted) { + this.isEditing.set(false); + this.isLocallySubmitted = false; + } + }); + } + editForm() { this.isEditing.set(true); } @@ -24,7 +39,7 @@ export class AddressSelect { } updateAddress(address: AddressResponse) { - this.isEditing.set(false); + this.isLocallySubmitted = true; this.addressUpdated.emit(address); } } diff --git a/src/app/features/checkout/payment/payment.css b/src/app/features/checkout/payment/payment.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/checkout/payment/payment.html b/src/app/features/checkout/payment/payment.html new file mode 100644 index 0000000..d9cf5ff --- /dev/null +++ b/src/app/features/checkout/payment/payment.html @@ -0,0 +1 @@ +

payment works!

diff --git a/src/app/features/checkout/payment/payment.spec.ts b/src/app/features/checkout/payment/payment.spec.ts new file mode 100644 index 0000000..f48e6e2 --- /dev/null +++ b/src/app/features/checkout/payment/payment.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { Payment } from "./payment"; + +describe("Payment", () => { + let component: Payment; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Payment], + }).compileComponents(); + + fixture = TestBed.createComponent(Payment); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/checkout/payment/payment.ts b/src/app/features/checkout/payment/payment.ts new file mode 100644 index 0000000..6f8e9a7 --- /dev/null +++ b/src/app/features/checkout/payment/payment.ts @@ -0,0 +1,9 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "app-payment", + imports: [], + templateUrl: "./payment.html", + styleUrl: "./payment.css", +}) +export class Payment {} diff --git a/src/app/features/checkout/services/address-service.ts b/src/app/features/checkout/services/address-service.ts index db6001e..2023741 100644 --- a/src/app/features/checkout/services/address-service.ts +++ b/src/app/features/checkout/services/address-service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core"; import { HttpClient } from "@angular/common/http"; import { API_URL } from "@core/tokens/api-url-tokens"; import { PaginatedResponse } from "@core/models/paginated.model"; +import { FormControl, Validators } from "@angular/forms"; export interface AddressRequest { firstName: string; @@ -16,13 +17,13 @@ export interface AddressResponse extends AddressRequest { id: number; } -@Injectable({ - providedIn: "root", -}) +@Injectable() export class AddressService { http = inject(HttpClient); apiUrl = inject(API_URL); + addressIdControl = new FormControl(null, Validators.required); + fetchAddresses(userId: number) { return this.http.get>( `${this.apiUrl}/user/${userId}/addresses`, diff --git a/src/app/features/checkout/services/order-service.spec.ts b/src/app/features/checkout/services/order-service.spec.ts new file mode 100644 index 0000000..6b09c5d --- /dev/null +++ b/src/app/features/checkout/services/order-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { OrderService } from "./order-service"; + +describe("OrderService", () => { + let service: OrderService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(OrderService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/checkout/services/order-service.ts b/src/app/features/checkout/services/order-service.ts new file mode 100644 index 0000000..0503fe6 --- /dev/null +++ b/src/app/features/checkout/services/order-service.ts @@ -0,0 +1,23 @@ +import { inject, Injectable } from "@angular/core"; +import { API_URL } from "@core/tokens/api-url-tokens"; +import { HttpClient } from "@angular/common/http"; +import { AuthService } from "@core/services/auth-service"; + +export interface OrderRequest { + addressId: number; + cartId: number; +} + +@Injectable({ + providedIn: "root", +}) +export class OrderService { + http = inject(HttpClient); + apiUrl = inject(API_URL); + private authService = inject(AuthService); + private user = this.authService.user; + + createOrder(data: OrderRequest) { + return this.http.post(`${this.apiUrl}/users/${this.user()?.id}/orders`, data); + } +} diff --git a/src/app/shared/components/cart/cart.html b/src/app/shared/components/cart/cart.html index 3aa4062..e23d592 100644 --- a/src/app/shared/components/cart/cart.html +++ b/src/app/shared/components/cart/cart.html @@ -25,9 +25,7 @@
  • - Proceed to checkout + Proceed to checkout
  • } diff --git a/src/app/shared/components/go-back/go-back.html b/src/app/shared/components/go-back/go-back.html index c21331c..4522e3f 100644 --- a/src/app/shared/components/go-back/go-back.html +++ b/src/app/shared/components/go-back/go-back.html @@ -1,4 +1,4 @@ - +

    {{ text }}

    diff --git a/src/app/shared/components/loading-spinner/loading-spinner.spec.ts b/src/app/shared/components/loading-spinner/loading-spinner.spec.ts new file mode 100644 index 0000000..26dc9f1 --- /dev/null +++ b/src/app/shared/components/loading-spinner/loading-spinner.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { LoadingSpinner } from "./loading-spinner"; + +describe("LoadingSpinner", () => { + let component: LoadingSpinner; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoadingSpinner], + }).compileComponents(); + + fixture = TestBed.createComponent(LoadingSpinner); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/loading-spinner/loading-spinner.ts b/src/app/shared/components/loading-spinner/loading-spinner.ts new file mode 100644 index 0000000..6768855 --- /dev/null +++ b/src/app/shared/components/loading-spinner/loading-spinner.ts @@ -0,0 +1,40 @@ +import { Component, input } from "@angular/core"; + +@Component({ + selector: "app-loading-spinner", + standalone: true, + imports: [], + template: ` + @if (isLoading()) { +
    + + + + +
    + } @else { + + } + `, +}) +export class LoadingSpinner { + isLoading = input(false); +} diff --git a/src/app/shared/components/stepper/stepper.html b/src/app/shared/components/stepper/stepper.html index 91b4b16..2ac2146 100644 --- a/src/app/shared/components/stepper/stepper.html +++ b/src/app/shared/components/stepper/stepper.html @@ -2,7 +2,9 @@ @for (step of steps; track step) {
  • - + @if (!$last) { diff --git a/src/app/shared/components/stepper/stepper.ts b/src/app/shared/components/stepper/stepper.ts index af56031..8f55cb6 100644 --- a/src/app/shared/components/stepper/stepper.ts +++ b/src/app/shared/components/stepper/stepper.ts @@ -1,13 +1,15 @@ import { Component, Input } from "@angular/core"; import { Check, Circle, LucideAngularModule } from "lucide-angular"; +import { RouterLink } from "@angular/router"; export type Steps = { label: string; + route: string; }; @Component({ selector: "app-stepper", - imports: [LucideAngularModule], + imports: [LucideAngularModule, RouterLink], templateUrl: "./stepper.html", styleUrl: "./stepper.css", })