Compare commits

..

14 Commits

Author SHA1 Message Date
kusowl
50c956c051 fix: make header logo navigation by router 2026-03-12 10:43:38 +05:30
kusowl
ad957efcf0 feature: add to cart
- make the cart service dependable on BehavorialSubject, migrated from
siganls
- implement add to cart service
2026-03-11 19:00:24 +05:30
kusowl
27a04c6458 minor: some quick design and color changes
make the dropdown hover color from gradient to simple gray shade
make the button 3d
change the product card design in home page
add add to cart button in the home page
change design of button ghost
2026-03-11 11:25:07 +05:30
kusowl
2b88cee10b feature: user can change quantity and remove products 2026-03-10 19:07:42 +05:30
kusowl
9000ea0052 feature: fetch cart products from api
- show total cart item count on header
- fetch and show cart items on cart modal
2026-03-09 19:04:31 +05:30
kusowl
3c2233d53e fix: make favorite button in product show page sync with db 2026-03-05 18:08:52 +05:30
kusowl
0f56303d59 fix sanctum and session environment variables 2026-03-05 14:43:21 +05:30
kusowl
a4eebef321 Merge branch 'feature/products' into staging 2026-03-05 13:48:30 +05:30
kusowl
a57566c1fe Merge branch 'backend' into staging 2026-03-05 13:48:24 +05:30
kusowl
7e1ecf35b9 make favorite state persistant with api 2026-03-05 13:34:37 +05:30
kusowl
ae008fbc9c chore: add isFavorite in produtcs response
- refactor code to use query
- add active column in products
2026-03-05 13:32:49 +05:30
kusowl
8ef4383bd9 feature: endpoint to favorite product 2026-03-05 10:32:40 +05:30
kusowl
b575b42f22 Merge remote-tracking branch 'origin/feature/products' into fix/history-issue
# Conflicts:
#	src/app/features/product/components/product-card/product-card.html
#	src/app/features/product/components/product-card/product-card.ts
#	src/app/features/product/services/product-service.ts
2026-03-03 17:40:12 +05:30
kusowl
553637d8e2 fix: commit whole changes 2026-03-03 17:27:30 +05:30
47 changed files with 671 additions and 80 deletions

0
.ai/mcp/mcp.json Normal file
View File

2
.gitignore vendored
View File

@ -38,6 +38,8 @@ yarn-error.log
testem.log
/typings
__screenshots__/
*.cache
.php-cs-fixer.dist.php
# System files
.DS_Store

1
.rgignore Normal file
View File

@ -0,0 +1 @@
backend/

View File

@ -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": {

View File

@ -31,7 +31,7 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_DOMAIN=localhost
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@ -64,3 +64,4 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
FRONTEND_URL=http://localhost:4200
SANCTUM_STATEFUL_DOMAINS=localhost:4200

View File

@ -22,6 +22,7 @@ public function __construct(
public array $productImages,
public ?string $updatedAt = null,
public ?string $createdAt = null,
public ?bool $isFavorite = null
) {}
/**
@ -41,6 +42,7 @@ public function toArray(): array
$this->productImages),
'updatedAt' => $this->updatedAt,
'createdAt' => $this->createdAt,
'isFavorite' => $this->isFavorite,
];
}
@ -57,6 +59,8 @@ public static function fromModel(Product $product): self
productImages: $product->images->map(fn (ProductImage $productImage) => ProductImageDTO::fromModel($productImage))->all(),
updatedAt: $product->updated_at,
createdAt: $product->created_at,
// this column is added by where exists query
isFavorite: $product->favorited_by_exists,
);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\FavouriteProductResource;
use App\Models\FavouriteProduct;
use App\Models\Product;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class FavouriteProductController extends Controller
{
public function index()
{
return FavouriteProductResource::collection(FavouriteProduct::all());
}
public function toggle(Request $request, Product $product)
{
/**
* @var User $user
*/
$user = $request->user();
$changes = $user->favoriteProducts()->toggle($product);
Log::info('hi again');
// If changes has any item, that means a product has been attached.
$isFavorite = count($changes['attached']) > 0;
return response()->json([
'message' => $isFavorite ? 'Product added to favorites' : 'Product removed from favorites',
'isFavorite' => $isFavorite,
]);
}
}

View File

@ -6,14 +6,16 @@
use App\Http\Requests\CreateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Queries\GetProductsQuery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProductController extends Controller
{
public function index()
public function index(GetProductsQuery $getProductsQuery)
{
$paginator = Product::query()->with(['category:id,name,slug', 'images:id,path,product_id'])->paginate();
$paginatedDtos = $paginator->through(fn ($product) => ProductDTO::fromModel($product));
$products = $getProductsQuery->get(Auth::user());
$paginatedDtos = $products->through(fn ($product) => ProductDTO::fromModel($product));
return ProductResource::collection($paginatedDtos);
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\FavouriteProduct;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FavouriteProduct */
class FavouriteProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'user_id' => $this->user_id,
'product_id' => $this->product_id,
];
}
}

View File

@ -30,6 +30,7 @@ public function toArray(Request $request): array
return Storage::disk('public')->url($productImage->path);
}, $this->resource->productImages),
'updatedAt' => $this->resource->updatedAt,
'isFavorite' => $this->resource->isFavorite,
];
}
}

View File

@ -2,8 +2,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
@ -28,6 +31,17 @@ public function images(): HasMany
return $this->hasMany(ProductImage::class, 'product_id', 'id');
}
public function favoritedBy(): BelongsToMany
{
return $this->belongsToMany(User::class, 'favorite_products');
}
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('is_active', true);
}
protected static function booted(): void
{
static::saving(function ($product) {
@ -36,4 +50,11 @@ protected static function booted(): void
}
});
}
protected function casts()
{
return [
'is_active' => 'boolean',
];
}
}

View File

@ -11,7 +11,9 @@
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory;
use Notifiable;
/**
* The attributes that are mass assignable.
@ -50,4 +52,14 @@ protected function casts(): array
'role' => UserRoles::class,
];
}
public function favoriteProducts()
{
return $this->belongsToMany(Product::class, 'favorite_products', 'user_id', 'product_id');
}
public function hasFavorited(Product $product): bool
{
return $this->favoriteProducts()->where('product_id', $product->id)->exists();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Queries;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
final readonly class GetProductsQuery
{
public function get(?User $user = null): LengthAwarePaginator
{
return Product::query()
->active()
->when($user, function (Builder $query) use ($user) {
$query->withExists(
[
'favoritedBy' => fn (Builder $query) => $query->where('user_id', $user->id),
]
);
})
->with(['category:id,name,slug', 'images:id,path,product_id'])
->paginate();
}
}

View File

@ -0,0 +1,26 @@
<?php
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('favorite_products', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class);
$table->foreignIdFor(Product::class);
$table->timestamps();
$table->unique(['user_id', 'product_id']);
});
}
public function down(): void
{
Schema::dropIfExists('favorite_products');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->boolean('is_active')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\FavouriteProductController;
use App\Http\Controllers\ProductCategoryController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProductImagesController;
@ -15,6 +16,9 @@
Route::get('/user', [AuthenticatedUserController::class, 'show']);
Route::post('/logout', [AuthenticatedUserController::class, 'destroy']);
Route::post('/upload/images', action: [ProductImagesController::class, 'store']);
// Favorites
Route::post('/products/{product}/favorite', [FavouriteProductController::class, 'toggle']);
});
Route::get('/categories', [ProductCategoryController::class, 'index']);
Route::apiResource('products', ProductController::class);

View File

@ -9,6 +9,6 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
provideHttpClient(withInterceptors([csrfInterceptor])),
],
};

View File

@ -7,7 +7,6 @@ export const routes: Routes = [
{
path: "",
component: Home,
canActivate: [authGuard],
},
{
path: "",

View File

@ -3,7 +3,7 @@
class="bg-gray-50 wrapper py-4 flex gap-x-5 sm:gap-x-10 items-center shadow-lg shadow-gray-400/20"
>
<div class="">
<a class="px-3 py-1 bg-gray-800 text-white" href="/">eKart</a>
<a class="px-3 py-1 bg-blue-600 text-white" routerLink="/">eKart</a>
</div>
<div class="flex-1 grid grid-cols-[1fr_auto]">
@ -26,23 +26,37 @@
>
<lucide-angular [img]="UserIcon" class="w-5" />
</button>
<button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
<button
class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0! relative"
popovertarget="popover-2"
style="anchor-name: --anchor-2"
>
<lucide-angular [img]="CartIcon" class="w-5" />
<span class="absolute top-0 text-xs ml-1">{{ cartItemCount | async }}</span>
</button>
</div>
</div>
</nav>
<ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1">
@if (authService.authState() === AuthState.Unauthenticated) {
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
} @else if (authService.authState() === AuthState.Loading) {
<li><a class="block h-full w-full">Loading</a></li>
<li><a class="block h-full w-full">Loading</a></li>
} @else {
<li><a class="block h-full w-full" routerLink="/logout">Logout</a></li>
<li><a class="block h-full w-full" routerLink="/logout">Logout</a></li>
}
<li><a class="block h-full w-full" href="">My Account</a></li>
<li><a class="block h-full w-full" href="">Orders</a></li>
<li><a class="block h-full w-full" href="">Wishlist</a></li>
<li><a class="block h-full w-full" href="">Notifications</a></li>
</ul>
<app-cart
[cart]="(cartItem$ | async)!"
id="popover-2"
class="dropdown"
popover
style="position-anchor: --anchor-2"
/>
</header>

View File

@ -2,10 +2,15 @@ import { Component, inject } from "@angular/core";
import { LucideAngularModule, Search, ShoppingCart, User } from "lucide-angular";
import { RouterLink } from "@angular/router";
import { AuthService, AuthState } from "../../../features/auth/services/auth-service";
import { CartService } from "@app/core/services/cart-service";
import { Cart } from "@app/shared/components/cart/cart";
import { CartModel } from "@app/core/models/cart.model";
import { map } from "rxjs";
import { AsyncPipe } from "@angular/common";
@Component({
selector: "app-header",
imports: [LucideAngularModule, RouterLink],
imports: [LucideAngularModule, RouterLink, Cart, AsyncPipe],
templateUrl: "./header.html",
styleUrl: "./header.css",
})
@ -14,5 +19,9 @@ export class Header {
readonly CartIcon = ShoppingCart;
readonly SearchIcon = Search;
readonly authService = inject(AuthService);
readonly cartService = inject(CartService);
protected readonly AuthState = AuthState;
cartItem$ = this.cartService.cartItem$;
cartItemCount = this.cartItem$.pipe(map((cart: CartModel) => cart.itemsCount ?? 0));
}

View File

@ -0,0 +1,20 @@
export interface CartItemModel {
id: number;
title: string;
quantity: number;
price: number;
subtotal: number;
image: string;
}
export interface CartModel {
id: number;
itemsCount: number;
totalPrice: number;
items: CartItemModel[];
}
export interface CartItemRequest {
productId: number;
quantity: number;
}

View File

@ -11,6 +11,7 @@ export interface ProductModel {
category: Category;
productImages: string[];
updatedAt: string;
isFavorite: boolean;
}
export interface ProductCollection extends PaginatedResponse<ProductModel> {}

View File

@ -0,0 +1,16 @@
import { TestBed } from "@angular/core/testing";
import { CartService } from "./cart-service";
describe("CartService", () => {
let service: CartService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,61 @@
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { effect, inject, Injectable, signal } from "@angular/core";
import { API_URL } from "../tokens/api-url-tokens";
import { CartItemModel, CartItemRequest, CartModel } from "../models/cart.model";
import { AuthService, AuthState } from "@app/features/auth/services/auth-service";
import { Cart } from "@app/shared/components/cart/cart";
import { BehaviorSubject, tap } from "rxjs";
@Injectable({
providedIn: "root",
})
export class CartService {
private authService = inject(AuthService);
// dependencies
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
private _cartItem = new BehaviorSubject<CartModel>({} as CartModel);
cartItem$ = this._cartItem.asObservable();
constructor() {
effect(() => {
if (this.authService.isAuthenticated()) {
this.fetchCart();
} else {
this._cartItem.next({} as CartModel);
}
});
}
fetchCart() {
return this.http.get<CartModel>(this.apiUrl + "/cart").subscribe({
next: (data) => this._cartItem.next(data),
error: (error: HttpErrorResponse) => {
if (error.status === 401) {
this.authService.purgeAuth();
}
// show an error in toast
},
});
}
addToCart(data: CartItemRequest) {
return this.http
.post<CartModel>(this.apiUrl + "/cart", data)
.pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart)));
}
updateCart(data: CartItemRequest) {
return this.http
.patch<CartModel>(this.apiUrl + "/cart", data)
.pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart)));
}
removeFromCart(productId: number) {
return this.http
.delete<CartModel>(this.apiUrl + "/cart", { body: { productId: productId } })
.pipe(tap((updatedCart: CartModel) => this._cartItem.next(updatedCart)));
}
}

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

View 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`, {});
}
}

View File

@ -104,7 +104,7 @@ export class AuthService {
this.authState.set(AuthState.Authenticated);
}
private purgeAuth() {
purgeAuth() {
this.localStorage.removeItem(this.userKey);
this.user.set(null);
this.authState.set(AuthState.Unauthenticated);

View File

@ -1,21 +1,29 @@
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
<!--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>
<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">
<div class="bg-gray-200 rounded-xl h-60">
<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 class="flex justify-between mt-4">
<p class="text-sm truncate font-medium text-gray-800 hover:text-blue-500">
{{ product.title }}
</p>
<p class="text-gray-400 text-xs">⭐4.5</p>
</div>
<div class="flex justify-between mt-4">
<div class="">
<p class="text-gray-400 text-xs line-through italic">Rs. {{ product.listPrice }}</p>
<p class="font-medium text-lg">Rs. {{ product.actualPrice }}</p>
</div>
<div>
<button (click)="addToCart($event)" class="btn btn-primary p-3" title="Add to cart">
<lucide-angular [img]="ShoppingCartIcon" class="w-4" />
</button>
</div>
</div>
</div>

View File

@ -1,22 +1,29 @@
import { Component, inject, Input } from "@angular/core";
import { LucideAngularModule, Heart } from "lucide-angular";
import { ProductModel } from "../../../../core/models/product.model";
import { BACKEND_URL } from "../../../../core/tokens/api-url-tokens";
import { Router } from "@angular/router";
import { FavoriteButton } from "../../../../shared/components/favorite-button/favorite-button";
import { LucideAngularModule, ShoppingCart } from "lucide-angular";
import { CartService } from "@app/core/services/cart-service";
@Component({
selector: "app-product-card",
standalone: true,
imports: [LucideAngularModule],
imports: [FavoriteButton, LucideAngularModule],
templateUrl: "./product-card.html",
})
export class ProductCard {
readonly HeartIcon = Heart;
readonly router = inject(Router);
readonly ShoppingCartIcon = ShoppingCart;
readonly cartService = inject(CartService);
@Input() product!: ProductModel;
goToProductDetails() {
this.router.navigate(["/products", this.product.slug]);
}
addToCart(event: Event) {
event.stopPropagation();
this.cartService.addToCart({ productId: this.product.id, quantity: 1 }).subscribe();
}
}

View File

@ -4,7 +4,7 @@
<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">
<section class="mt-10 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-4 wrapper">
@for (product of products(); track product) {
<app-product-card [product]="product" />
}

View File

@ -2,7 +2,6 @@ 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";
import { map } from "rxjs";
@Injectable({
providedIn: "root",

View File

@ -16,28 +16,27 @@
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>
<app-favorite-button
[productId]="product()!.id"
class="absolute top-5 right-5"
[isFavorite]="product()!.isFavorite"
/>
<button
(click)="prevImage()"
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost"
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
>
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4" />
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4 text-gray-200" />
</button>
<button
(click)="nextImage()"
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost"
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
>
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4" />
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4 text-gray-200" />
</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)"
@ -91,38 +90,16 @@
<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 class="">
<p class="text-sm text-gray-400 line-through decoration-1">
Rs.{{ product()?.listPrice }}
</p>
<p class="text-3xl font-bold text-gray-900">Rs.{{ product()?.actualPrice }}</p>
</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 (click)="addToCart()" class="w-full btn btn-ghost">Add to Cart</button>
<button class="w-full btn btn-primary">Buy Now</button>
</div>
</div>

View File

@ -2,10 +2,12 @@ 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";
import { FavoriteButton } from "../../../shared/components/favorite-button/favorite-button";
import { CartService } from "@app/core/services/cart-service";
@Component({
selector: "app-show-product",
imports: [LucideAngularModule],
imports: [LucideAngularModule, FavoriteButton],
templateUrl: "./show-product.html",
styleUrl: "./show-product.css",
})
@ -16,6 +18,7 @@ export class ShowProduct {
ArrowRightIcon = ArrowRight;
ArrowLeftIcon = ArrowLeft;
productService = inject(ProductService);
cartService = inject(CartService);
product = signal<ProductModel | null>(null);
activeImageIndex: WritableSignal<number> = signal(0);
totalImageCount: number = 0;
@ -28,9 +31,14 @@ export class ShowProduct {
});
}
addToCart() {
this.cartService.addToCart({ productId: this.product()!.id, quantity: 1 }).subscribe();
}
nextImage() {
this.activeImageIndex.update((index) => (index + 1) % this.totalImageCount);
}
prevImage() {
this.activeImageIndex.update(
(index) =>

View File

@ -0,0 +1,26 @@
<li class="px-2! py-4! border-b border-b-gray-200">
<div class="flex space-x-5">
<div class="w-20 h-20 bg-gray-100 p-2 rounded-xl">
<img [src]="cartItem.image" class="object-cover h-full w-full rounded-lg" />
</div>
<div class="flex-1 flex flex-col">
<p class="text-sm truncate max-w-30 mt-2">{{ cartItem.title }}</p>
<div class="mt-auto flex space-x-4 items-center text-gray-600">
<p class="text-sm">Rs. {{ cartItem.price }} x {{ cartItem.quantity }}</p>
</div>
</div>
<div class="flex flex-col justify-between">
<div class="mt-2 flex space-x-2 items-center">
<p class="font-medium">Rs. {{ cartItem.subtotal }}</p>
<button (click)="removeProduct()" class="active:scale-80 py-0" title="Remove Item">
<lucide-angular [name]="TrashIcon" class="w-3" />
</button>
</div>
<div class="flex max-h-7 text-xs text-center text-gray-700">
<button (click)="decrementQty()" class="btn btn-ghost py-1! px-2 rounded-r-none!">-</button>
<p class="w-7 text-center py-1 px-2 border-y border-y-gray-300">{{ cartItem.quantity }}</p>
<button (click)="incrementQty()" class="btn py-1! btn-ghost px-2 rounded-l-none!">+</button>
</div>
</div>
</div>
</li>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { CartItem } from "./cart-item";
describe("CartItem", () => {
let component: CartItem;
let fixture: ComponentFixture<CartItem>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CartItem],
}).compileComponents();
fixture = TestBed.createComponent(CartItem);
component = fixture.componentInstance;
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import { Component, EventEmitter, Input, Output, signal } from "@angular/core";
import { CartItemModel, CartItemRequest } from "@app/core/models/cart.model";
import { LucideAngularModule, Trash } from "lucide-angular";
@Component({
selector: "app-cart-item",
imports: [LucideAngularModule],
templateUrl: "./cart-item.html",
styleUrl: "./cart-item.css",
})
export class CartItem {
@Input() cartItem!: CartItemModel;
@Output() qtyChangeEvent = new EventEmitter<CartItemRequest>();
@Output() productDeleteEvent = new EventEmitter<number>();
TrashIcon = Trash;
incrementQty() {
if (this.cartItem.quantity < 10) {
this.cartItem.quantity += 1;
this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity });
}
}
decrementQty() {
if (this.cartItem.quantity > 1) {
this.cartItem.quantity -= 1;
this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity });
}
}
removeProduct() {
this.productDeleteEvent.emit(this.cartItem.id);
}
}

View File

View File

@ -0,0 +1,26 @@
<ul>
@if (authService.authState() === AuthState.Unauthenticated) {
<li><a class="block h-full w-full" routerLink="/login">Login to access cart</a></li>
} @else if (authService.authState() === AuthState.Loading) {
<li><a class="block h-full w-full">Loading</a></li>
} @else {
<ol
[class.pointer-events-none]="isLoading()"
[class.opacity-40]="isLoading()"
[class.cursor-block]="isLoading()"
>
@for (item of cart.items; track item.id) {
<app-cart-item
(qtyChangeEvent)="updateProductQty($event)"
(productDeleteEvent)="removeProduct($event)"
[cartItem]="item"
/>
}
</ol>
<div class="flex justify-between mt-4 px-2 font-bold text-lg">
<p>Total</p>
<p>Rs. {{ cart.totalPrice }}</p>
</div>
}
</ul>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Cart } from "./cart";
describe("Cart", () => {
let component: Cart;
let fixture: ComponentFixture<Cart>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Cart],
}).compileComponents();
fixture = TestBed.createComponent(Cart);
component = fixture.componentInstance;
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { Component, computed, inject, Input, signal } from "@angular/core";
import { CartItemModel, CartItemRequest, CartModel } from "@app/core/models/cart.model";
import { CartItem } from "../cart-item/cart-item";
import { AuthService, AuthState } from "@app/features/auth/services/auth-service";
import { CartService } from "@app/core/services/cart-service";
import { finalize, tap } from "rxjs";
@Component({
selector: "app-cart",
imports: [CartItem],
templateUrl: "./cart.html",
styleUrl: "./cart.css",
})
export class Cart {
@Input() cart!: CartModel;
isLoading = signal(false);
protected readonly authService = inject(AuthService);
protected readonly cartService = inject(CartService);
protected readonly AuthState = AuthState;
updateProductQty(cartItem: CartItemRequest) {
this.isLoading.set(true);
this.cartService
.updateCart(cartItem)
.pipe(finalize(() => this.isLoading.set(false)))
.subscribe();
}
removeProduct(productId: number) {
this.isLoading.set(true);
this.cartService
.removeFromCart(productId)
.pipe(finalize(() => this.isLoading.set(false)))
.subscribe();
}
}

View File

@ -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>

View File

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

View File

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

View File

@ -25,15 +25,15 @@ body {
}
.btn-ghost {
@apply text-gray-600 border border-gray-300 hover:bg-gray-800 hover:text-gray-200;
@apply text-gray-600 border border-b-3 border-gray-300 hover:bg-gray-200/70 hover:text-gray-700;
}
.btn-black {
@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-b-3 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;
@apply text-blue-100 bg-blue-600 border border-b-3 border-blue-900 hover:bg-blue-700;
}
.card {
@ -70,7 +70,7 @@ body {
}
.dropdown li {
@apply rounded-lg hover:bg-linear-to-r hover:from-teal-300 hover:to-transparent px-5 py-1;
@apply rounded-lg hover:bg-linear-to-r hover:bg-gray-100 px-5 py-1;
}
h1,

View File

@ -13,7 +13,14 @@
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
"module": "preserve",
"baseUrl": "./src", // Paths are resolved relative to the baseUrl
"paths": {
"@app/*": ["app/*"],
"@shared/*": ["app/shared/*"],
"@core/*": ["app/core/*"],
"@env/*": ["environments/*"]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,