Compare commits

..

2 Commits

Author SHA1 Message Date
kusowl
2b99d43f53 Merge branch 'feature/products' 2026-03-02 19:05:55 +05:30
kusowl
194424bfb2 Merge branch 'backend' 2026-03-02 19:05:39 +05:30
47 changed files with 80 additions and 671 deletions

View File

2
.gitignore vendored
View File

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

View File

@ -1 +0,0 @@
backend/

View File

@ -2,8 +2,7 @@
"$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": {

View File

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

View File

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

View File

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

View File

@ -1,23 +0,0 @@
<?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,7 +30,6 @@ public function toArray(Request $request): array
return Storage::disk('public')->url($productImage->path); return Storage::disk('public')->url($productImage->path);
}, $this->resource->productImages), }, $this->resource->productImages),
'updatedAt' => $this->resource->updatedAt, 'updatedAt' => $this->resource->updatedAt,
'isFavorite' => $this->resource->isFavorite,
]; ];
} }
} }

View File

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

View File

@ -11,9 +11,7 @@
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory; use HasFactory, Notifiable;
use Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -52,14 +50,4 @@ protected function casts(): array
'role' => UserRoles::class, '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

@ -1,27 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

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

View File

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

View File

@ -7,6 +7,7 @@ export const routes: Routes = [
{ {
path: "", path: "",
component: Home, component: Home,
canActivate: [authGuard],
}, },
{ {
path: "", 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" 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=""> <div class="">
<a class="px-3 py-1 bg-blue-600 text-white" routerLink="/">eKart</a> <a class="px-3 py-1 bg-gray-800 text-white" href="/">eKart</a>
</div> </div>
<div class="flex-1 grid grid-cols-[1fr_auto]"> <div class="flex-1 grid grid-cols-[1fr_auto]">
@ -26,18 +26,12 @@
> >
<lucide-angular [img]="UserIcon" class="w-5" /> <lucide-angular [img]="UserIcon" class="w-5" />
</button> </button>
<button <button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
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" /> <lucide-angular [img]="CartIcon" class="w-5" />
<span class="absolute top-0 text-xs ml-1">{{ cartItemCount | async }}</span>
</button> </button>
</div> </div>
</div> </div>
</nav> </nav>
<ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1"> <ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1">
@if (authService.authState() === AuthState.Unauthenticated) { @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>
@ -51,12 +45,4 @@
<li><a class="block h-full w-full" href="">Wishlist</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> <li><a class="block h-full w-full" href="">Notifications</a></li>
</ul> </ul>
<app-cart
[cart]="(cartItem$ | async)!"
id="popover-2"
class="dropdown"
popover
style="position-anchor: --anchor-2"
/>
</header> </header>

View File

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

View File

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

View File

@ -1,16 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,20 +0,0 @@
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); this.authState.set(AuthState.Authenticated);
} }
purgeAuth() { private purgeAuth() {
this.localStorage.removeItem(this.userKey); this.localStorage.removeItem(this.userKey);
this.user.set(null); this.user.set(null);
this.authState.set(AuthState.Unauthenticated); this.authState.set(AuthState.Unauthenticated);

View File

@ -1,29 +1,21 @@
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()"> <div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
<app-favorite-button <!--Favorite button -->
[productId]="product.id" <button
[isFavorite]="product.isFavorite" class="absolute right-6 top-6 transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full"
class="absolute top-5 right-5" >
/> <lucide-angular [img]="HeartIcon" class="w-4 h-4 text-gray-500" />
</button>
<!--Product image--> <!--Product image-->
<div class="bg-gray-200 rounded-xl h-60"> <div class="bg-gray-200 rounded-xl h-40">
<img [src]="product.productImages[0]" alt="" class="object-cover rounded-xl w-full h-full" /> <img [src]="product.productImages[0]" alt="" class="object-cover rounded-xl w-full h-full" />
</div> </div>
<div class="flex justify-between mt-4"> <p class="text-gray-400 text-sm truncate">{{product.title}}</p>
<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> <p class="text-gray-400 text-xs">⭐4.5</p>
</div> <p class="text-gray-400 text-xs">
<div class="flex justify-between mt-4"> Price:
<div class=""> <span class="line-through italic mr-1">{{product.actualPrice}}</span>
<p class="text-gray-400 text-xs line-through italic">Rs. {{ product.listPrice }}</p> <span class="font-bold">{{product.listPrice}}/-</span>
<p class="font-medium text-lg">Rs. {{ product.actualPrice }}</p> </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> </div>

View File

@ -1,29 +1,22 @@
import { Component, inject, Input } from "@angular/core"; import { Component, inject, Input } from "@angular/core";
import { LucideAngularModule, Heart } from "lucide-angular";
import { ProductModel } from "../../../../core/models/product.model"; import { ProductModel } from "../../../../core/models/product.model";
import { BACKEND_URL } from "../../../../core/tokens/api-url-tokens";
import { Router } from "@angular/router"; 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({ @Component({
selector: "app-product-card", selector: "app-product-card",
standalone: true, standalone: true,
imports: [FavoriteButton, LucideAngularModule], imports: [LucideAngularModule],
templateUrl: "./product-card.html", templateUrl: "./product-card.html",
}) })
export class ProductCard { export class ProductCard {
readonly HeartIcon = Heart;
readonly router = inject(Router); readonly router = inject(Router);
readonly ShoppingCartIcon = ShoppingCart;
readonly cartService = inject(CartService);
@Input() product!: ProductModel; @Input() product!: ProductModel;
goToProductDetails() { goToProductDetails() {
this.router.navigate(["/products", this.product.slug]); 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> <p class="text-3xl text-gray-700 font-sans">Our Product</p>
</section> </section>
<section class="mt-10 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-4 wrapper"> <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) { @for (product of products(); track product) {
<app-product-card [product]="product" /> <app-product-card [product]="product" />
} }

View File

@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { API_URL } from "../../../core/tokens/api-url-tokens"; import { API_URL } from "../../../core/tokens/api-url-tokens";
import { ProductCollection, ProductModel } from "../../../core/models/product.model"; import { ProductCollection, ProductModel } from "../../../core/models/product.model";
import { map } from "rxjs";
@Injectable({ @Injectable({
providedIn: "root", providedIn: "root",

View File

@ -16,27 +16,28 @@
alt="Product Image" alt="Product Image"
class="w-full h-full object-cover object-center rounded-lg" class="w-full h-full object-cover object-center rounded-lg"
/> />
<app-favorite-button <button
[productId]="product()!.id" class="absolute top-2 right-2 p-2! rounded-full! btn btn-ghost border-none! bg-gray-50"
class="absolute top-5 right-5" >
[isFavorite]="product()!.isFavorite" <lucide-angular [img]="HeartIcon" class="w-4 h-4" />
/> </button>
<button <button
(click)="prevImage()" (click)="prevImage()"
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs" class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost"
> >
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4 text-gray-200" /> <lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4" />
</button> </button>
<button <button
(click)="nextImage()" (click)="nextImage()"
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs" class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost"
> >
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4 text-gray-200" /> <lucide-angular [img]="ArrowRightIcon" class="w-4 h-4" />
</button> </button>
</div> </div>
<div class="grid grid-cols-4 gap-3"> <div class="grid grid-cols-4 gap-3">
@for (image of product()?.productImages; track image) { @for (image of product()?.productImages; track image) {
<button <button
class="card p-2! aspect-square cursor-pointer" class="card p-2! aspect-square cursor-pointer"
(click)="this.activeImageIndex.set($index)" (click)="this.activeImageIndex.set($index)"
@ -90,16 +91,38 @@
<div class="lg:col-span-3 flex flex-col gap-6"> <div class="lg:col-span-3 flex flex-col gap-6">
<div class="card"> <div class="card">
<div class="flex justify-between items-start mb-6"> <div class="flex justify-between items-start mb-6">
<div class=""> <div class="flex items-baseline gap-2">
<p class="text-sm text-gray-400 line-through decoration-1"> <span class="text-xl text-gray-400 line-through decoration-1"
Rs.{{ product()?.listPrice }} >Rs.{{ product()?.actualPrice }}</span
</p> >
<p class="text-3xl font-bold text-gray-900">Rs.{{ product()?.actualPrice }}</p> <span class="text-3xl font-bold text-gray-900">Rs.{{ product()?.listPrice }}</span>
<span class="text-xs text-gray-400 ml-1"></span>
</div> </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>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<button (click)="addToCart()" class="w-full btn btn-ghost">Add to Cart</button> <button class="w-full btn btn-ghost">Add to Cart</button>
<button class="w-full btn btn-primary">Buy Now</button> <button class="w-full btn btn-primary">Buy Now</button>
</div> </div>
</div> </div>

View File

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

View File

@ -1,26 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,34 +0,0 @@
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

@ -1,26 +0,0 @@
<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

@ -1,22 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,13 +0,0 @@
<!--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

@ -1,22 +0,0 @@
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

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

View File

@ -13,14 +13,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "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": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,