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()) {
+
+
+
{{ successMessage() }}
+
+
+ }
+
+ Add Product
+
+
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 @@
+
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;
+}