diff --git a/src/app/app.config.ts b/src/app/app.config.ts index baca460..17362d3 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -3,12 +3,12 @@ import { provideRouter } from "@angular/router"; import { routes } from "./app.routes"; import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http"; -import { authInterceptor } from "./core/interceptors/auth-interceptor"; +import { csrfInterceptor } from "./core/interceptors/csrf-interceptor"; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(withFetch(), withInterceptors([authInterceptor])), + provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])), ], }; diff --git a/src/app/app.html b/src/app/app.html index 47869cd..b6a87ba 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,3 +1,7 @@ - - - +
+ +
+ +
+ +
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d948d53..fe77d90 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from "@angular/router"; import { Home } from "./features/home/home"; import { authGuard } from "./core/guards/auth-guard"; +import { roleGuard } from "./core/guards/role-guard"; export const routes: Routes = [ { @@ -12,4 +13,11 @@ export const routes: Routes = [ path: "", loadChildren: () => import("./features/auth/auth.routes").then((routes) => routes.AuthRoutes), }, + { + path: "products", + loadChildren: () => + import("./features/product/product.routes").then((routes) => routes.productRoutes), + canActivate: [authGuard, roleGuard], + data: { roles: ["broker"] }, + }, ]; diff --git a/src/app/core/guards/role-guard.spec.ts b/src/app/core/guards/role-guard.spec.ts new file mode 100644 index 0000000..61e5eb7 --- /dev/null +++ b/src/app/core/guards/role-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from "@angular/core/testing"; +import { CanActivateFn } from "@angular/router"; + +import { roleGuard } from "./role-guard"; + +describe("roleGuard", () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => roleGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it("should be created", () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/src/app/core/guards/role-guard.ts b/src/app/core/guards/role-guard.ts new file mode 100644 index 0000000..dfd7a3c --- /dev/null +++ b/src/app/core/guards/role-guard.ts @@ -0,0 +1,12 @@ +import { CanActivateFn } from "@angular/router"; +import { inject } from "@angular/core"; +import { AuthService } from "../../features/auth/services/auth-service"; + +export const roleGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + + // get role from route data passed in route config. + const roles = route.data["roles"] as string[]; + + return roles && authService.hasRoles(roles); +}; diff --git a/src/app/core/interceptors/auth-interceptor.spec.ts b/src/app/core/interceptors/csrf-interceptor.spec.ts similarity index 75% rename from src/app/core/interceptors/auth-interceptor.spec.ts rename to src/app/core/interceptors/csrf-interceptor.spec.ts index 854ad84..d2a210a 100644 --- a/src/app/core/interceptors/auth-interceptor.spec.ts +++ b/src/app/core/interceptors/csrf-interceptor.spec.ts @@ -1,11 +1,11 @@ import { TestBed } from "@angular/core/testing"; import { HttpInterceptorFn } from "@angular/common/http"; -import { authInterceptor } from "./auth-interceptor"; +import { csrfInterceptor } from "./csrf-interceptor"; describe("authInterceptor", () => { const interceptor: HttpInterceptorFn = (req, next) => - TestBed.runInInjectionContext(() => authInterceptor(req, next)); + TestBed.runInInjectionContext(() => csrfInterceptor(req, next)); beforeEach(() => { TestBed.configureTestingModule({}); diff --git a/src/app/core/interceptors/auth-interceptor.ts b/src/app/core/interceptors/csrf-interceptor.ts similarity index 90% rename from src/app/core/interceptors/auth-interceptor.ts rename to src/app/core/interceptors/csrf-interceptor.ts index 246a565..ba08a92 100644 --- a/src/app/core/interceptors/auth-interceptor.ts +++ b/src/app/core/interceptors/csrf-interceptor.ts @@ -1,6 +1,6 @@ import { HttpInterceptorFn } from "@angular/common/http"; -export const authInterceptor: HttpInterceptorFn = (req, next) => { +export const csrfInterceptor: HttpInterceptorFn = (req, next) => { const getCookie = (name: string): string | null => { const match = document.cookie.match(new RegExp("(^|;\\s*)(" + name + ")=([^;]*)")); return match ? decodeURIComponent(match[3]) : null; diff --git a/src/app/core/models/user.model.ts b/src/app/core/models/user.model.ts index aa61525..086b340 100644 --- a/src/app/core/models/user.model.ts +++ b/src/app/core/models/user.model.ts @@ -13,4 +13,5 @@ export interface User { email: string; mobileNumber: string; city: string; + role: string; } diff --git a/src/app/features/auth/services/auth-service.ts b/src/app/features/auth/services/auth-service.ts index 20f195f..d973e8c 100644 --- a/src/app/features/auth/services/auth-service.ts +++ b/src/app/features/auth/services/auth-service.ts @@ -27,6 +27,7 @@ export class AuthService { // User states readonly authState: WritableSignal; readonly user: WritableSignal; + readonly userRole: Signal; // Computed state for easy checking readonly isAuthenticated: Signal; @@ -45,6 +46,7 @@ export class AuthService { ); this.user = signal(cachedUser); this.isAuthenticated = computed(() => !!this.user()); + this.userRole = computed(() => this.user()?.role || null); } register(userRequest: RegisterUserRequest) { @@ -74,6 +76,16 @@ export class AuthService { ); } + /** + * Check if current user has the role. + * Mostly used in role guard. + */ + hasRoles(roles: string[]) { + const role = this.userRole(); + if (!role) return false; + return roles.includes(role); + } + logout() { return this.http.post(`${this.backendURL}/logout`, {}).pipe( tap({ diff --git a/src/app/features/product/add-product/add-product.css b/src/app/features/product/add-product/add-product.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/product/add-product/add-product.html b/src/app/features/product/add-product/add-product.html new file mode 100644 index 0000000..fc29349 --- /dev/null +++ b/src/app/features/product/add-product/add-product.html @@ -0,0 +1,157 @@ +
+ @if (successMessage()) { + + } + +

Add Product

+
+
+
+
+ Title + + +
+ +
+ Description + + +
+ +
+ Select category + + +
+
+
+
+
+ List Price + + + +
+ +
+ Actual Price + + + +
+
+ +
+ Image +
+ + + + + +
+
+ + + +
+
+ Product Preview +
+
+
+
+
+ +
+
+
+
diff --git a/src/app/features/product/add-product/add-product.spec.ts b/src/app/features/product/add-product/add-product.spec.ts new file mode 100644 index 0000000..6f894c3 --- /dev/null +++ b/src/app/features/product/add-product/add-product.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { AddProduct } from "./add-product"; + +describe("AddProduct", () => { + let component: AddProduct; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddProduct], + }).compileComponents(); + + fixture = TestBed.createComponent(AddProduct); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product/add-product/add-product.ts b/src/app/features/product/add-product/add-product.ts new file mode 100644 index 0000000..d52f521 --- /dev/null +++ b/src/app/features/product/add-product/add-product.ts @@ -0,0 +1,112 @@ +import { Component, ElementRef, inject, signal, ViewChild } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { ImageInput } from "../../../shared/components/image-input/image-input"; +import { HttpClient } from "@angular/common/http"; +import { API_URL } from "../../../core/tokens/api-url-tokens"; +import { Error } from "../../../shared/components/error/error"; +import { Category, CategoryService } from "../services/category-service"; +import { forkJoin, switchMap } from "rxjs"; + +export interface ImageSelection { + id: string; + url: string; + file: File; +} + +@Component({ + selector: "app-add-product", + imports: [ReactiveFormsModule, ImageInput, Error], + templateUrl: "./add-product.html", + styleUrl: "./add-product.css", +}) +export class AddProduct { + http = inject(HttpClient); + apiUrl = inject(API_URL); + categoryService = inject(CategoryService); + + @ViewChild("imageDialog") imageDialog!: ElementRef; + activeImage = signal(null); + selectedImages = signal>({}); + categories = signal([]); + successMessage = signal(null); + + productAddFrom = new FormGroup({ + title: new FormControl("", { + validators: [Validators.required, Validators.minLength(3)], + }), + description: new FormControl("", { + validators: [Validators.required, Validators.minLength(10)], + }), + actual_price: new FormControl(0, { validators: [Validators.required, Validators.min(0)] }), + list_price: new FormControl(0, { validators: [Validators.required, Validators.min(0)] }), + product_category_id: new FormControl("", { validators: [Validators.required] }), + }); + + ngOnInit() { + this.categoryService.getCategories().subscribe({ + next: (categories) => this.categories.set(categories), + error: (error) => console.log(error), + }); + } + + openPreview(image: ImageSelection) { + this.activeImage.set(image); + this.imageDialog.nativeElement.showModal(); + } + + confirmImage() { + // Add the current image to the selected images + const current = this.activeImage(); + if (current) { + this.selectedImages.update((images) => ({ ...images, [current.id]: current })); + } + this.closeDialog(); + } + + closeDialog() { + this.activeImage.set(null); + this.imageDialog.nativeElement.close(); + } + + submitProductForm() { + if (this.productAddFrom.invalid) { + this.productAddFrom.markAllAsTouched(); + return; + } + + this.http + .post<{ id: string | number }>(`${this.apiUrl}/products`, this.productAddFrom.value) + .pipe( + switchMap((response) => { + const productId = response.id; + const images = Object.values(this.selectedImages()); + + if (images.length === 0) { + return [response]; + } + + const uploadRequests = images.map((img) => this.uploadImage(img.file, productId)); + + return forkJoin(uploadRequests); + }), + ) + .subscribe({ + next: (results) => { + this.successMessage.set("Product and images uploaded successfully!"); + console.log("Product and all images uploaded successfully", results); + this.productAddFrom.reset(); + this.selectedImages.set({}); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (err) => console.error("Upload failed", err), + }); + } + + private uploadImage(file: File, productId: string | number) { + const formData = new FormData(); + formData.append("image", file); + formData.append("product_id", productId.toString()); + + return this.http.post(`${this.apiUrl}/upload/images`, formData); + } +} diff --git a/src/app/features/product/product.css b/src/app/features/product/product.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/features/product/product.html b/src/app/features/product/product.html new file mode 100644 index 0000000..772b623 --- /dev/null +++ b/src/app/features/product/product.html @@ -0,0 +1 @@ +

product works!

diff --git a/src/app/features/product/product.routes.ts b/src/app/features/product/product.routes.ts new file mode 100644 index 0000000..c11716c --- /dev/null +++ b/src/app/features/product/product.routes.ts @@ -0,0 +1,9 @@ +import { Routes } from "@angular/router"; +import { AddProduct } from "./add-product/add-product"; + +export const productRoutes: Routes = [ + { + path: "create", + component: AddProduct, + }, +]; diff --git a/src/app/features/product/product.spec.ts b/src/app/features/product/product.spec.ts new file mode 100644 index 0000000..8a5b60f --- /dev/null +++ b/src/app/features/product/product.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { Product } from "./product"; + +describe("Product", () => { + let component: Product; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Product], + }).compileComponents(); + + fixture = TestBed.createComponent(Product); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/product/product.ts b/src/app/features/product/product.ts new file mode 100644 index 0000000..9c169a0 --- /dev/null +++ b/src/app/features/product/product.ts @@ -0,0 +1,9 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "app-product", + imports: [], + templateUrl: "./product.html", + styleUrl: "./product.css", +}) +export class Product {} diff --git a/src/app/features/product/services/category-service.spec.ts b/src/app/features/product/services/category-service.spec.ts new file mode 100644 index 0000000..08b7367 --- /dev/null +++ b/src/app/features/product/services/category-service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { CategoryService } from "./category-service"; + +describe("CategoryService", () => { + let service: CategoryService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CategoryService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/features/product/services/category-service.ts b/src/app/features/product/services/category-service.ts new file mode 100644 index 0000000..02319a8 --- /dev/null +++ b/src/app/features/product/services/category-service.ts @@ -0,0 +1,21 @@ +import { inject, Injectable } from "@angular/core"; +import { HttpClient } from "@angular/common/http"; +import { API_URL } from "../../../core/tokens/api-url-tokens"; + +export interface Category { + id: number; + name: string; + slug: string; +} + +@Injectable({ + providedIn: "root", +}) +export class CategoryService { + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + getCategories() { + return this.http.get(`${this.apiUrl}/categories`); + } +} diff --git a/src/app/shared/components/error/error.ts b/src/app/shared/components/error/error.ts index c447a09..d07f331 100644 --- a/src/app/shared/components/error/error.ts +++ b/src/app/shared/components/error/error.ts @@ -6,11 +6,13 @@ import { UpperCaseFirstPipe } from "../../pipes/upper-case-first-pipe"; selector: "app-error", imports: [UpperCaseFirstPipe], template: ` - @if (this.control && this.control.touched) { - @for (error of getErrorMessages(); track error) { -

{{ error | upperCaseFirst }}

+
+ @if (this.control && this.control.touched) { + @for (error of getErrorMessages(); track error) { +

{{ error | upperCaseFirst }}

+ } } - } +
`, }) export class Error { diff --git a/src/app/shared/components/image-input/image-input.css b/src/app/shared/components/image-input/image-input.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/shared/components/image-input/image-input.html b/src/app/shared/components/image-input/image-input.html new file mode 100644 index 0000000..9992e07 --- /dev/null +++ b/src/app/shared/components/image-input/image-input.html @@ -0,0 +1,29 @@ +
+ @if (bgImageUrl) { + + } @else { + + } + +
diff --git a/src/app/shared/components/image-input/image-input.spec.ts b/src/app/shared/components/image-input/image-input.spec.ts new file mode 100644 index 0000000..14e3aec --- /dev/null +++ b/src/app/shared/components/image-input/image-input.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ImageInput } from "./image-input"; + +describe("ImageInput", () => { + let component: ImageInput; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ImageInput], + }).compileComponents(); + + fixture = TestBed.createComponent(ImageInput); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/image-input/image-input.ts b/src/app/shared/components/image-input/image-input.ts new file mode 100644 index 0000000..027cab3 --- /dev/null +++ b/src/app/shared/components/image-input/image-input.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Camera, LucideAngularModule } from "lucide-angular"; +import { ImageSelection } from "../../../features/product/add-product/add-product"; + +@Component({ + selector: "app-image-input", + imports: [LucideAngularModule], + templateUrl: "./image-input.html", + styleUrl: "./image-input.css", +}) +export class ImageInput { + cameraIcon = Camera; + + @Output() imageSelected = new EventEmitter(); + @Input() id!: string; + @Input() bgImageUrl: string | undefined; + + handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files[0]) { + const file = input.files[0]; + const url = URL.createObjectURL(file); + this.imageSelected.emit({ id: this.id, url: url, file: file }); + } + } +} diff --git a/src/styles.css b/src/styles.css index 9123e1d..b38fa58 100644 --- a/src/styles.css +++ b/src/styles.css @@ -13,7 +13,7 @@ } body { - @apply bg-gray-100; + @apply bg-gray-100 antialiased m-0; } .wrapper { @@ -21,7 +21,7 @@ body { } .btn { - @apply rounded-full transition-all duration-200 font-medium ease-out flex justify-center active:translate-y-px disabled:opacity-50 disabled:cursor-not-allowed; + @apply rounded-xl py-2 transition-all duration-200 font-medium ease-out flex justify-center active:translate-y-px disabled:opacity-50 disabled:cursor-not-allowed; } .btn-ghost { @@ -39,6 +39,7 @@ body { .fieldset { @apply space-y-1; } + .fieldset-legend { @apply text-xs font-bold ml-2 text-gray-800; } @@ -51,6 +52,14 @@ body { @apply p-3 border border-gray-300 rounded-xl text-sm w-full; } +.input-image { + @apply rounded-xl border bg-teal-100 border-teal-500 border-dashed; +} + +.input-image-ghost { + @apply bg-gray-100 border-gray-300; +} + .dropdown { position-area: span-left bottom; @apply p-4 mt-2 border border-gray-300 shadow-lg rounded-xl space-y-2 text-gray-800; @@ -59,3 +68,16 @@ body { .dropdown li { @apply rounded-lg hover:bg-linear-to-r hover:from-teal-300 hover:to-transparent px-5 py-1; } + +h1, +h2, +h3, +h4, +h5, +h6 { + @apply font-space text-gray-800; +} + +h1 { + @apply text-3xl my-4 ml-2; +}