feature: upload images and show alert after successfull product creation

This commit is contained in:
kusowl 2026-02-26 19:02:39 +05:30
parent bb05fb7747
commit 617053c0ee
9 changed files with 208 additions and 27 deletions

View File

@ -3,12 +3,12 @@ import { provideRouter } from "@angular/router";
import { routes } from "./app.routes"; import { routes } from "./app.routes";
import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http"; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
], ],
}; };

View File

@ -1,11 +1,11 @@
import { TestBed } from "@angular/core/testing"; import { TestBed } from "@angular/core/testing";
import { HttpInterceptorFn } from "@angular/common/http"; import { HttpInterceptorFn } from "@angular/common/http";
import { authInterceptor } from "./auth-interceptor"; import { csrfInterceptor } from "./csrf-interceptor";
describe("authInterceptor", () => { describe("authInterceptor", () => {
const interceptor: HttpInterceptorFn = (req, next) => const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => authInterceptor(req, next)); TestBed.runInInjectionContext(() => csrfInterceptor(req, next));
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({}); TestBed.configureTestingModule({});

View File

@ -1,6 +1,6 @@
import { HttpInterceptorFn } from "@angular/common/http"; 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 getCookie = (name: string): string | null => {
const match = document.cookie.match(new RegExp("(^|;\\s*)(" + name + ")=([^;]*)")); const match = document.cookie.match(new RegExp("(^|;\\s*)(" + name + ")=([^;]*)"));
return match ? decodeURIComponent(match[3]) : null; return match ? decodeURIComponent(match[3]) : null;

View File

@ -1,35 +1,106 @@
<section class="wrapper"> <section class="wrapper">
@if (successMessage()) {
<div
class="fixed top-5 right-5 z-50 flex items-center p-4 mb-4 text-green-800 rounded-lg bg-green-50 border border-green-200 shadow-lg animate-bounce-in"
role="alert"
>
<svg
aria-hidden="true"
class="flex-shrink-0 w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"
/>
</svg>
<div class="ms-3 text-sm font-medium">{{ successMessage() }}</div>
<button
(click)="successMessage.set(null)"
class="ms-auto -mx-1.5 -my-1.5 bg-green-50 text-green-500 rounded-lg p-1.5 hover:bg-green-200 inline-flex items-center justify-center h-8 w-8"
type="button"
>
<span class="sr-only">Close</span>
</button>
</div>
}
<h1 class="h1">Add Product</h1> <h1 class="h1">Add Product</h1>
<section class="card"> <section class="card">
<form class="flex flex-col sm:flex-row gap-4"> <form
(ngSubmit)="submitProductForm()"
[formGroup]="productAddFrom"
class="flex flex-col sm:flex-row gap-4"
>
<fieldset class="flex flex-col space-y-4 min-w-0 flex-1"> <fieldset class="flex flex-col space-y-4 min-w-0 flex-1">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Title</legend> <legend class="fieldset-legend">Title</legend>
<input class="input" id="title" placeholder="Enter product title" type="text" /> <input
<!-- <app-error fieldName="email" [control]="loginForm.get('email')" />--> class="input"
formControlName="title"
id="title"
placeholder="Enter product title"
type="text"
/>
<app-error [control]="productAddFrom.get('title')" fieldName="title" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Description</legend> <legend class="fieldset-legend">Description</legend>
<textarea class="input" id="description" rows="5"></textarea> <textarea
<!-- <app-error fieldName="email" [control]="loginForm.get('email')" />--> class="input"
formControlName="description"
id="description"
rows="5"
></textarea>
<app-error [control]="productAddFrom.get('description')" fieldName="description" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Select category</legend> <legend class="fieldset-legend">Select category</legend>
<select class="input"> <select class="input" formControlName="product_category_id" id="category">
<option>Select Category</option> <option disabled selected value="">Select Category</option>
<option>Electronics</option> @for (category of categories(); track category.id) {
<option>Fashion</option> <option [value]="category.id">{{ category.name }}</option>
<option>Books</option> }
</select> </select>
<!-- <app-error fieldName="email" [control]="loginForm.get('email')" />--> <app-error [control]="productAddFrom.get('product_category_id')" fieldName="category" />
</fieldset> </fieldset>
</fieldset> </fieldset>
<fieldset class="flex flex-col space-y-4 min-w-0 flex-1"> <fieldset class="flex flex-col space-y-4 min-w-0 flex-1">
<div class="flex gap-4">
<fieldset class="fieldset flex-1">
<legend class="fieldset-legend">List Price</legend>
<input
class="input"
formControlName="list_price"
id="list-price"
placeholder="$0"
type="number"
/>
<label class="label" for="list-price">Price to be shown as MRP</label>
<app-error [control]="productAddFrom.get('list_price')" fieldName="List Price" />
</fieldset>
<fieldset class="fieldset flex-1">
<legend class="fieldset-legend">Actual Price</legend>
<input
class="input"
formControlName="actual_price"
id="actual-price"
placeholder="$0"
type="number"
/>
<label class="label" for="actual-price">Price to be calculated when billing</label>
<app-error [control]="productAddFrom.get('actual_price')" fieldName="Actual Price" />
</fieldset>
</div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Image</legend> <legend class="fieldset-legend">Image</legend>
<div class="grid grid-cols-1 sm:grid-cols-3 sm:h-40 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 sm:h-36 gap-4">
<app-image-input <app-image-input
(imageSelected)="openPreview($event)" (imageSelected)="openPreview($event)"
[bgImageUrl]="selectedImages()['image_1']?.url" [bgImageUrl]="selectedImages()['image_1']?.url"
@ -79,7 +150,7 @@
</dialog> </dialog>
</div> </div>
</fieldset> </fieldset>
<button class="btn btn-black py-2">Add Product</button> <button class="btn btn-black py-2" type="submit">Add Product</button>
</fieldset> </fieldset>
</form> </form>
</section> </section>

View File

@ -1,6 +1,11 @@
import { Component, ElementRef, signal, ViewChild } from "@angular/core"; import { Component, ElementRef, inject, signal, ViewChild } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { ImageInput } from "../../../shared/components/image-input/image-input"; 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 { export interface ImageSelection {
id: string; id: string;
@ -10,14 +15,39 @@ export interface ImageSelection {
@Component({ @Component({
selector: "app-add-product", selector: "app-add-product",
imports: [ReactiveFormsModule, ImageInput], imports: [ReactiveFormsModule, ImageInput, Error],
templateUrl: "./add-product.html", templateUrl: "./add-product.html",
styleUrl: "./add-product.css", styleUrl: "./add-product.css",
}) })
export class AddProduct { export class AddProduct {
http = inject(HttpClient);
apiUrl = inject<string>(API_URL);
categoryService = inject(CategoryService);
@ViewChild("imageDialog") imageDialog!: ElementRef<HTMLDialogElement>; @ViewChild("imageDialog") imageDialog!: ElementRef<HTMLDialogElement>;
activeImage = signal<ImageSelection | null>(null); activeImage = signal<ImageSelection | null>(null);
selectedImages = signal<Record<string, { url: string; file: File }>>({}); selectedImages = signal<Record<string, { url: string; file: File }>>({});
categories = signal<Category[]>([]);
successMessage = signal<string | null>(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) { openPreview(image: ImageSelection) {
this.activeImage.set(image); this.activeImage.set(image);
@ -30,7 +60,6 @@ export class AddProduct {
if (current) { if (current) {
this.selectedImages.update((images) => ({ ...images, [current.id]: current })); this.selectedImages.update((images) => ({ ...images, [current.id]: current }));
} }
console.log(this.selectedImages());
this.closeDialog(); this.closeDialog();
} }
@ -38,4 +67,46 @@ export class AddProduct {
this.activeImage.set(null); this.activeImage.set(null);
this.imageDialog.nativeElement.close(); 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);
}
} }

View File

@ -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();
});
});

View File

@ -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<Category[]>(`${this.apiUrl}/categories`);
}
}

View File

@ -6,11 +6,13 @@ import { UpperCaseFirstPipe } from "../../pipes/upper-case-first-pipe";
selector: "app-error", selector: "app-error",
imports: [UpperCaseFirstPipe], imports: [UpperCaseFirstPipe],
template: ` template: `
@if (this.control && this.control.touched) { <div class="min-h-4">
@for (error of getErrorMessages(); track error) { @if (this.control && this.control.touched) {
<p class="ml-2 text-xs text-red-400">{{ error | upperCaseFirst }}</p> @for (error of getErrorMessages(); track error) {
<p class="ml-2 text-xs text-red-400">{{ error | upperCaseFirst }}</p>
}
} }
} </div>
`, `,
}) })
export class Error { export class Error {

View File

@ -5,7 +5,7 @@
[style.backgroundImage]="'url(' + bgImageUrl + ')'" [style.backgroundImage]="'url(' + bgImageUrl + ')'"
class="absolute inset-0 bg-cover bg-center bg-no-repeat p-4 w-full h-full flex flex-col space-y-4 justify-center items-center cursor-pointer rounded-xl" class="absolute inset-0 bg-cover bg-center bg-no-repeat p-4 w-full h-full flex flex-col space-y-4 justify-center items-center cursor-pointer rounded-xl"
> >
<div class="absolute inset-0 bg-black/40 rounded-xl"></div> <div class="absolute inset-0 bg-black/40 h-full rounded-xl"></div>
<lucide-angular [img]="cameraIcon" class="w-10 h-10 text-white stroke-1 z-10" /> <lucide-angular [img]="cameraIcon" class="w-10 h-10 text-white stroke-1 z-10" />
<p class="text-white text-sm font-semibold z-10">Change image</p> <p class="text-white text-sm font-semibold z-10">Change image</p>