Merge branch 'feature/products' into staging
This commit is contained in:
commit
a4eebef321
@ -2,7 +2,8 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm"
|
"packageManager": "npm",
|
||||||
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from "@angular/core";
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from "@angular/core";
|
||||||
import { provideRouter } from "@angular/router";
|
import { provideRouter, withComponentInputBinding } 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";
|
||||||
@ -8,7 +8,7 @@ import { csrfInterceptor } from "./core/interceptors/csrf-interceptor";
|
|||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
|
provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,6 @@ export const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import("./features/product/product.routes").then((routes) => routes.productRoutes),
|
import("./features/product/product.routes").then((routes) => routes.productRoutes),
|
||||||
canActivate: [authGuard, roleGuard],
|
canActivate: [authGuard, roleGuard],
|
||||||
data: { roles: ["broker"] },
|
data: { roles: ["admin", "broker"] },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Component, signal } from "@angular/core";
|
import { Component, signal } from "@angular/core";
|
||||||
import { RouterOutlet } from "@angular/router";
|
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 { Footer } from "./core/layouts/footer/footer";
|
||||||
import { Header } from "./core/layouts/header/header";
|
import { Header } from "./core/layouts/header/header";
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="">Home</a>
|
<a href="">Home</a>
|
||||||
<a href="">Products</a>
|
<a href="">Product</a>
|
||||||
<a href="">About Us</a>
|
<a href="">About Us</a>
|
||||||
<a href="">Contact Us</a>
|
<a href="">Contact Us</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
src/app/core/models/paginated.model.ts
Normal file
25
src/app/core/models/paginated.model.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
17
src/app/core/models/product.model.ts
Normal file
17
src/app/core/models/product.model.ts
Normal file
@ -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<ProductModel> {}
|
||||||
16
src/app/core/services/favorite-service.spec.ts
Normal file
16
src/app/core/services/favorite-service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/app/core/services/favorite-service.ts
Normal file
20
src/app/core/services/favorite-service.ts
Normal file
@ -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<FavoriteResponse>(`${this.apiUrl}/products/${productId}/favorite`, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { Component } from "@angular/core";
|
import { Component } from "@angular/core";
|
||||||
import { Products } from "./products/products";
|
import { Product } from "../product/product";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-home",
|
selector: "app-home",
|
||||||
imports: [Products],
|
imports: [Product],
|
||||||
templateUrl: "./home.html",
|
templateUrl: "./home.html",
|
||||||
styleUrl: "./home.css",
|
styleUrl: "./home.css",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
<div class="card flex flex-col relative">
|
|
||||||
<!--Favorite button -->
|
|
||||||
<button
|
|
||||||
class="absolute right-6 top-6 transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full"
|
|
||||||
>
|
|
||||||
<lucide-angular [img]="HeartIcon" class="w-4 h-4 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!--Product image-->
|
|
||||||
<div class="bg-gray-200 rounded-xl h-40">
|
|
||||||
<img src="https://placehold.co/400x600" alt="" class="object-cover rounded-xl w-full h-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-400 text-sm">Product Name</p>
|
|
||||||
<p class="text-gray-400 text-xs">⭐4.5</p>
|
|
||||||
<p class="text-gray-400 text-xs">Price: 4999/-</p>
|
|
||||||
</div>
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<section
|
|
||||||
class="wrapper py-8 bg-gray-300 bg-cover bg-end bg-[linear-gradient(to_right,hsla(0,0%,90%,1.0),hsla(0,0%,90%,0.8)_20%,hsla(0,0%,100%,0)),url('/assets/images/watch-banner.jpg')]"
|
|
||||||
>
|
|
||||||
<p class="text-3xl text-gray-700 font-sans">Our Products</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4 wrapper">
|
|
||||||
<app-product-card />
|
|
||||||
<app-product-card />
|
|
||||||
<app-product-card />
|
|
||||||
<app-product-card />
|
|
||||||
<app-product-card />
|
|
||||||
<app-product-card />
|
|
||||||
</section>
|
|
||||||
@ -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 {}
|
|
||||||
@ -62,8 +62,8 @@
|
|||||||
<legend class="fieldset-legend">Select category</legend>
|
<legend class="fieldset-legend">Select category</legend>
|
||||||
<select class="input" formControlName="product_category_id" id="category">
|
<select class="input" formControlName="product_category_id" id="category">
|
||||||
<option disabled selected value="">Select Category</option>
|
<option disabled selected value="">Select Category</option>
|
||||||
@for (category of categories(); track category.id) {
|
@for (category of categories(); track category?.id) {
|
||||||
<option [value]="category.id">{{ category.name }}</option>
|
<option [value]="category?.id">{{ category.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<app-error [control]="productAddFrom.get('product_category_id')" fieldName="category" />
|
<app-error [control]="productAddFrom.get('product_category_id')" fieldName="category" />
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
|
||||||
|
<app-favorite-button
|
||||||
|
[productId]="product.id"
|
||||||
|
[isFavorite]="product.isFavorite"
|
||||||
|
class="absolute top-5 right-5"
|
||||||
|
/>
|
||||||
|
<!--Product image-->
|
||||||
|
<div class="bg-gray-200 rounded-xl h-40">
|
||||||
|
<img [src]="product.productImages[0]" alt="" class="object-cover rounded-xl w-full h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-400 text-sm truncate">{{ product.title }}</p>
|
||||||
|
<p class="text-gray-400 text-xs">⭐4.5</p>
|
||||||
|
<p class="text-gray-400 text-xs">
|
||||||
|
Price:
|
||||||
|
<span class="line-through italic mr-1">{{ product.actualPrice }}</span>
|
||||||
|
<span class="font-bold">{{ product.listPrice }}/-</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,11 @@
|
|||||||
<p>product works!</p>
|
<section
|
||||||
|
class="wrapper py-8 bg-gray-300 bg-cover bg-end bg-[linear-gradient(to_right,hsla(0,0%,90%,1.0),hsla(0,0%,90%,0.8)_20%,hsla(0,0%,100%,0)),url('/assets/images/watch-banner.jpg')]"
|
||||||
|
>
|
||||||
|
<p class="text-3xl text-gray-700 font-sans">Our Product</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4 wrapper">
|
||||||
|
@for (product of products(); track product) {
|
||||||
|
<app-product-card [product]="product" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { Routes } from "@angular/router";
|
import { Routes } from "@angular/router";
|
||||||
import { AddProduct } from "./add-product/add-product";
|
import { AddProduct } from "./add-product/add-product";
|
||||||
|
import { ShowProduct } from "./show-product/show-product";
|
||||||
|
|
||||||
export const productRoutes: Routes = [
|
export const productRoutes: Routes = [
|
||||||
{
|
{
|
||||||
path: "create",
|
path: "create",
|
||||||
component: AddProduct,
|
component: AddProduct,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ":slug",
|
||||||
|
component: ShowProduct,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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({
|
@Component({
|
||||||
selector: "app-product",
|
selector: "app-products",
|
||||||
imports: [],
|
imports: [ProductCard],
|
||||||
templateUrl: "./product.html",
|
templateUrl: "./product.html",
|
||||||
styleUrl: "./product.css",
|
|
||||||
})
|
})
|
||||||
export class Product {}
|
export class Product {
|
||||||
|
productService = inject(ProductService);
|
||||||
|
products = signal<ProductModel[]>([]);
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.productService.getProducts().subscribe((data) => {
|
||||||
|
this.products.set(data.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { HttpClient } from "@angular/common/http";
|
|||||||
import { API_URL } from "../../../core/tokens/api-url-tokens";
|
import { API_URL } from "../../../core/tokens/api-url-tokens";
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/app/features/product/services/product-service.spec.ts
Normal file
16
src/app/features/product/services/product-service.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/app/features/product/services/product-service.ts
Normal file
20
src/app/features/product/services/product-service.ts
Normal file
@ -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<ProductCollection>(`${this.apiUrl}/products`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProduct(slug: string) {
|
||||||
|
return this.http.get<{ data: ProductModel }>(`${this.apiUrl}/products/${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/app/features/product/show-product/show-product.html
Normal file
132
src/app/features/product/show-product/show-product.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<div class="bg-gray-100 py-8 font-sans text-slate-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<nav class="flex text-sm text-gray-500 mb-6 items-center space-x-2">
|
||||||
|
<a href="#" class="hover:text-gray-900">Home</a>
|
||||||
|
<span>›</span>
|
||||||
|
<a href="#" class="hover:text-gray-900">{{ product()?.category?.name }}</a>
|
||||||
|
<span>›</span>
|
||||||
|
<a href="#" class="hover:text-gray-900">{{ product()?.title }}</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
<div class="lg:col-span-4 flex flex-col gap-4">
|
||||||
|
<div class="card aspect-square relative">
|
||||||
|
<img
|
||||||
|
[src]="product()?.productImages?.[activeImageIndex()] ?? ''"
|
||||||
|
alt="Product Image"
|
||||||
|
class="w-full h-full object-cover object-center rounded-lg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 p-2! rounded-full! btn btn-ghost border-none! bg-gray-50"
|
||||||
|
>
|
||||||
|
<lucide-angular [img]="HeartIcon" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="prevImage()"
|
||||||
|
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost"
|
||||||
|
>
|
||||||
|
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="nextImage()"
|
||||||
|
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost"
|
||||||
|
>
|
||||||
|
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-3">
|
||||||
|
@for (image of product()?.productImages; track image) {
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="card p-2! aspect-square cursor-pointer"
|
||||||
|
(click)="this.activeImageIndex.set($index)"
|
||||||
|
>
|
||||||
|
<img [src]="image" alt="Thumbnail 1" class="w-full h-full object-cover rounded-lg" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-5 flex flex-col space-y-5">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<p class="text-3xl font-semibold">{{ product()?.title }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-400">Category:</span>
|
||||||
|
<a href="#" class="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>{{ product()?.category?.name }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex text-yellow-400 text-lg">
|
||||||
|
<span>★</span><span>★</span><span>★</span><span>★</span><span>★</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium">4.8</span>
|
||||||
|
<span class="text-xs text-gray-400"></span>
|
||||||
|
<span class="text-sm text-gray-600 ml-2">112 Reviews</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-40">
|
||||||
|
<h2 class="text-xl font-medium mb-3">Product Description</h2>
|
||||||
|
<p class="text-gray-700 text-sm leading-relaxed mb-4">{{ product()?.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium mb-3">Customer Reviews</h3>
|
||||||
|
<ul class="list-disc list-outside ml-4 space-y-2 text-sm text-gray-700 relative pb-4">
|
||||||
|
<li>
|
||||||
|
"Rich in condensed nutrients using daily."
|
||||||
|
<span class="text-[10px] text-gray-400"></span>
|
||||||
|
</li>
|
||||||
|
<li>"Seedless & Eco-Peel it" <span class="text-[10px] text-gray-400"></span></li>
|
||||||
|
<li>
|
||||||
|
"Longer Shelf Life has encounant" <span class="text-[10px] text-gray-400"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-3 flex flex-col gap-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-xl text-gray-400 line-through decoration-1"
|
||||||
|
>Rs.{{ product()?.actualPrice }}</span
|
||||||
|
>
|
||||||
|
<span class="text-3xl font-bold text-gray-900">Rs.{{ product()?.listPrice }}</span>
|
||||||
|
<span class="text-xs text-gray-400 ml-1"></span>
|
||||||
|
</div>
|
||||||
|
<button class="text-gray-400 hover:text-red-500">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mb-6 overflow-hidden">
|
||||||
|
<button class="px-4 py-2 btn btn-ghost rounded-r-none!">−</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="1"
|
||||||
|
class="w-12 text-center btn btn-ghost rounded-none! border-x-0!"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button class="px-4 py-2 btn btn-ghost rounded-l-none!">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button class="w-full btn btn-ghost">Add to Cart</button>
|
||||||
|
<button class="w-full btn btn-primary">Buy Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
|
||||||
import { Products } from "./products";
|
import { ShowProduct } from "./show-product";
|
||||||
|
|
||||||
describe("Products", () => {
|
describe("ShowProduct", () => {
|
||||||
let component: Products;
|
let component: ShowProduct;
|
||||||
let fixture: ComponentFixture<Products>;
|
let fixture: ComponentFixture<ShowProduct>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Products],
|
imports: [ShowProduct],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Products);
|
fixture = TestBed.createComponent(ShowProduct);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
40
src/app/features/product/show-product/show-product.ts
Normal file
40
src/app/features/product/show-product/show-product.ts
Normal file
@ -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<ProductModel | null>(null);
|
||||||
|
activeImageIndex: WritableSignal<number> = 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!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<!--Favorite button -->
|
||||||
|
<button
|
||||||
|
(click)="$event.stopPropagation(); toggleFavorite($event)"
|
||||||
|
class="transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<lucide-angular
|
||||||
|
[img]="HeartIcon"
|
||||||
|
[class.text-gray-500]="!isFavorite"
|
||||||
|
[class.text-red-400]="isFavorite"
|
||||||
|
[class]="isFavorite ? 'fill-red-500' : 'fill-none'"
|
||||||
|
class="w-4 h-4 transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
|
||||||
|
import { FavoriteButton } from "./favorite-button";
|
||||||
|
|
||||||
|
describe("FavoriteButton", () => {
|
||||||
|
let component: FavoriteButton;
|
||||||
|
let fixture: ComponentFixture<FavoriteButton>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FavoriteButton],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FavoriteButton);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create", () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
@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 {
|
.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;
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user