diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c25961e..f6736f3 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -7,7 +7,6 @@ export const routes: Routes = [ { path: "", component: Home, - canActivate: [authGuard], }, { path: "", diff --git a/src/app/core/layouts/header/header.html b/src/app/core/layouts/header/header.html index 508f08a..70f585c 100644 --- a/src/app/core/layouts/header/header.html +++ b/src/app/core/layouts/header/header.html @@ -3,7 +3,7 @@ class="bg-gray-50 wrapper py-4 flex gap-x-5 sm:gap-x-10 items-center shadow-lg shadow-gray-400/20" >
- eKart + eKart
@@ -26,23 +26,37 @@ > -
+ + + diff --git a/src/app/core/layouts/header/header.ts b/src/app/core/layouts/header/header.ts index 3ec425e..8c4ad69 100644 --- a/src/app/core/layouts/header/header.ts +++ b/src/app/core/layouts/header/header.ts @@ -2,10 +2,15 @@ import { Component, inject } from "@angular/core"; import { LucideAngularModule, Search, ShoppingCart, User } from "lucide-angular"; import { RouterLink } from "@angular/router"; import { AuthService, AuthState } from "../../../features/auth/services/auth-service"; +import { CartService } from "@app/core/services/cart-service"; +import { Cart } from "@app/shared/components/cart/cart"; +import { CartModel } from "@app/core/models/cart.model"; +import { map } from "rxjs"; +import { AsyncPipe } from "@angular/common"; @Component({ selector: "app-header", - imports: [LucideAngularModule, RouterLink], + imports: [LucideAngularModule, RouterLink, Cart, AsyncPipe], templateUrl: "./header.html", styleUrl: "./header.css", }) @@ -14,5 +19,9 @@ export class Header { readonly CartIcon = ShoppingCart; readonly SearchIcon = Search; readonly authService = inject(AuthService); + readonly cartService = inject(CartService); protected readonly AuthState = AuthState; + + cartItem$ = this.cartService.cartItem$; + cartItemCount = this.cartItem$.pipe(map((cart: CartModel) => cart.itemsCount ?? 0)); } diff --git a/src/app/core/models/cart.model.ts b/src/app/core/models/cart.model.ts new file mode 100644 index 0000000..dbccc0b --- /dev/null +++ b/src/app/core/models/cart.model.ts @@ -0,0 +1,20 @@ +export interface CartItemModel { + id: number; + title: string; + quantity: number; + price: number; + subtotal: number; + image: string; +} + +export interface CartModel { + id: number; + itemsCount: number; + totalPrice: number; + items: CartItemModel[]; +} + +export interface CartItemRequest { + productId: number; + quantity: number; +} diff --git a/src/app/core/services/cart-service.spec.ts b/src/app/core/services/cart-service.spec.ts new file mode 100644 index 0000000..fa06308 --- /dev/null +++ b/src/app/core/services/cart-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { CartService } from "./cart-service"; + +describe("CartService", () => { + let service: CartService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CartService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/cart-service.ts b/src/app/core/services/cart-service.ts new file mode 100644 index 0000000..f58d354 --- /dev/null +++ b/src/app/core/services/cart-service.ts @@ -0,0 +1,61 @@ +import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { effect, inject, Injectable, signal } from "@angular/core"; +import { API_URL } from "../tokens/api-url-tokens"; +import { CartItemModel, CartItemRequest, CartModel } from "../models/cart.model"; +import { AuthService, AuthState } from "@app/features/auth/services/auth-service"; +import { Cart } from "@app/shared/components/cart/cart"; +import { BehaviorSubject, tap } from "rxjs"; + +@Injectable({ + providedIn: "root", +}) +export class CartService { + private authService = inject(AuthService); + // dependencies + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + private _cartItem = new BehaviorSubject({} as CartModel); + + cartItem$ = this._cartItem.asObservable(); + + constructor() { + effect(() => { + if (this.authService.isAuthenticated()) { + this.fetchCart(); + } else { + this._cartItem.next({} as CartModel); + } + }); + } + + fetchCart() { + return this.http.get(this.apiUrl + "/cart").subscribe({ + next: (data) => this._cartItem.next(data), + error: (error: HttpErrorResponse) => { + if (error.status === 401) { + this.authService.purgeAuth(); + } + // show an error in toast + }, + }); + } + + addToCart(data: CartItemRequest) { + return this.http + .post(this.apiUrl + "/cart", data) + .pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart))); + } + + updateCart(data: CartItemRequest) { + return this.http + .patch(this.apiUrl + "/cart", data) + .pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart))); + } + + removeFromCart(productId: number) { + return this.http + .delete(this.apiUrl + "/cart", { body: { productId: productId } }) + .pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart))); + } +} diff --git a/src/app/features/auth/services/auth-service.ts b/src/app/features/auth/services/auth-service.ts index d973e8c..ca38560 100644 --- a/src/app/features/auth/services/auth-service.ts +++ b/src/app/features/auth/services/auth-service.ts @@ -104,7 +104,7 @@ export class AuthService { this.authState.set(AuthState.Authenticated); } - private purgeAuth() { + purgeAuth() { this.localStorage.removeItem(this.userKey); this.user.set(null); this.authState.set(AuthState.Unauthenticated); diff --git a/src/app/features/product/components/product-card/product-card.html b/src/app/features/product/components/product-card/product-card.html index 73f1c6f..c4e3d1e 100644 --- a/src/app/features/product/components/product-card/product-card.html +++ b/src/app/features/product/components/product-card/product-card.html @@ -5,15 +5,25 @@ class="absolute top-5 right-5" /> -
+
-

{{ product.title }}

-

⭐4.5

-

- Price: - {{ product.actualPrice }} - {{ product.listPrice }}/- -

+
+

+ {{ product.title }} +

+

⭐4.5

+
+
+
+

Rs. {{ product.listPrice }}

+

Rs. {{ product.actualPrice }}

+
+
+ +
+
diff --git a/src/app/features/product/components/product-card/product-card.ts b/src/app/features/product/components/product-card/product-card.ts index 4f3e754..c137804 100644 --- a/src/app/features/product/components/product-card/product-card.ts +++ b/src/app/features/product/components/product-card/product-card.ts @@ -1,20 +1,29 @@ import { Component, inject, Input } from "@angular/core"; import { ProductModel } from "../../../../core/models/product.model"; import { Router } from "@angular/router"; -import { FavoriteButton } from "../../../../src/app/shared/components/favorite-button/favorite-button"; +import { FavoriteButton } from "../../../../shared/components/favorite-button/favorite-button"; +import { LucideAngularModule, ShoppingCart } from "lucide-angular"; +import { CartService } from "@app/core/services/cart-service"; @Component({ selector: "app-product-card", standalone: true, - imports: [FavoriteButton], + imports: [FavoriteButton, LucideAngularModule], templateUrl: "./product-card.html", }) export class ProductCard { readonly router = inject(Router); + readonly ShoppingCartIcon = ShoppingCart; + readonly cartService = inject(CartService); @Input() product!: ProductModel; goToProductDetails() { this.router.navigate(["/products", this.product.slug]); } + + addToCart(event: Event) { + event.stopPropagation(); + this.cartService.addToCart({ productId: this.product.id, quantity: 1 }).subscribe(); + } } diff --git a/src/app/features/product/product.html b/src/app/features/product/product.html index 67121c9..c94f682 100644 --- a/src/app/features/product/product.html +++ b/src/app/features/product/product.html @@ -4,7 +4,7 @@

Our Product

-
+
@for (product of products(); track product) { } diff --git a/src/app/features/product/show-product/show-product.html b/src/app/features/product/show-product/show-product.html index 8ccf6be..6809b38 100644 --- a/src/app/features/product/show-product/show-product.html +++ b/src/app/features/product/show-product/show-product.html @@ -23,15 +23,15 @@ /> @@ -90,28 +90,16 @@
-
- Rs.{{ product()?.actualPrice }} - Rs.{{ product()?.listPrice }} - +
+

+ Rs.{{ product()?.listPrice }} +

+

Rs.{{ product()?.actualPrice }}

-
- - - -
-
- +
diff --git a/src/app/features/product/show-product/show-product.ts b/src/app/features/product/show-product/show-product.ts index dd02023..c923c86 100644 --- a/src/app/features/product/show-product/show-product.ts +++ b/src/app/features/product/show-product/show-product.ts @@ -2,7 +2,8 @@ import { Component, inject, Input, signal, WritableSignal } from "@angular/core" import { ProductModel } from "../../../core/models/product.model"; import { ProductService } from "../services/product-service"; import { LucideAngularModule, Heart, ArrowRight, ArrowLeft } from "lucide-angular"; -import { FavoriteButton } from "../../../src/app/shared/components/favorite-button/favorite-button"; +import { FavoriteButton } from "../../../shared/components/favorite-button/favorite-button"; +import { CartService } from "@app/core/services/cart-service"; @Component({ selector: "app-show-product", @@ -17,6 +18,7 @@ export class ShowProduct { ArrowRightIcon = ArrowRight; ArrowLeftIcon = ArrowLeft; productService = inject(ProductService); + cartService = inject(CartService); product = signal(null); activeImageIndex: WritableSignal = signal(0); totalImageCount: number = 0; @@ -29,9 +31,14 @@ export class ShowProduct { }); } + addToCart() { + this.cartService.addToCart({ productId: this.product()!.id, quantity: 1 }).subscribe(); + } + nextImage() { this.activeImageIndex.update((index) => (index + 1) % this.totalImageCount); } + prevImage() { this.activeImageIndex.update( (index) => diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.css b/src/app/shared/components/cart-item/cart-item.css similarity index 100% rename from src/app/src/app/shared/components/favorite-button/favorite-button.css rename to src/app/shared/components/cart-item/cart-item.css diff --git a/src/app/shared/components/cart-item/cart-item.html b/src/app/shared/components/cart-item/cart-item.html new file mode 100644 index 0000000..d29f4e0 --- /dev/null +++ b/src/app/shared/components/cart-item/cart-item.html @@ -0,0 +1,26 @@ +
  • +
    +
    + +
    +
    +

    {{ cartItem.title }}

    +
    +

    Rs. {{ cartItem.price }} x {{ cartItem.quantity }}

    +
    +
    +
    +
    +

    Rs. {{ cartItem.subtotal }}

    + +
    +
    + +

    {{ cartItem.quantity }}

    + +
    +
    +
    +
  • diff --git a/src/app/shared/components/cart-item/cart-item.spec.ts b/src/app/shared/components/cart-item/cart-item.spec.ts new file mode 100644 index 0000000..f5e371e --- /dev/null +++ b/src/app/shared/components/cart-item/cart-item.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { CartItem } from "./cart-item"; + +describe("CartItem", () => { + let component: CartItem; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CartItem], + }).compileComponents(); + + fixture = TestBed.createComponent(CartItem); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/cart-item/cart-item.ts b/src/app/shared/components/cart-item/cart-item.ts new file mode 100644 index 0000000..1a398be --- /dev/null +++ b/src/app/shared/components/cart-item/cart-item.ts @@ -0,0 +1,34 @@ +import { Component, EventEmitter, Input, Output, signal } from "@angular/core"; +import { CartItemModel, CartItemRequest } from "@app/core/models/cart.model"; +import { LucideAngularModule, Trash } from "lucide-angular"; + +@Component({ + selector: "app-cart-item", + imports: [LucideAngularModule], + templateUrl: "./cart-item.html", + styleUrl: "./cart-item.css", +}) +export class CartItem { + @Input() cartItem!: CartItemModel; + @Output() qtyChangeEvent = new EventEmitter(); + @Output() productDeleteEvent = new EventEmitter(); + TrashIcon = Trash; + + incrementQty() { + if (this.cartItem.quantity < 10) { + this.cartItem.quantity += 1; + this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity }); + } + } + + decrementQty() { + if (this.cartItem.quantity > 1) { + this.cartItem.quantity -= 1; + this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity }); + } + } + + removeProduct() { + this.productDeleteEvent.emit(this.cartItem.id); + } +} diff --git a/src/app/shared/components/cart/cart.css b/src/app/shared/components/cart/cart.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/cart/cart.html b/src/app/shared/components/cart/cart.html new file mode 100644 index 0000000..df526c5 --- /dev/null +++ b/src/app/shared/components/cart/cart.html @@ -0,0 +1,26 @@ +
      + @if (authService.authState() === AuthState.Unauthenticated) { +
    • Login to access cart
    • + } @else if (authService.authState() === AuthState.Loading) { +
    • Loading
    • + } @else { +
        + @for (item of cart.items; track item.id) { + + } +
      + +
      +

      Total

      +

      Rs. {{ cart.totalPrice }}

      +
      + } +
    diff --git a/src/app/shared/components/cart/cart.spec.ts b/src/app/shared/components/cart/cart.spec.ts new file mode 100644 index 0000000..0880605 --- /dev/null +++ b/src/app/shared/components/cart/cart.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { Cart } from "./cart"; + +describe("Cart", () => { + let component: Cart; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Cart], + }).compileComponents(); + + fixture = TestBed.createComponent(Cart); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/cart/cart.ts b/src/app/shared/components/cart/cart.ts new file mode 100644 index 0000000..121b0ef --- /dev/null +++ b/src/app/shared/components/cart/cart.ts @@ -0,0 +1,40 @@ +import { Component, computed, inject, Input, signal } from "@angular/core"; +import { CartItemModel, CartItemRequest, CartModel } from "@app/core/models/cart.model"; +import { CartItem } from "../cart-item/cart-item"; +import { AuthService, AuthState } from "@app/features/auth/services/auth-service"; +import { CartService } from "@app/core/services/cart-service"; +import { finalize, tap } from "rxjs"; + +@Component({ + selector: "app-cart", + imports: [CartItem], + templateUrl: "./cart.html", + styleUrl: "./cart.css", +}) +export class Cart { + @Input() cart!: CartModel; + + isLoading = signal(false); + + protected readonly authService = inject(AuthService); + protected readonly cartService = inject(CartService); + protected readonly AuthState = AuthState; + + updateProductQty(cartItem: CartItemRequest) { + this.isLoading.set(true); + + this.cartService + .updateCart(cartItem) + .pipe(finalize(() => this.isLoading.set(false))) + .subscribe(); + } + + removeProduct(productId: number) { + this.isLoading.set(true); + + this.cartService + .removeFromCart(productId) + .pipe(finalize(() => this.isLoading.set(false))) + .subscribe(); + } +} diff --git a/src/app/shared/components/favorite-button/favorite-button.css b/src/app/shared/components/favorite-button/favorite-button.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.html b/src/app/shared/components/favorite-button/favorite-button.html similarity index 100% rename from src/app/src/app/shared/components/favorite-button/favorite-button.html rename to src/app/shared/components/favorite-button/favorite-button.html diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.spec.ts b/src/app/shared/components/favorite-button/favorite-button.spec.ts similarity index 100% rename from src/app/src/app/shared/components/favorite-button/favorite-button.spec.ts rename to src/app/shared/components/favorite-button/favorite-button.spec.ts diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.ts b/src/app/shared/components/favorite-button/favorite-button.ts similarity index 91% rename from src/app/src/app/shared/components/favorite-button/favorite-button.ts rename to src/app/shared/components/favorite-button/favorite-button.ts index 733a074..71f95bd 100644 --- a/src/app/src/app/shared/components/favorite-button/favorite-button.ts +++ b/src/app/shared/components/favorite-button/favorite-button.ts @@ -1,6 +1,6 @@ import { Component, inject, Input } from "@angular/core"; import { HeartIcon, LucideAngularModule } from "lucide-angular"; -import { FavoriteService } from "../../../../../core/services/favorite-service"; +import { FavoriteService } from "../../../core/services/favorite-service"; @Component({ selector: "app-favorite-button", diff --git a/src/styles.css b/src/styles.css index 4ecbfd4..0f9fd15 100644 --- a/src/styles.css +++ b/src/styles.css @@ -25,15 +25,15 @@ body { } .btn-ghost { - @apply text-gray-600 border border-gray-300 hover:bg-gray-800 hover:text-gray-200; + @apply text-gray-600 border border-b-3 border-gray-300 hover:bg-gray-200/70 hover:text-gray-700; } .btn-black { - @apply text-gray-100 bg-gray-800 border border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400; + @apply text-gray-100 bg-gray-800 border border-b-3 border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400; } .btn-primary { - @apply text-gray-100 bg-blue-700 border border-blue-800 hover:bg-blue-200 hover:text-blue-800 hover:border-blue-400; + @apply text-blue-100 bg-blue-600 border border-b-3 border-blue-900 hover:bg-blue-700; } .card { @@ -70,7 +70,7 @@ body { } .dropdown li { - @apply rounded-lg hover:bg-linear-to-r hover:from-teal-300 hover:to-transparent px-5 py-1; + @apply rounded-lg hover:bg-linear-to-r hover:bg-gray-100 px-5 py-1; } h1, diff --git a/tsconfig.json b/tsconfig.json index 2ab7442..8929661 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,14 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "baseUrl": "./src", // Paths are resolved relative to the baseUrl + "paths": { + "@app/*": ["app/*"], + "@shared/*": ["app/shared/*"], + "@core/*": ["app/core/*"], + "@env/*": ["environments/*"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false,