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
95 changed files with 95 additions and 1619 deletions

View File

@ -1,8 +0,0 @@
{
"mcpServers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

2
.gitignore vendored
View File

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

View File

@ -1,3 +0,0 @@
{
"indexer.exclude_patterns": ["/node_modules/**/*", "/backend/**/*"]
}

View File

@ -1 +0,0 @@
backend/

View File

@ -2,8 +2,7 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {

View File

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

View File

@ -22,7 +22,6 @@ public function __construct(
public array $productImages,
public ?string $updatedAt = null,
public ?string $createdAt = null,
public ?bool $isFavorite = null
) {}
/**
@ -42,7 +41,6 @@ public function toArray(): array
$this->productImages),
'updatedAt' => $this->updatedAt,
'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(),
updatedAt: $product->updated_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\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(GetProductsQuery $getProductsQuery)
public function index()
{
$products = $getProductsQuery->get(Auth::user());
$paginatedDtos = $products->through(fn ($product) => ProductDTO::fromModel($product));
$paginator = Product::query()->with(['category:id,name,slug', 'images:id,path,product_id'])->paginate();
$paginatedDtos = $paginator->through(fn ($product) => ProductDTO::fromModel($product));
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);
}, $this->resource->productImages),
'updatedAt' => $this->resource->updatedAt,
'isFavorite' => $this->resource->isFavorite,
];
}
}

View File

@ -2,11 +2,8 @@
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;
@ -31,17 +28,6 @@ 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) {
@ -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
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory;
use Notifiable;
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@ -52,14 +50,4 @@ 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

@ -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
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;
@ -16,9 +15,6 @@
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(withInterceptors([csrfInterceptor])),
provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
],
};

View File

@ -7,6 +7,7 @@ export const routes: Routes = [
{
path: "",
component: Home,
canActivate: [authGuard],
},
{
path: "",
@ -19,9 +20,4 @@ export const routes: Routes = [
canActivate: [authGuard, roleGuard],
data: { roles: ["admin", "broker"] },
},
{
path: "checkout",
loadChildren: () =>
import("./features/checkout/checkout.routes").then((routes) => routes.checkoutRoutes),
},
];

View File

@ -1,6 +1,6 @@
import { CanActivateFn, Router } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
import { AuthService } from "../../features/auth/services/auth-service";
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);

View File

@ -1,6 +1,6 @@
import { CanActivateFn } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
import { AuthService } from "../../features/auth/services/auth-service";
export const roleGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);

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-blue-600 text-white" routerLink="/">eKart</a>
<a class="px-3 py-1 bg-gray-800 text-white" href="/">eKart</a>
</div>
<div class="flex-1 grid grid-cols-[1fr_auto]">
@ -20,24 +20,18 @@
<div class="flex space-x-4">
<div class="flex text-gray-600">
<button
class="btn btn-ghost py-1 px-3 rounded-r-none!"
class="btn btn-ghost py-1 px-2 rounded-r-none!"
popovertarget="popover-1"
style="anchor-name: --anchor-1"
>
<lucide-angular [img]="UserIcon" class="w-5" />
</button>
<button
class="btn btn-ghost py-1 px-3 rounded-l-none! border-l-0! relative"
popovertarget="popover-2"
style="anchor-name: --anchor-2"
>
<button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
<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>
@ -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="">Notifications</a></li>
</ul>
<app-cart
[cart]="(cartItems$ | async)!"
id="popover-2"
class="dropdown"
popover
style="position-anchor: --anchor-2"
/>
</header>

View File

@ -1,15 +1,11 @@
import { Component, inject } from "@angular/core";
import { LucideAngularModule, Search, ShoppingCart, User } from "lucide-angular";
import { AuthService, AuthState } from "@core/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";
import { RouterLink } from "@angular/router";
import { AuthService, AuthState } from "../../../features/auth/services/auth-service";
@Component({
selector: "app-header",
imports: [LucideAngularModule, Cart, AsyncPipe],
imports: [LucideAngularModule, RouterLink],
templateUrl: "./header.html",
styleUrl: "./header.css",
})
@ -18,9 +14,5 @@ export class Header {
readonly CartIcon = ShoppingCart;
readonly SearchIcon = Search;
readonly authService = inject(AuthService);
readonly cartService = inject(CartService);
protected readonly AuthState = AuthState;
cartItems$ = this.cartService.cartItems$;
cartItemCount = this.cartItems$.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;
productImages: string[];
updatedAt: string;
isFavorite: boolean;
}
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 "@core/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 _cartItems = new BehaviorSubject<CartModel>({} as CartModel);
cartItems$ = this._cartItems.asObservable();
constructor() {
effect(() => {
if (this.authService.isAuthenticated()) {
this.fetchCart();
} else {
this._cartItems.next({} as CartModel);
}
});
}
private fetchCart() {
return this.http.get<CartModel>(this.apiUrl + "/cart").subscribe({
next: (data) => this._cartItems.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._cartItems.next(updatedCart)));
}
updateCart(data: CartItemRequest) {
return this.http
.patch<CartModel>(this.apiUrl + "/cart", data)
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
}
removeFromCart(productId: number) {
return this.http
.delete<CartModel>(this.apiUrl + "/cart", { body: { productId: productId } })
.pipe(tap((updatedCart: CartModel) => this._cartItems.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

@ -2,7 +2,7 @@ import { Router, Routes } from "@angular/router";
import { Login } from "./components/login/login";
import { Register } from "./components/register/register";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
import { AuthService } from "./services/auth-service";
export const AuthRoutes: Routes = [
{

View File

@ -1,12 +1,12 @@
import { Component, inject } from "@angular/core";
import { Router } from "@angular/router";
import { Router, RouterLink } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "@core/services/auth-service";
import { Error } from "@app/shared/components/error/error";
import { AuthService } from "../../services/auth-service";
import { Error } from "../../../../shared/components/error/error";
@Component({
selector: "app-login",
imports: [ReactiveFormsModule, Error],
imports: [RouterLink, ReactiveFormsModule, Error],
templateUrl: "./login.html",
styleUrl: "./login.css",
})

View File

@ -1,8 +1,8 @@
import { Component, inject, signal } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "@core/services/auth-service";
import { RegisterUserRequest } from "@core/models/user.model";
import { Error } from "@shared/components/error/error";
import { AuthService } from "../../services/auth-service";
import { RegisterUserRequest } from "../../../../core/models/user.model";
import { Error } from "../../../../shared/components/error/error";
import { Router, RouterLink } from "@angular/router";
@Component({

View File

@ -1,9 +1,9 @@
import { computed, inject, Injectable, Signal, signal, WritableSignal } from "@angular/core";
import { RegisterUserRequest, User } from "../models/user.model";
import { RegisterUserRequest, User } from "../../../core/models/user.model";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { API_URL, BACKEND_URL } from "../tokens/api-url-tokens";
import { API_URL, BACKEND_URL } from "../../../core/tokens/api-url-tokens";
import { switchMap, tap } from "rxjs";
import { LocalStorageService } from "../services/local-storage.service";
import { LocalStorageService } from "../../../core/services/local-storage.service";
export enum AuthState {
Loading = "loading",
@ -104,7 +104,7 @@ export class AuthService {
this.authState.set(AuthState.Authenticated);
}
purgeAuth() {
private purgeAuth() {
this.localStorage.removeItem(this.userKey);
this.user.set(null);
this.authState.set(AuthState.Unauthenticated);

View File

@ -1,18 +0,0 @@
<div class="col-span-2 flex flex-col space-y-4">
@for (address of addresses(); track address.id) {
<div class="flex space-x-2">
<input [formControl]="addressIdControl" name="address" type="radio" value="{{address.id}}" />
<app-address-select
(addressUpdated)="updateAddress($event)"
[address]="address"
[isProcessing]="isProcessing()"
class="flex-1"
/>
</div>
}
<app-address-form
(submitAddress)="createNewAddress($event)"
[isProcessing]="isProcessing()"
class="ml-5"
/>
</div>

View File

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

View File

@ -1,80 +0,0 @@
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { AddressForm } from "../components/address-form/address-form";
import { AddressSelect } from "../components/address-select/address-select";
import {
AddressRequest,
AddressResponse,
AddressService,
} from "@app/features/checkout/services/address-service";
import { AuthService } from "@core/services/auth-service";
import { User } from "@core/models/user.model";
import { finalize, of, switchMap } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ReactiveFormsModule } from "@angular/forms";
@Component({
selector: "app-address",
imports: [AddressSelect, AddressForm, ReactiveFormsModule],
templateUrl: "./address.html",
styleUrl: "./address.css",
})
export class Address implements OnInit {
addressService = inject(AddressService);
authService = inject(AuthService);
addressIdControl = this.addressService.addressIdControl;
isProcessing = signal(false);
// I am subscribing to the observable instead of using toSignal(),
// i have to destroy the subscription manually.
destroyRef = inject(DestroyRef);
protected addresses = signal<AddressResponse[]>([]);
private user: User | undefined;
ngOnInit(): void {
this.authService
.getCurrentUser()
.pipe(
switchMap((user) => {
this.user = user;
if (user?.id) {
return this.addressService.fetchAddresses(user.id);
}
return of({ data: [] });
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (addresses) => {
this.addresses.set(addresses.data);
},
});
}
protected createNewAddress(addressData: AddressRequest) {
this.isProcessing.set(true);
this.addressService
.createAddress(this.user!.id, addressData)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.isProcessing.set(false)),
)
.subscribe({
next: (address) => this.addresses.update((addresses) => [...addresses, address]),
});
}
protected updateAddress(addressData: AddressResponse) {
this.isProcessing.set(true);
this.addressService
.updateAddress(addressData.id, addressData)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.isProcessing.set(false)),
)
.subscribe({
next: (address) =>
this.addresses.update((addresses) =>
addresses.map((a) => (a.id === address.id ? address : a)),
),
});
}
}

View File

@ -1,46 +0,0 @@
<section class="wrapper my-10">
<div class="wrapper grid place-content-center w-full">
<app-stepper [currentStep]="currentStepNumber()" [steps]="steps" />
</div>
<section class="mt-10">
@if (currentStepNumber() > 0) {
<app-go-back
[route]="steps[currentStepNumber() - 1].route"
[text]="steps[currentStepNumber() - 1 ].label"
class="ml-4 block"
/>
}
<div class="grid md:grid-cols-6 gap-10">
<div class="md:col-span-4">
<router-outlet />
</div>
<div class="md:col-span-2">
<app-order-summery class="hidden md:block" />
<div class="card mt-4">
<fieldset class="fieldset">
<legend class="fieldset-legend">Have any coupon ?</legend>
<div class="flex items-center space-x-2">
<input class="input" placeholder="Enter coupon here" type="text" />
<button class="btn btn-ghost px-4">Apply</button>
</div>
</fieldset>
@if (addressIdControl.invalid && addressIdControl.touched) {
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
Please select an address
</div>
}
<button
(click)="proceedToPayment()"
[disabled]="addressIdControl.invalid && addressIdControl.touched || orderCreationLoading()"
class="btn btn-primary w-full mt-4"
>
<app-loading-spinner [isLoading]="orderCreationLoading()">
Proceed to payment
</app-loading-spinner>
</button>
</div>
</div>
</div>
</section>
</section>

View File

@ -1,21 +0,0 @@
import { Routes } from "@angular/router";
import { Address } from "./address/address";
import { Checkout } from "./checkout";
import { Payment } from "@app/features/checkout/payment/payment";
export const checkoutRoutes: Routes = [
{
path: "",
component: Checkout,
children: [
{
path: "address",
component: Address,
},
{
path: "payment",
component: Payment,
},
],
},
];

View File

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

View File

@ -1,77 +0,0 @@
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Stepper, Steps } from "@app/shared/components/stepper/stepper";
import { OrderSummery } from "@app/features/checkout/components/order-summery/order-summery";
import { GoBack } from "@shared/components/go-back/go-back";
import { AddressService } from "@app/features/checkout/services/address-service";
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
import { OrderService } from "@app/features/checkout/services/order-service";
import { CartService } from "@core/services/cart-service";
import { CartModel } from "@core/models/cart.model";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { filter, finalize, map } from "rxjs";
@Component({
selector: "app-checkout",
providers: [AddressService],
imports: [RouterOutlet, Stepper, OrderSummery, GoBack, LoadingSpinner],
templateUrl: "./checkout.html",
styleUrl: "./checkout.css",
})
export class Checkout implements OnInit {
steps: Steps[] = [
{ label: "Cart", route: "" },
{ label: "Address", route: "/checkout/address" },
{ label: "Payment", route: "/checkout/payment" },
{ label: "Confirm", route: "/checkout/address" },
];
destroyRef = inject(DestroyRef);
orderCreationLoading = signal(false);
private addressService = inject(AddressService);
addressIdControl = this.addressService.addressIdControl;
private orderService = inject(OrderService);
private cartService = inject(CartService);
private cart: CartModel | undefined;
private router = inject(Router);
protected currentStepNumber = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd), // Added TS type guard
map((event: NavigationEnd) => {
// Strip query params and fragments to ensure exact matching
const cleanUrl = event.urlAfterRedirects.split("?")[0].split("#")[0];
// Exact match comparison
const activeIndex = this.steps.findIndex(
(step) => cleanUrl === step.route || (step.route !== "" && cleanUrl.endsWith(step.route)),
);
return activeIndex !== -1 ? activeIndex : 0;
}),
),
{ initialValue: 1 },
);
ngOnInit(): void {
this.cartService.cartItems$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((cart) => {
this.cart = cart;
});
}
protected proceedToPayment() {
if (this.addressIdControl.invalid) {
this.addressIdControl.markAsTouched();
return;
}
this.orderCreationLoading.set(true);
this.orderService
.createOrder({ cartId: this.cart!.id, addressId: this.addressIdControl.value! })
.pipe(
finalize(() => this.orderCreationLoading.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(async () => {
await this.router.navigate(["/checkout/payment"]);
});
}
}

View File

@ -1,57 +0,0 @@
<details [open]="isEditing()" class="card p-0!" title="Click to add a new address">
<summary class="p-6">
<label class="font-medium text-gray-600 ml-2" for="currentAddress"
>{{isEditing() ? 'Update address' : 'Add new address'}}</label
>
</summary>
<form
(ngSubmit)="submitForm()"
[formGroup]="addressForm"
class="w-full flex flex-col gap-y-2 pt-0 p-4"
>
<fieldset class="flex space-x-4 w-full">
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">First Name</legend>
<input class="input" formControlName="firstName" placeholder="Example: Jhon" type="text" />
<app-error [control]="addressForm.get('firstName')" fieldName="First name" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Last Name</legend>
<input class="input" formControlName="lastName" placeholder="Example: Doe" type="text" />
<app-error [control]="addressForm.get('lastName')" fieldName="Last name" />
</fieldset>
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Street Address</legend>
<input class="input" formControlName="street" placeholder="Your street address" type="text" />
<app-error [control]="addressForm.get('street')" fieldName="Street address" />
</fieldset>
<fieldset class="flex space-x-4 w-full">
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">City</legend>
<input class="input" formControlName="city" placeholder="Your city" type="text" />
<app-error [control]="addressForm.get('city')" fieldName="City" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">State</legend>
<input class="input" formControlName="state" placeholder="State Name" type="text" />
<app-error [control]="addressForm.get('state')" fieldName="State" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Pin Code</legend>
<input class="input" formControlName="pinCode" placeholder="7XX XX1" type="text" />
<app-error [control]="addressForm.get('pinCode')" fieldName="Pin Code" />
</fieldset>
</fieldset>
<div class="ml-auto flex space-x-4">
<button (click)="cancelEditing()" class="btn btn-ghost px-3 text-sm" type="button">
Cancel
</button>
<button class="btn btn-primary min-w-50 px-3 text-sm">
<app-loading-spinner [isLoading]="isProcessing()">
{{isEditing() ? 'Update this address' : 'Use this address'}}
</app-loading-spinner>
</button>
</div>
</form>
</details>

View File

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

View File

@ -1,65 +0,0 @@
import { Component, EventEmitter, input, Input, Output, signal } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { Error } from "@app/shared/components/error/error";
import { AddressRequest, AddressResponse } from "@app/features/checkout/services/address-service";
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
@Component({
selector: "app-address-form",
imports: [ReactiveFormsModule, Error, LoadingSpinner],
templateUrl: "./address-form.html",
styleUrl: "./address-form.css",
})
export class AddressForm {
@Output() submitAddress: EventEmitter<AddressRequest> = new EventEmitter<AddressRequest>();
@Output() updateAddress: EventEmitter<AddressResponse> = new EventEmitter<AddressResponse>();
@Output() editingCanceled: EventEmitter<void> = new EventEmitter<void>();
isProcessing = input<boolean>(false);
addressForm = new FormGroup({
firstName: new FormControl("", {
validators: [Validators.required, Validators.pattern("^[a-zA-Z]\\S+$")],
}),
lastName: new FormControl("", {
validators: [Validators.required, Validators.pattern("^[a-zA-Z]\\S+$")],
}),
street: new FormControl("", { validators: Validators.required }),
city: new FormControl("", { validators: Validators.required }),
state: new FormControl("", { validators: Validators.required }),
pinCode: new FormControl("", {
validators: [Validators.required, Validators.pattern("^[0-9]{6}$")],
}),
});
protected isEditing = signal(false);
protected address = signal<AddressResponse | null>(null);
@Input() set initialData(address: AddressResponse) {
if (address) {
this.addressForm.patchValue(address);
this.address.set(address);
this.isEditing.set(true);
}
}
submitForm() {
if (this.addressForm.invalid) {
this.addressForm.markAllAsTouched();
return;
}
const emittedData = this.addressForm.getRawValue() as AddressRequest;
if (this.isEditing()) {
const mergedData = { ...this.address(), ...emittedData };
this.updateAddress.emit(mergedData as unknown as AddressResponse);
} else {
this.submitAddress.emit(emittedData);
}
}
cancelEditing() {
this.addressForm.reset();
this.editingCanceled.emit();
}
}

View File

@ -1,20 +0,0 @@
@if (isEditing()) {
<app-address-form
(editingCanceled)="cancelEditing()"
(updateAddress)="updateAddress($event)"
[initialData]="address"
[isProcessing]="isProcessing()"
/>
} @else{
<div class="flex justify-between card">
<div class="flex space-x-4 items-center">
<p class="text-gray-600 font-medium">{{[address.firstName, address.lastName] | fullname}}</p>
<p class="text-gray-400 text-sm">
{{`${address.street}, ${address.city}, ${address.pinCode}`}}
</p>
</div>
<div>
<button (click)="editForm()" class="btn btn-ghost text-sm px-2">Edit</button>
</div>
</div>
}

View File

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

View File

@ -1,45 +0,0 @@
import { Component, effect, EventEmitter, Input, input, Output, signal } from "@angular/core";
import { AddressResponse } from "@app/features/checkout/services/address-service";
import { FullnamePipe } from "@shared/pipes/fullname-pipe";
import { AddressForm } from "@app/features/checkout/components/address-form/address-form";
@Component({
selector: "app-address-select",
imports: [FullnamePipe, AddressForm],
templateUrl: "./address-select.html",
styleUrl: "./address-select.css",
})
export class AddressSelect {
@Input() address!: AddressResponse;
@Output() addressUpdated = new EventEmitter<AddressResponse>();
isProcessing = input<boolean>(false);
protected isEditing = signal(false);
// Track if THIS specific component triggered the API call
private isLocallySubmitted = false;
constructor() {
effect(() => {
const processing = this.isProcessing();
if (!processing && this.isLocallySubmitted) {
this.isEditing.set(false);
this.isLocallySubmitted = false;
}
});
}
editForm() {
this.isEditing.set(true);
}
cancelEditing() {
this.isEditing.set(false);
}
updateAddress(address: AddressResponse) {
this.isLocallySubmitted = true;
this.addressUpdated.emit(address);
}
}

View File

@ -1,24 +0,0 @@
<div class="card">
<p class="text-gray-800 font-medium text-xl">Order Summery</p>
@if (cartItems | async; as cart) { @for (item of cart.items; track item.id) {
<article
class="mt-4 pb-4 border-b border-b-gray-400 border-dashed flex justify-between items-center"
>
<div class="flex space-x-4">
<div class="w-15 h-15 rounded-lg aspect-square relative overflow-hidden">
<img ngSrc="{{item.image}}" fill class="object-cover" alt="product image" />
</div>
<article>
<h2>{{item.title}}</h2>
<p class="text-gray-600 text-sm">Rs. {{item.price}} x {{item.quantity}}</p>
</article>
</div>
<p class="text-gray-800 font-medium">Rs. {{item.subtotal}}</p>
</article>
}
<article class="mt-4 flex justify-between items-center text-lg">
<p class="text-gray-800 font-medium">Total</p>
<p class="text-gray-800 font-medium">Rs. {{cart.totalPrice}}</p>
</article>
}
</div>

View File

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

View File

@ -1,19 +0,0 @@
import { Component, inject, OnInit } from "@angular/core";
import { CartService } from "@core/services/cart-service";
import { CartModel } from "@core/models/cart.model";
import { Observable } from "rxjs";
import { AsyncPipe, NgOptimizedImage } from "@angular/common";
@Component({
selector: "app-order-summery",
imports: [AsyncPipe, NgOptimizedImage],
templateUrl: "./order-summery.html",
styleUrl: "./order-summery.css",
})
export class OrderSummery implements OnInit {
cartService = inject(CartService);
cartItems: Observable<CartModel> | undefined;
ngOnInit(): void {
this.cartItems = this.cartService.cartItems$;
}
}

View File

@ -1 +0,0 @@
<p>payment works!</p>

View File

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

View File

@ -1,9 +0,0 @@
import { Component } from "@angular/core";
@Component({
selector: "app-payment",
imports: [],
templateUrl: "./payment.html",
styleUrl: "./payment.css",
})
export class Payment {}

View File

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

View File

@ -1,44 +0,0 @@
import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { API_URL } from "@core/tokens/api-url-tokens";
import { PaginatedResponse } from "@core/models/paginated.model";
import { FormControl, Validators } from "@angular/forms";
export interface AddressRequest {
firstName: string;
lastName: string;
street: string;
city: string;
state: string;
pinCode: string;
}
export interface AddressResponse extends AddressRequest {
id: number;
}
@Injectable()
export class AddressService {
http = inject(HttpClient);
apiUrl = inject(API_URL);
addressIdControl = new FormControl<number | null>(null, Validators.required);
fetchAddresses(userId: number) {
return this.http.get<PaginatedResponse<AddressResponse>>(
`${this.apiUrl}/user/${userId}/addresses`,
);
}
createAddress(userId: number, data: AddressRequest) {
return this.http.post<AddressResponse>(`${this.apiUrl}/user/${userId}/addresses`, data);
}
updateAddress(addressId: number, data: AddressRequest) {
return this.http.patch<AddressResponse>(`${this.apiUrl}/addresses/${addressId}`, data);
}
deleteAddress(userId: number, addressId: number) {
return this.http.delete<AddressResponse>(
`${this.apiUrl}/user/${userId}/addresses/${addressId}`,
);
}
}

View File

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

View File

@ -1,23 +0,0 @@
import { inject, Injectable } from "@angular/core";
import { API_URL } from "@core/tokens/api-url-tokens";
import { HttpClient } from "@angular/common/http";
import { AuthService } from "@core/services/auth-service";
export interface OrderRequest {
addressId: number;
cartId: number;
}
@Injectable({
providedIn: "root",
})
export class OrderService {
http = inject(HttpClient);
apiUrl = inject(API_URL);
private authService = inject(AuthService);
private user = this.authService.user;
createOrder(data: OrderRequest) {
return this.http.post(`${this.apiUrl}/users/${this.user()?.id}/orders`, data);
}
}

View File

@ -1,29 +1,21 @@
<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"
/>
<!--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-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" />
</div>
<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-sm truncate">{{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>
<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>

View File

@ -1,29 +1,22 @@
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: [FavoriteButton, LucideAngularModule],
imports: [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-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) {
<app-product-card [product]="product" />
}

View File

@ -2,6 +2,7 @@ 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,27 +16,28 @@
alt="Product Image"
class="w-full h-full object-cover object-center rounded-lg"
/>
<app-favorite-button
[productId]="product()!.id"
class="absolute top-5 right-5"
[isFavorite]="product()!.isFavorite"
/>
<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 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
(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>
</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)"
@ -90,16 +91,38 @@
<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="">
<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 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 (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>
</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 { 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, FavoriteButton],
imports: [LucideAngularModule],
templateUrl: "./show-product.html",
styleUrl: "./show-product.css",
})
@ -18,7 +16,6 @@ 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;
@ -31,14 +28,9 @@ 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

@ -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,31 +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()"
class="rounded-none!"
>
@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>
<li class="pt-4! mt-4 border-t border-gray-200 rounded-none!">
<a [routerLink]="`/checkout/address`" class="btn btn-primary px-4">Proceed to checkout</a>
</li>
}
</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,41 +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 "@core/services/auth-service";
import { CartService } from "@app/core/services/cart-service";
import { finalize, tap } from "rxjs";
import { RouterLink } from "@angular/router";
@Component({
selector: "app-cart",
imports: [CartItem, RouterLink],
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

@ -1,4 +0,0 @@
<a [routerLink]="route" class="flex space-x-2 w-min my-4 text-gray-600 hover:text-blue-500 text-sm">
<lucide-angular [img]="MoveLeftIcon" class="w-4" />
<p class="font-medium">{{ text }}</p>
</a>

View File

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

View File

@ -1,15 +0,0 @@
import { Component, Input } from "@angular/core";
import { RouterLink } from "@angular/router";
import { LucideAngularModule, MoveLeft } from "lucide-angular";
@Component({
selector: "app-go-back",
imports: [RouterLink, LucideAngularModule],
templateUrl: "./go-back.html",
styleUrl: "./go-back.css",
})
export class GoBack {
@Input() route: string = "#";
@Input() text: string = "";
MoveLeftIcon = MoveLeft;
}

View File

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

View File

@ -1,40 +0,0 @@
import { Component, input } from "@angular/core";
@Component({
selector: "app-loading-spinner",
standalone: true,
imports: [],
template: `
@if (isLoading()) {
<div class="grid w-full place-items-center overflow-x-scroll lg:overflow-visible">
<svg
class="text-gray-300 animate-spin w-6"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
class="text-gray-900"
></path>
</svg>
</div>
} @else {
<ng-content />
}
`,
})
export class LoadingSpinner {
isLoading = input<boolean>(false);
}

View File

@ -1,31 +0,0 @@
<ol class="flex">
@for (step of steps; track step) {
<li class="flex flex-col items-start">
<div class="flex items-center">
<a
[routerLink]="$index <= currentStep ? step.route : ''"
[class.cursor-not-allowed]="$index > currentStep"
[class.bg-blue-600]="$index <= currentStep"
class="btn py-0! px-1 rounded-full! w-min"
>
<lucide-angular
[class.text-white]="$index <= currentStep"
[class.text-gray-400]="$index > currentStep"
[img]="$index <= currentStep ? CheckIcon : CirecleIcon"
class="w-4"
/>
</a>
<!-- Connected line -->
@if (!$last) {
<hr
[class.border-blue-600]="$index < currentStep"
[class.border-gray-200]="$index >= currentStep"
class="border w-20"
/>
}
</div>
<p class="text-xs text-gray-600">{{ step.label }}</p>
</li>
}
</ol>

View File

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

View File

@ -1,22 +0,0 @@
import { Component, Input } from "@angular/core";
import { Check, Circle, LucideAngularModule } from "lucide-angular";
import { RouterLink } from "@angular/router";
export type Steps = {
label: string;
route: string;
};
@Component({
selector: "app-stepper",
imports: [LucideAngularModule, RouterLink],
templateUrl: "./stepper.html",
styleUrl: "./stepper.css",
})
export class Stepper {
@Input() currentStep: number = 0;
@Input() steps: Steps[] = [];
CheckIcon = Check;
CirecleIcon = Circle;
}

View File

@ -1,8 +0,0 @@
import { FullnamePipe } from "./fullname-pipe";
describe("FullnamePipe", () => {
it("create an instance", () => {
const pipe = new FullnamePipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -1,13 +0,0 @@
import { Pipe, PipeTransform } from "@angular/core";
import { TitleCasePipe } from "@angular/common";
@Pipe({
name: "fullname",
})
export class FullnamePipe implements PipeTransform {
titlecase = new TitleCasePipe();
transform(values: string[]): unknown {
return this.titlecase.transform(values.join(" "));
}
}

View File

@ -13,7 +13,7 @@
}
body {
@apply bg-gray-50 antialiased m-0;
@apply bg-gray-100 antialiased m-0;
}
.wrapper {
@ -25,19 +25,19 @@ body {
}
.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 {
@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 {
@apply text-blue-100 bg-blue-600 border-b 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 {
@apply bg-white rounded-xl border-2 border-gray-200 p-4;
@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;
}
.fieldset {
@ -70,7 +70,7 @@ body {
}
.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,

View File

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