Compare commits

..

23 Commits

Author SHA1 Message Date
kusowl
0c349d816c fix: show empty cart if cart is empty 2026-03-25 15:48:14 +05:30
kusowl
aa35fe44b3 feature: implement payment verification, make cart as converted after successful payment 2026-03-25 15:24:39 +05:30
kusowl
85b0fbf499 feature: implement payment method selector and checkout confirmation page 2026-03-24 18:52:06 +05:30
kusowl
4546d309b8 refactor: synchronize routes with checkout steps, add loading spinner in action buttons 2026-03-19 16:38:05 +05:30
kusowl
bb3aafd89e feature: show order summary on address page 2026-03-17 17:05:49 +05:30
kusowl
419e8281e2 BREAKING CHANGE: change obervable name from cartItem$ to cartItems$ 2026-03-17 16:10:05 +05:30
kusowl
24bdfe9cc6 feature: fetch, edit and add address
- fetch existing addresses from api,
- user can edit existing address
- user can add new address
2026-03-17 10:58:22 +05:30
kusowl
3059a923b4 refactor: move auth service to core 2026-03-16 13:05:06 +05:30
kusowl
6d1cb81e6b wip: checkout page
- add button to go address page in cart ui
- add template for address page
2026-03-13 18:54:48 +05:30
kusowl
50c956c051 fix: make header logo navigation by router 2026-03-12 10:43:38 +05:30
kusowl
ad957efcf0 feature: add to cart
- make the cart service dependable on BehavorialSubject, migrated from
siganls
- implement add to cart service
2026-03-11 19:00:24 +05:30
kusowl
27a04c6458 minor: some quick design and color changes
make the dropdown hover color from gradient to simple gray shade
make the button 3d
change the product card design in home page
add add to cart button in the home page
change design of button ghost
2026-03-11 11:25:07 +05:30
kusowl
2b88cee10b feature: user can change quantity and remove products 2026-03-10 19:07:42 +05:30
kusowl
9000ea0052 feature: fetch cart products from api
- show total cart item count on header
- fetch and show cart items on cart modal
2026-03-09 19:04:31 +05:30
kusowl
3c2233d53e fix: make favorite button in product show page sync with db 2026-03-05 18:08:52 +05:30
kusowl
0f56303d59 fix sanctum and session environment variables 2026-03-05 14:43:21 +05:30
kusowl
a4eebef321 Merge branch 'feature/products' into staging 2026-03-05 13:48:30 +05:30
kusowl
a57566c1fe Merge branch 'backend' into staging 2026-03-05 13:48:24 +05:30
kusowl
7e1ecf35b9 make favorite state persistant with api 2026-03-05 13:34:37 +05:30
kusowl
ae008fbc9c chore: add isFavorite in produtcs response
- refactor code to use query
- add active column in products
2026-03-05 13:32:49 +05:30
kusowl
8ef4383bd9 feature: endpoint to favorite product 2026-03-05 10:32:40 +05:30
kusowl
b575b42f22 Merge remote-tracking branch 'origin/feature/products' into fix/history-issue
# Conflicts:
#	src/app/features/product/components/product-card/product-card.html
#	src/app/features/product/components/product-card/product-card.ts
#	src/app/features/product/services/product-service.ts
2026-03-03 17:40:12 +05:30
kusowl
553637d8e2 fix: commit whole changes 2026-03-03 17:27:30 +05:30
108 changed files with 2033 additions and 94 deletions

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

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

2
.gitignore vendored
View File

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

3
.phpactor.json Normal file
View File

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

1
.rgignore Normal file
View File

@ -0,0 +1 @@
backend/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
<svg height="1524" viewBox="55.2 38.3 464.5 287.8" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m519.7 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.2-143.9 143.5-143.9 143.7 64.4 143.7 143.9z" fill="#f79f1a"/><path d="m342.4 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.3-143.9 143.6-143.9 143.6 64.4 143.6 143.9z" fill="#ea001b"/><path d="m287.4 68.9c-33.5 26.3-55 67.3-55 113.3s21.5 87 55 113.3c33.5-26.3 55-67.3 55-113.3s-21.5-86.9-55-113.3z" fill="#ff5f01"/></svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -0,0 +1 @@
<svg width="2500" height="1045" viewBox="0 0 512 214" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774zm96.142-66.986l-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862zm71.112 41.386L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524zm38.257-10.524h35.698v124.444h-35.698V47.36zm0-10.809l35.698-7.68V0l-35.698 7.538V36.55zm109.938 8.391c-13.938 0-22.898 6.542-27.875 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716zm-8.533 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28zM512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756h-36.694z" fill="#6772E5"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg height="812" viewBox="0.5 0.5 999 323.684" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="M651.185.5c-70.933 0-134.322 36.766-134.322 104.694 0 77.9 112.423 83.28 112.423 122.415 0 16.478-18.884 31.229-51.137 31.229-45.773 0-79.984-20.611-79.984-20.611l-14.638 68.547s39.41 17.41 91.734 17.41c77.552 0 138.576-38.572 138.576-107.66 0-82.316-112.89-87.537-112.89-123.86 0-12.91 15.501-27.053 47.662-27.053 36.286 0 65.892 14.99 65.892 14.99l14.326-66.204S696.614.5 651.185.5zM2.218 5.497L.5 15.49s29.842 5.461 56.719 16.356c34.606 12.492 37.072 19.765 42.9 42.353l63.51 244.832h85.138L379.927 5.497h-84.942L210.707 218.67l-34.39-180.696c-3.154-20.68-19.13-32.477-38.685-32.477H2.218zm411.865 0L347.449 319.03h80.999l66.4-313.534h-80.765zm451.759 0c-19.532 0-29.88 10.457-37.474 28.73L709.699 319.03h84.942l16.434-47.468h103.483l9.994 47.468H999.5L934.115 5.497h-68.273zm11.047 84.707l25.178 117.653h-67.454z" fill="#1434cb"/></svg>

After

Width:  |  Height:  |  Size: 945 B

View File

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

View File

@ -7,7 +7,6 @@ export const routes: Routes = [
{
path: "",
component: Home,
canActivate: [authGuard],
},
{
path: "",
@ -20,4 +19,9 @@ 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 "../../features/auth/services/auth-service";
import { AuthService } from "@core/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 "../../features/auth/services/auth-service";
import { AuthService } from "@core/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-gray-800 text-white" href="/">eKart</a>
<a class="px-3 py-1 bg-blue-600 text-white" routerLink="/">eKart</a>
</div>
<div class="flex-1 grid grid-cols-[1fr_auto]">
@ -20,18 +20,24 @@
<div class="flex space-x-4">
<div class="flex text-gray-600">
<button
class="btn btn-ghost py-1 px-2 rounded-r-none!"
class="btn btn-ghost py-1 px-3 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-2 rounded-l-none! border-l-0!">
<button
class="btn btn-ghost py-1 px-3 rounded-l-none! border-l-0! relative"
popovertarget="popover-2"
style="anchor-name: --anchor-2"
>
<lucide-angular [img]="CartIcon" class="w-5" />
<span class="absolute top-0 text-xs ml-1">{{ cartItemCount | async }}</span>
</button>
</div>
</div>
</nav>
<ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1">
@if (authService.authState() === AuthState.Unauthenticated) {
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
@ -45,4 +51,12 @@
<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,11 +1,16 @@
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, RouterLink],
imports: [LucideAngularModule, Cart, AsyncPipe, RouterLink],
templateUrl: "./header.html",
styleUrl: "./header.css",
})
@ -14,5 +19,8 @@ export class Header {
readonly CartIcon = ShoppingCart;
readonly SearchIcon = Search;
readonly authService = inject(AuthService);
readonly cartService = inject(CartService);
cartItems$ = this.cartService.cartItems$;
cartItemCount = this.cartItems$.pipe(map((cart: CartModel) => cart.itemsCount ?? 0));
protected readonly AuthState = AuthState;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { effect, inject, Injectable } from "@angular/core";
import { API_URL } from "../tokens/api-url-tokens";
import { CartItemRequest, CartModel } from "../models/cart.model";
import { AuthService } from "@core/services/auth-service";
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);
}
});
}
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

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

View File

@ -0,0 +1,20 @@
import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import { API_URL } from "../tokens/api-url-tokens";
export interface FavoriteResponse {
message: string;
isFavorite: boolean;
}
@Injectable({
providedIn: "root",
})
export class FavoriteService {
http = inject(HttpClient);
apiUrl = inject(API_URL);
toggle(productId: number) {
return this.http.post<FavoriteResponse>(`${this.apiUrl}/products/${productId}/favorite`, {});
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class SessionStorageService {
setItem<T>(key: string, value: T) {
try {
const item = JSON.stringify(value);
sessionStorage.setItem(key, item);
} catch (e) {
console.error("Could not set item", e);
}
}
getItem<T>(key: string): T | null {
try {
const item = sessionStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
} catch (e) {
console.error("Could not get item", e);
return null;
}
}
/**
* @throws Error if key is not found.
* @param key
*/
removeItem(key: string): void {
sessionStorage.removeItem(key);
}
clear(): void {
sessionStorage.clear();
}
}

View File

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

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 "./services/auth-service";
import { AuthService } from "@core/services/auth-service";
export const AuthRoutes: Routes = [
{

View File

@ -1,12 +1,12 @@
import { Component, inject } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "../../services/auth-service";
import { Error } from "../../../../shared/components/error/error";
import { AuthService } from "@core/services/auth-service";
import { Error } from "@app/shared/components/error/error";
@Component({
selector: "app-login",
imports: [RouterLink, ReactiveFormsModule, Error],
imports: [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 "../../services/auth-service";
import { RegisterUserRequest } from "../../../../core/models/user.model";
import { Error } from "../../../../shared/components/error/error";
import { AuthService } from "@core/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

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

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

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

View File

@ -0,0 +1,63 @@
<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 && currentStepNumber() < 3) {
<app-go-back
[route]="steps[currentStepNumber() - 1].route"
[text]="steps[currentStepNumber() - 1 ].label"
class="ml-4 block"
/>
}
<div [class.md:grid-cols-6]="currentStepNumber() < 3" class="grid gap-10">
<div class="md:col-span-4">
<router-outlet />
</div>
@if (currentStepNumber() < 3) {
<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>
} @if (paymentMethodControl.invalid && paymentMethodControl.touched) {
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
Please select an payment checkout
</div>
} @if (currentStepNumber() === 1) {
<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>
} @if (currentStepNumber() === 2) {
<button
(click)="proceedToCheckout()"
[disabled]="paymentMethodControl.invalid && paymentMethodControl.touched || proceedToPaymentLoading()"
class="btn btn-primary w-full mt-4"
>
<app-loading-spinner [isLoading]="proceedToPaymentLoading()">
Proceed to checkout
</app-loading-spinner>
</button>
}
</div>
</div>
}
</div>
</section>
</section>

View File

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

View File

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

@ -0,0 +1,109 @@
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 { CheckoutResponse, 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: "Confirmation", route: "/checkout/confirmation" },
];
destroyRef = inject(DestroyRef);
orderCreationLoading = signal(false);
proceedToPaymentLoading = signal(false);
private addressService = inject(AddressService);
addressIdControl = this.addressService.addressIdControl;
private orderService = inject(OrderService);
paymentMethodControl = this.orderService.paymentMethodForm;
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"]);
});
}
protected proceedToCheckout() {
if (this.paymentMethodControl.invalid) {
this.paymentMethodControl.markAsTouched();
return;
}
this.proceedToPaymentLoading.set(true);
this.orderService
.checkout(this.paymentMethodControl.value!)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => this.proceedToPaymentLoading.set(false)),
)
.subscribe((response) => this.handleCheckout(response));
}
private handleCheckout(data: CheckoutResponse): void {
if (data.success) {
if (data.method === "stripeCheckout") {
this.handleStripeCheckout(data);
}
}
}
private handleStripeCheckout(data: CheckoutResponse) {
console.log(data);
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
}
}

View File

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

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

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

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

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

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

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

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

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

@ -0,0 +1,3 @@
<div class="card min-h-20">
<ng-content />
</div>

View File

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

View File

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

View File

@ -0,0 +1,47 @@
<div class="flex flex-col gap-4 items-center justify-center">
<app-loading-spinner [isLoading]="isProcessing()">
<div class="card max-w-md text-center flex items-center flex-col gap-4">
<div
[class.bg-green-100]="paymentData()!.isSuccess"
[class.bg-red-100]="!paymentData()!.isSuccess"
class="rounded-full p-4"
>
@if (paymentData()!.isSuccess) {
<lucide-angular [img]="BadgeCheck" class="w-6 h-6 text-green-500" />
} @else {
<lucide-angular [img]="BadgeQuestionMark" class="w-6 h-6 text-red-500" />
}
</div>
<p
[class.text-green-500]="paymentData()!.isSuccess"
[class.text-red-500]="!paymentData()!.isSuccess"
class="text-xl font-medium"
>
{{ paymentData()!.message }}
</p>
<p class="text-sm text-gray-400">
@if (paymentData()!.isSuccess) { Your payment processed successfully. You will receive a
confirmation email shortly. } @else { If money is debited then wait for few hours then check
payment status. }
</p>
@if (paymentData()!.isSuccess) {
<article class="rounded-xl bg-gray-100 px-3 py-4 text-gray-500 text-sm w-full space-y-4">
<article class="flex justify-between border-b border-b-gray-200 pb-4">
<p class="font-medium">Amount</p>
<p>Rs.{{ paymentData()?.amount }}</p>
</article>
<article class="flex justify-between">
<p class="font-medium">Transaction ID</p>
<p class="truncate">{{ paymentData()?.transactionId }}</p>
</article>
<article class="flex justify-between">
<p class="font-medium">Payment method</p>
<p>{{ paymentData()?.paymentMethod | uppercase }}</p>
</article>
</article>
}
</div>
</app-loading-spinner>
<app-go-back class="w-min-content! flex-nowrap" route="/" text="Continue Shopping" />
</div>

View File

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

View File

@ -0,0 +1,73 @@
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { BadgeCheck, BadgeQuestionMark, LucideAngularModule } from "lucide-angular";
import { GoBack } from "@shared/components/go-back/go-back";
import { ActivatedRoute } from "@angular/router";
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
import {
OrderService,
PaymentVerificationResponse,
} from "@app/features/checkout/services/order-service";
import { finalize, tap } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UpperCasePipe } from "@angular/common";
import { CartService } from "@core/services/cart-service";
@Component({
selector: "app-confirmation",
imports: [LucideAngularModule, GoBack, LoadingSpinner, UpperCasePipe],
templateUrl: "./confirmation.html",
styleUrl: "./confirmation.css",
})
export default class Confirmation implements OnInit {
destroyRef = inject(DestroyRef);
protected paymentData = signal<PaymentVerificationResponse | null>(null);
protected isProcessing = signal(false);
protected readonly BadgeCheck = BadgeCheck;
protected readonly BadgeQuestionMark = BadgeQuestionMark;
private route = inject(ActivatedRoute);
private orderService = inject(OrderService);
private cartService = inject(CartService);
private orderId: string | null = null;
private sessionId: string | null = null;
ngOnInit() {
this.orderId = this.route.snapshot.queryParamMap.get("order_id");
this.sessionId = this.route.snapshot.queryParamMap.get("session_id");
this.checkConfirmation();
}
private checkConfirmation() {
if (!this.orderId || !this.sessionId) {
console.error("Missing order ID or session ID");
return;
}
try {
this.isProcessing.set(true);
const orderId = parseInt(this.orderId!);
this.orderService
.verifyPayment(orderId, this.sessionId!)
.pipe(
tap((data) => {
this.paymentData.set(data);
if (data && data.isSuccess) {
this.cartService.fetchCart();
}
}),
finalize(() => this.isProcessing.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: () => {
console.log("Payment verified successfully");
},
error: (err) => {
console.error(err);
},
});
} catch (e) {
this.isProcessing.set(false);
console.error(e);
return;
}
}
}

View File

@ -0,0 +1,51 @@
<div class="flex flex-col gap-4">
<app-payment-method-card>
<div class="flex gap-4">
<input
(change)="onPaymentMethodSelected('stripeCheckout')"
[formControl]="paymentMethodForm"
name="payment-method"
type="radio"
value="stripeCheckout"
/>
<div class="flex-1">
<div class="flex justify-between mb-2">
<div class="">
<img alt="" class="w-25" src="/assets/images/stripe-4.svg" />
</div>
<div class="flex gap-2">
<div class="card p-3! flex justify-center items-center">
<img alt="" class="h-3" src="/assets/images/visa-10.svg" />
</div>
<div class="card p-3! flex justify-center items-center">
<img alt="" class="h-5" src="/assets/images/mastercard-modern-design-.svg" />
</div>
</div>
</div>
<p class="text-sm text-gray-600">Pay using card via stripe checkout.</p>
</div>
</div>
</app-payment-method-card>
<app-payment-method-card>
<div class="flex gap-4">
<input
(change)="onPaymentMethodSelected('cashOnDelivery')"
[formControl]="paymentMethodForm"
name="payment-method"
type="radio"
value="cashOnDelivery"
/>
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<div class="text-gray-700">
<lucide-angular [img]="coinsIcon" class="h-10" />
</div>
<div class="">
<p class="text-xl font-medium text-gray-700">Cash on Delivery</p>
</div>
</div>
<p class="text-sm text-gray-600">Additional charges may apply.</p>
</div>
</div>
</app-payment-method-card>
</div>

View File

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

@ -0,0 +1,28 @@
import { Component, inject, OnInit } from "@angular/core";
import { PaymentMethodCard } from "@app/features/checkout/components/payment-method-card/payment-method-card";
import { Coins, LucideAngularModule } from "lucide-angular";
import { ReactiveFormsModule } from "@angular/forms";
import { OrderService } from "@app/features/checkout/services/order-service";
import { ActivatedRoute } from "@angular/router";
@Component({
selector: "app-payment",
imports: [PaymentMethodCard, LucideAngularModule, ReactiveFormsModule],
templateUrl: "./payment.html",
styleUrl: "./payment.css",
})
export class Payment implements OnInit {
coinsIcon = Coins;
private orderService = inject(OrderService);
protected paymentMethodForm = this.orderService.paymentMethodForm;
private route = inject(ActivatedRoute);
private orderId: string | null = null;
ngOnInit() {
this.orderId = this.route.snapshot.paramMap.get("order_id");
}
onPaymentMethodSelected(paymentMethod: string) {
this.paymentMethodForm.setValue(paymentMethod);
}
}

View File

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

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

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

@ -0,0 +1,79 @@
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";
import { FormControl, Validators } from "@angular/forms";
import { tap } from "rxjs";
import { SessionStorageService } from "@core/services/session-storage.service";
export interface PaymentVerificationResponse {
isSuccess: boolean;
message: string;
amount: number | null;
transactionId: string | null;
paymentMethod: string | null;
}
export interface CheckoutResponse {
success: boolean;
amount: number;
currency: string;
method: string;
redirectUrl: string | null;
errorMessage: string | null;
}
export interface OrderRequest {
addressId: number;
cartId: number;
}
export interface OrderResponse {
orderId: number;
message: string;
}
@Injectable({
providedIn: "root",
})
export class OrderService {
public paymentMethodForm = new FormControl<string | null>(null, Validators.required);
private currentOrderId: number | null = null;
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
private authService = inject(AuthService);
private user = this.authService.user;
private sessionStorage = inject(SessionStorageService);
constructor() {
const cachedOrderId = this.sessionStorage.getItem<number>("orderId");
if (cachedOrderId) {
this.currentOrderId = cachedOrderId;
}
}
createOrder(data: OrderRequest) {
return this.http
.post<OrderResponse>(`${this.apiUrl}/users/${this.user()?.id}/orders`, data)
.pipe(
tap((response) => {
this.currentOrderId = response.orderId;
this.sessionStorage.setItem<number>("orderId", response.orderId);
}),
);
}
checkout(mode: string) {
return this.http.post<CheckoutResponse>(
`${this.apiUrl}/orders/${this.currentOrderId}/payments`,
{
mode: mode,
},
);
}
verifyPayment(orderId: number, sessionId: string) {
return this.http.post<PaymentVerificationResponse>(`${this.apiUrl}/payments/verify`, {
orderId: orderId,
sessionId: sessionId,
});
}
}

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<p class="text-3xl text-gray-700 font-sans">Our Product</p>
</section>
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4 wrapper">
<section class="mt-10 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-4 wrapper">
@for (product of products(); track product) {
<app-product-card [product]="product" />
}

View File

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

View File

@ -16,28 +16,27 @@
alt="Product Image"
class="w-full h-full object-cover object-center rounded-lg"
/>
<button
class="absolute top-2 right-2 p-2! rounded-full! btn btn-ghost border-none! bg-gray-50"
>
<lucide-angular [img]="HeartIcon" class="w-4 h-4" />
</button>
<app-favorite-button
[productId]="product()!.id"
class="absolute top-5 right-5"
[isFavorite]="product()!.isFavorite"
/>
<button
(click)="prevImage()"
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost"
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
>
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4" />
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4 text-gray-200" />
</button>
<button
(click)="nextImage()"
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost"
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
>
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4" />
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4 text-gray-200" />
</button>
</div>
<div class="grid grid-cols-4 gap-3">
@for (image of product()?.productImages; track image) {
<button
class="card p-2! aspect-square cursor-pointer"
(click)="this.activeImageIndex.set($index)"
@ -91,38 +90,16 @@
<div class="lg:col-span-3 flex flex-col gap-6">
<div class="card">
<div class="flex justify-between items-start mb-6">
<div class="flex items-baseline gap-2">
<span class="text-xl text-gray-400 line-through decoration-1"
>Rs.{{ product()?.actualPrice }}</span
>
<span class="text-3xl font-bold text-gray-900">Rs.{{ product()?.listPrice }}</span>
<span class="text-xs text-gray-400 ml-1"></span>
<div class="">
<p class="text-sm text-gray-400 line-through decoration-1">
Rs.{{ product()?.listPrice }}
</p>
<p class="text-3xl font-bold text-gray-900">Rs.{{ product()?.actualPrice }}</p>
</div>
<button class="text-gray-400 hover:text-red-500">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
></path>
</svg>
</button>
</div>
<div class="flex mb-6 overflow-hidden">
<button class="px-4 py-2 btn btn-ghost rounded-r-none!"></button>
<input
type="text"
value="1"
class="w-12 text-center btn btn-ghost rounded-none! border-x-0!"
readonly
/>
<button class="px-4 py-2 btn btn-ghost rounded-l-none!">+</button>
</div>
<div class="flex flex-col gap-3">
<button class="w-full btn btn-ghost">Add to Cart</button>
<button (click)="addToCart()" class="w-full btn btn-ghost">Add to Cart</button>
<button class="w-full btn btn-primary">Buy Now</button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1,36 @@
<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.cursor-block]="isLoading()"
[class.opacity-40]="isLoading()"
[class.pointer-events-none]="isLoading()"
class="rounded-none!"
>
@for (item of cart.items; track item.id) {
<app-cart-item
(productDeleteEvent)="removeProduct($event)"
(qtyChangeEvent)="updateProductQty($event)"
[cartItem]="item"
/>
}
</ol>
@if (cart.itemsCount > 0) {
<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>
}
@else{
<li><a class="block h-full w-full">Cart is empty !</a></li>
}
}
</ul>

View File

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

View File

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

@ -0,0 +1,13 @@
<!--Favorite button -->
<button
(click)="$event.stopPropagation(); toggleFavorite($event)"
class="transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full flex items-center justify-center"
>
<lucide-angular
[img]="HeartIcon"
[class.text-gray-500]="!isFavorite"
[class.text-red-400]="isFavorite"
[class]="isFavorite ? 'fill-red-500' : 'fill-none'"
class="w-4 h-4 transition-colors duration-200"
/>
</button>

View File

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

View File

@ -0,0 +1,34 @@
import { Component, inject, Input } from "@angular/core";
import { HeartIcon, LucideAngularModule } from "lucide-angular";
import { FavoriteService } from "../../../core/services/favorite-service";
@Component({
selector: "app-favorite-button",
imports: [LucideAngularModule],
templateUrl: "./favorite-button.html",
styleUrl: "./favorite-button.css",
})
export class FavoriteButton {
@Input({ required: true }) productId!: number;
@Input() isFavorite = false;
favoriteService = inject(FavoriteService);
HeartIcon = HeartIcon;
toggleFavorite(event: Event) {
event.stopPropagation();
this.isFavorite = !this.isFavorite;
this.favoriteService.toggle(this.productId).subscribe({
next: (response) => {
this.isFavorite = response.isFavorite;
},
error: (err) => {
console.error(err);
// Revert the state incase of error
this.isFavorite = !this.isFavorite;
},
});
}
}

View File

@ -0,0 +1,4 @@
<a [routerLink]="route" class="flex space-x-2 w-max 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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More