diff --git a/.rgignore b/.rgignore new file mode 100644 index 0000000..21df648 --- /dev/null +++ b/.rgignore @@ -0,0 +1 @@ +backend/ diff --git a/angular.json b/angular.json index 77a0468..2131bae 100644 --- a/angular.json +++ b/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 17362d3..0c81531 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from "@angular/core"; -import { provideRouter } from "@angular/router"; +import { provideRouter, withComponentInputBinding } from "@angular/router"; import { routes } from "./app.routes"; import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http"; @@ -8,7 +8,7 @@ import { csrfInterceptor } from "./core/interceptors/csrf-interceptor"; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), - provideRouter(routes), + provideRouter(routes, withComponentInputBinding()), provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])), ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index fe77d90..c25961e 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -18,6 +18,6 @@ export const routes: Routes = [ loadChildren: () => import("./features/product/product.routes").then((routes) => routes.productRoutes), canActivate: [authGuard, roleGuard], - data: { roles: ["broker"] }, + data: { roles: ["admin", "broker"] }, }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index 780b269..9eb83b9 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,6 +1,6 @@ import { Component, signal } from "@angular/core"; import { RouterOutlet } from "@angular/router"; -import { Products } from "./features/home/products/products"; +import { Product } from "./features/product/product"; import { Footer } from "./core/layouts/footer/footer"; import { Header } from "./core/layouts/header/header"; diff --git a/src/app/core/layouts/footer/footer.html b/src/app/core/layouts/footer/footer.html index 068c3d5..aa791bb 100644 --- a/src/app/core/layouts/footer/footer.html +++ b/src/app/core/layouts/footer/footer.html @@ -18,7 +18,7 @@ diff --git a/src/app/core/models/paginated.model.ts b/src/app/core/models/paginated.model.ts new file mode 100644 index 0000000..9dfb658 --- /dev/null +++ b/src/app/core/models/paginated.model.ts @@ -0,0 +1,25 @@ +export interface PaginatedResponse { + data: T[]; + links: { + next: string | null; + prev: string | null; + last: string | null; + first: string | null; + }; + meta: { + total: number; + per_page: number; + current_page: number; + last_page: number; + from: number; + to: number; + links: links[]; + }; +} + +interface links { + url: string | null; + label: string; + active: boolean; + page: number | null; +} diff --git a/src/app/core/models/product.model.ts b/src/app/core/models/product.model.ts new file mode 100644 index 0000000..85160b4 --- /dev/null +++ b/src/app/core/models/product.model.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from "./paginated.model"; +import { Category } from "../../features/product/services/category-service"; + +export interface ProductModel { + id: number; + title: string; + slug: string; + description: string; + actualPrice: number; + listPrice: number; + category: Category; + productImages: string[]; + updatedAt: string; + isFavorite: boolean; +} + +export interface ProductCollection extends PaginatedResponse {} diff --git a/src/app/core/services/favorite-service.spec.ts b/src/app/core/services/favorite-service.spec.ts new file mode 100644 index 0000000..6e7bc54 --- /dev/null +++ b/src/app/core/services/favorite-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { FavoriteService } from "./favorite-service"; + +describe("FavoriteService", () => { + let service: FavoriteService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FavoriteService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/favorite-service.ts b/src/app/core/services/favorite-service.ts new file mode 100644 index 0000000..fdde44e --- /dev/null +++ b/src/app/core/services/favorite-service.ts @@ -0,0 +1,20 @@ +import { HttpClient } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { API_URL } from "../tokens/api-url-tokens"; + +export interface FavoriteResponse { + message: string; + isFavorite: boolean; +} + +@Injectable({ + providedIn: "root", +}) +export class FavoriteService { + http = inject(HttpClient); + apiUrl = inject(API_URL); + + toggle(productId: number) { + return this.http.post(`${this.apiUrl}/products/${productId}/favorite`, {}); + } +} diff --git a/src/app/features/home/home.ts b/src/app/features/home/home.ts index cbc2461..f5e5889 100644 --- a/src/app/features/home/home.ts +++ b/src/app/features/home/home.ts @@ -1,9 +1,9 @@ import { Component } from "@angular/core"; -import { Products } from "./products/products"; +import { Product } from "../product/product"; @Component({ selector: "app-home", - imports: [Products], + imports: [Product], templateUrl: "./home.html", styleUrl: "./home.css", }) diff --git a/src/app/features/home/products/componets/product-card/product-card.html b/src/app/features/home/products/componets/product-card/product-card.html deleted file mode 100644 index 7e8b73d..0000000 --- a/src/app/features/home/products/componets/product-card/product-card.html +++ /dev/null @@ -1,17 +0,0 @@ -
- - - - -
- -
- -

Product Name

-

⭐4.5

-

Price: 4999/-

-
diff --git a/src/app/features/home/products/componets/product-card/product-card.ts b/src/app/features/home/products/componets/product-card/product-card.ts deleted file mode 100644 index b609c53..0000000 --- a/src/app/features/home/products/componets/product-card/product-card.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from "@angular/core"; -import { LucideAngularModule, Heart } from "lucide-angular"; - -@Component({ - selector: "app-product-card", - standalone: true, - imports: [LucideAngularModule], - templateUrl: "./product-card.html", -}) -export class ProductCard { - readonly HeartIcon = Heart; -} diff --git a/src/app/features/home/products/products.html b/src/app/features/home/products/products.html deleted file mode 100644 index c9a866f..0000000 --- a/src/app/features/home/products/products.html +++ /dev/null @@ -1,14 +0,0 @@ -
-

Our Products

-
- -
- - - - - - -
diff --git a/src/app/features/home/products/products.ts b/src/app/features/home/products/products.ts deleted file mode 100644 index ef2c403..0000000 --- a/src/app/features/home/products/products.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from "@angular/core"; -import { ProductCard } from "./componets/product-card/product-card"; - -@Component({ - selector: "app-products", - imports: [ProductCard], - templateUrl: "./products.html", -}) -export class Products {} diff --git a/src/app/features/product/add-product/add-product.html b/src/app/features/product/add-product/add-product.html index fc29349..a7afa68 100644 --- a/src/app/features/product/add-product/add-product.html +++ b/src/app/features/product/add-product/add-product.html @@ -62,8 +62,8 @@ Select category diff --git a/src/app/features/product/components/product-card/product-card.html b/src/app/features/product/components/product-card/product-card.html new file mode 100644 index 0000000..73f1c6f --- /dev/null +++ b/src/app/features/product/components/product-card/product-card.html @@ -0,0 +1,19 @@ +
+ + +
+ +
+ +

{{ product.title }}

+

⭐4.5

+

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

+
diff --git a/src/app/features/home/products/componets/product-card/product-card.spec.ts b/src/app/features/product/components/product-card/product-card.spec.ts similarity index 100% rename from src/app/features/home/products/componets/product-card/product-card.spec.ts rename to src/app/features/product/components/product-card/product-card.spec.ts diff --git a/src/app/features/product/components/product-card/product-card.ts b/src/app/features/product/components/product-card/product-card.ts new file mode 100644 index 0000000..4f3e754 --- /dev/null +++ b/src/app/features/product/components/product-card/product-card.ts @@ -0,0 +1,20 @@ +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"; + +@Component({ + selector: "app-product-card", + standalone: true, + imports: [FavoriteButton], + templateUrl: "./product-card.html", +}) +export class ProductCard { + readonly router = inject(Router); + + @Input() product!: ProductModel; + + goToProductDetails() { + this.router.navigate(["/products", this.product.slug]); + } +} diff --git a/src/app/features/product/product.html b/src/app/features/product/product.html index 772b623..67121c9 100644 --- a/src/app/features/product/product.html +++ b/src/app/features/product/product.html @@ -1 +1,11 @@ -

product works!

+
+

Our Product

+
+ +
+ @for (product of products(); track product) { + + } +
diff --git a/src/app/features/product/product.routes.ts b/src/app/features/product/product.routes.ts index c11716c..bd85c26 100644 --- a/src/app/features/product/product.routes.ts +++ b/src/app/features/product/product.routes.ts @@ -1,9 +1,14 @@ import { Routes } from "@angular/router"; import { AddProduct } from "./add-product/add-product"; +import { ShowProduct } from "./show-product/show-product"; export const productRoutes: Routes = [ { path: "create", component: AddProduct, }, + { + path: ":slug", + component: ShowProduct, + }, ]; diff --git a/src/app/features/product/product.ts b/src/app/features/product/product.ts index 9c169a0..aade0bf 100644 --- a/src/app/features/product/product.ts +++ b/src/app/features/product/product.ts @@ -1,9 +1,22 @@ -import { Component } from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; +import { ProductCard } from "./components/product-card/product-card"; +import { HttpClient } from "@angular/common/http"; +import { API_URL } from "../../core/tokens/api-url-tokens"; +import { ProductModel } from "../../core/models/product.model"; +import { ProductService } from "./services/product-service"; @Component({ - selector: "app-product", - imports: [], + selector: "app-products", + imports: [ProductCard], templateUrl: "./product.html", - styleUrl: "./product.css", }) -export class Product {} +export class Product { + productService = inject(ProductService); + products = signal([]); + + ngOnInit() { + this.productService.getProducts().subscribe((data) => { + this.products.set(data.data); + }); + } +} diff --git a/src/app/features/product/services/category-service.ts b/src/app/features/product/services/category-service.ts index 02319a8..7ffc3de 100644 --- a/src/app/features/product/services/category-service.ts +++ b/src/app/features/product/services/category-service.ts @@ -3,7 +3,7 @@ import { HttpClient } from "@angular/common/http"; import { API_URL } from "../../../core/tokens/api-url-tokens"; export interface Category { - id: number; + id?: number; name: string; slug: string; } diff --git a/src/app/features/product/services/product-service.spec.ts b/src/app/features/product/services/product-service.spec.ts new file mode 100644 index 0000000..dae1d10 --- /dev/null +++ b/src/app/features/product/services/product-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { ProductService } from "./product-service"; + +describe("ProductService", () => { + let service: ProductService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ProductService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/product/services/product-service.ts b/src/app/features/product/services/product-service.ts new file mode 100644 index 0000000..9613f86 --- /dev/null +++ b/src/app/features/product/services/product-service.ts @@ -0,0 +1,20 @@ +import { inject, Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { API_URL } from "../../../core/tokens/api-url-tokens"; +import { ProductCollection, ProductModel } from "../../../core/models/product.model"; + +@Injectable({ + providedIn: "root", +}) +export class ProductService { + http = inject(HttpClient); + apiUrl = inject(API_URL); + + getProducts() { + return this.http.get(`${this.apiUrl}/products`); + } + + getProduct(slug: string) { + return this.http.get<{ data: ProductModel }>(`${this.apiUrl}/products/${slug}`); + } +} diff --git a/src/app/features/product/product.css b/src/app/features/product/show-product/show-product.css similarity index 100% rename from src/app/features/product/product.css rename to src/app/features/product/show-product/show-product.css diff --git a/src/app/features/product/show-product/show-product.html b/src/app/features/product/show-product/show-product.html new file mode 100644 index 0000000..cd333a5 --- /dev/null +++ b/src/app/features/product/show-product/show-product.html @@ -0,0 +1,132 @@ +
+
+ + +
+
+
+ Product Image + + + +
+ +
+ @for (image of product()?.productImages; track image) { + + + } +
+
+ +
+
+

{{ product()?.title }}

+ + + +
+
+ +
+ 4.8 + + 112 Reviews +
+
+
+

Product Description

+

{{ product()?.description }}

+
+ +
+

Customer Reviews

+
    +
  • + "Rich in condensed nutrients using daily." + +
  • +
  • "Seedless & Eco-Peel it"
  • +
  • + "Longer Shelf Life has encounant" +
  • +
+
+
+ +
+
+
+
+ Rs.{{ product()?.actualPrice }} + Rs.{{ product()?.listPrice }} + +
+ +
+ +
+ + + +
+ +
+ + +
+
+
+
+
+
diff --git a/src/app/features/home/products/products.spec.ts b/src/app/features/product/show-product/show-product.spec.ts similarity index 58% rename from src/app/features/home/products/products.spec.ts rename to src/app/features/product/show-product/show-product.spec.ts index 0129469..31465ea 100644 --- a/src/app/features/home/products/products.spec.ts +++ b/src/app/features/product/show-product/show-product.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Products } from "./products"; +import { ShowProduct } from "./show-product"; -describe("Products", () => { - let component: Products; - let fixture: ComponentFixture; +describe("ShowProduct", () => { + let component: ShowProduct; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [Products], + imports: [ShowProduct], }).compileComponents(); - fixture = TestBed.createComponent(Products); + fixture = TestBed.createComponent(ShowProduct); component = fixture.componentInstance; await fixture.whenStable(); }); diff --git a/src/app/features/product/show-product/show-product.ts b/src/app/features/product/show-product/show-product.ts new file mode 100644 index 0000000..3581d38 --- /dev/null +++ b/src/app/features/product/show-product/show-product.ts @@ -0,0 +1,40 @@ +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"; + +@Component({ + selector: "app-show-product", + imports: [LucideAngularModule], + templateUrl: "./show-product.html", + styleUrl: "./show-product.css", +}) +export class ShowProduct { + @Input() slug?: string; + + HeartIcon = Heart; + ArrowRightIcon = ArrowRight; + ArrowLeftIcon = ArrowLeft; + productService = inject(ProductService); + product = signal(null); + activeImageIndex: WritableSignal = signal(0); + totalImageCount: number = 0; + + ngOnInit() { + if (!this.slug) return; + this.productService.getProduct(this.slug).subscribe((data) => { + this.product.set(data.data); + this.totalImageCount = this.product()?.productImages?.length || 0; + }); + } + + nextImage() { + this.activeImageIndex.update((index) => (index + 1) % this.totalImageCount); + } + prevImage() { + this.activeImageIndex.update( + (index) => + (index - 1 + this.product()?.productImages.length!) % this.product()?.productImages.length!, + ); + } +} diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.css b/src/app/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/src/app/shared/components/favorite-button/favorite-button.html new file mode 100644 index 0000000..1380952 --- /dev/null +++ b/src/app/src/app/shared/components/favorite-button/favorite-button.html @@ -0,0 +1,13 @@ + + diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.spec.ts b/src/app/src/app/shared/components/favorite-button/favorite-button.spec.ts new file mode 100644 index 0000000..227ca91 --- /dev/null +++ b/src/app/src/app/shared/components/favorite-button/favorite-button.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { FavoriteButton } from "./favorite-button"; + +describe("FavoriteButton", () => { + let component: FavoriteButton; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FavoriteButton], + }).compileComponents(); + + fixture = TestBed.createComponent(FavoriteButton); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/src/app/shared/components/favorite-button/favorite-button.ts b/src/app/src/app/shared/components/favorite-button/favorite-button.ts new file mode 100644 index 0000000..733a074 --- /dev/null +++ b/src/app/src/app/shared/components/favorite-button/favorite-button.ts @@ -0,0 +1,34 @@ +import { Component, inject, Input } from "@angular/core"; +import { HeartIcon, LucideAngularModule } from "lucide-angular"; +import { FavoriteService } from "../../../../../core/services/favorite-service"; + +@Component({ + selector: "app-favorite-button", + imports: [LucideAngularModule], + templateUrl: "./favorite-button.html", + styleUrl: "./favorite-button.css", +}) +export class FavoriteButton { + @Input({ required: true }) productId!: number; + @Input() isFavorite = false; + + favoriteService = inject(FavoriteService); + + HeartIcon = HeartIcon; + + toggleFavorite(event: Event) { + event.stopPropagation(); + this.isFavorite = !this.isFavorite; + + this.favoriteService.toggle(this.productId).subscribe({ + next: (response) => { + this.isFavorite = response.isFavorite; + }, + error: (err) => { + console.error(err); + // Revert the state incase of error + this.isFavorite = !this.isFavorite; + }, + }); + } +} diff --git a/src/styles.css b/src/styles.css index b38fa58..4ecbfd4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -32,6 +32,10 @@ body { @apply text-gray-100 bg-gray-800 border 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; +} + .card { @apply bg-gray-50 rounded-xl border border-gray-300 hover:border-gray-400 p-4 hover:shadow-xl transition-all duration-300 ease-in-out; }