Compare commits

...

35 Commits

Author SHA1 Message Date
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
kusowl
f5393f5110 feature: show products on the home page and add individual product page 2026-03-02 18:47:43 +05:30
kusowl
068975d3b0 Merge branch 'backend' 2026-02-27 18:22:36 +05:30
kusowl
a34bea34d4 Merge branch 'feature/add-product' 2026-02-27 18:17:35 +05:30
kusowl
8b1b831ea2 feature: authorization - add role guard and protect products route 2026-02-27 13:22:02 +05:30
kusowl
617053c0ee feature: upload images and show alert after successfull product creation 2026-02-26 19:02:39 +05:30
kusowl
bb05fb7747 feature: add product page
- add UI
- add dialog for preview selected images
2026-02-25 19:04:00 +05:30
kusowl
03525280db Merge branch 'feature/main/login' 2026-02-24 18:49:45 +05:30
kusowl
6e2fd45803 Merge branch 'backend' 2026-02-24 18:49:33 +05:30
kusowl
4a4c8bd4e3 feature: user logout and auth states
added s authState which helps conditonaly render components based on this state

stored user details in localStoarge so that server side end point does not get hit in every page load.

add a guard which protects routes and redirects to login if user is not logged in.

create a logout route
2026-02-24 18:14:21 +05:30
kusowl
043d54bcd0 user can login via frontend 2026-02-23 18:53:54 +05:30
kusowl
0427d1c62d feature: user can login to backend 2026-02-23 18:46:23 +05:30
kusowl
aee7e4fd89 navigate user after successful registration.
- data is passed via state
2026-02-23 16:10:59 +05:30
kusowl
4aba99fcb5 chore: format 2026-02-23 15:08:06 +05:30
kusowl
78bf326622 add client side validation errors 2026-02-23 15:07:43 +05:30
kusowl
77532aaac2 Show server side validation errors 2026-02-23 12:17:14 +05:30
138 changed files with 2910 additions and 178 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 testem.log
/typings /typings
__screenshots__/ __screenshots__/
*.cache
.php-cs-fixer.dist.php
# System files # System files
.DS_Store .DS_Store

3
.oxfmtrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"ignorePatterns": ["backend/**", "*.min.js"]
}

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", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm" "packageManager": "npm",
"analytics": false
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {

View File

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

View File

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

View File

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

View File

@ -2,8 +2,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -28,6 +31,17 @@ public function images(): HasMany
return $this->hasMany(ProductImage::class, 'product_id', 'id'); return $this->hasMany(ProductImage::class, 'product_id', 'id');
} }
public function favoritedBy(): BelongsToMany
{
return $this->belongsToMany(User::class, 'favorite_products');
}
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('is_active', true);
}
protected static function booted(): void protected static function booted(): void
{ {
static::saving(function ($product) { static::saving(function ($product) {
@ -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 class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasFactory;
use Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -50,4 +52,14 @@ protected function casts(): array
'role' => UserRoles::class, 'role' => UserRoles::class,
]; ];
} }
public function favoriteProducts()
{
return $this->belongsToMany(Product::class, 'favorite_products', 'user_id', 'product_id');
}
public function hasFavorited(Product $product): bool
{
return $this->favoriteProducts()->where('product_id', $product->id)->exists();
}
} }

View File

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

View File

@ -1,8 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from "@angular/core"; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from "@angular/core";
import { provideRouter } from "@angular/router"; import { provideRouter, withComponentInputBinding } from "@angular/router";
import { routes } from "./app.routes"; import { routes } from "./app.routes";
import { provideHttpClient, withFetch, withInterceptors } from "@angular/common/http";
import { csrfInterceptor } from "./core/interceptors/csrf-interceptor";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([csrfInterceptor])),
],
}; };

View File

@ -1,3 +1,7 @@
<div class="flex min-h-screen flex-col antialiased m-0">
<app-header /> <app-header />
<main class="flex-1">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main>
<app-footer /> <app-footer />
</div>

View File

@ -1,5 +1,7 @@
import { Routes } from "@angular/router"; import { Routes } from "@angular/router";
import { Home } from "./features/home/home"; import { Home } from "./features/home/home";
import { authGuard } from "./core/guards/auth-guard";
import { roleGuard } from "./core/guards/role-guard";
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -10,4 +12,16 @@ export const routes: Routes = [
path: "", path: "",
loadChildren: () => import("./features/auth/auth.routes").then((routes) => routes.AuthRoutes), loadChildren: () => import("./features/auth/auth.routes").then((routes) => routes.AuthRoutes),
}, },
{
path: "products",
loadChildren: () =>
import("./features/product/product.routes").then((routes) => routes.productRoutes),
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 { Component, signal } from "@angular/core"; import { Component, signal } from "@angular/core";
import { RouterOutlet } from "@angular/router"; import { RouterOutlet } from "@angular/router";
import { Products } from "./features/home/products/products"; import { Product } from "./features/product/product";
import { Footer } from "./core/layouts/footer/footer"; import { Footer } from "./core/layouts/footer/footer";
import { Header } from "./core/layouts/header/header"; import { Header } from "./core/layouts/header/header";

View File

@ -0,0 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { CanActivateFn } from "@angular/router";
import { authGuard } from "./auth-guard";
describe("authGuard", () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it("should be created", () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@ -0,0 +1,11 @@
import { CanActivateFn, Router } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
if (authService.isAuthenticated()) return true;
return router.navigate(["/login"]);
};

View File

@ -0,0 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { CanActivateFn } from "@angular/router";
import { roleGuard } from "./role-guard";
describe("roleGuard", () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => roleGuard(...guardParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it("should be created", () => {
expect(executeGuard).toBeTruthy();
});
});

View File

@ -0,0 +1,12 @@
import { CanActivateFn } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
export const roleGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
// get role from route data passed in route config.
const roles = route.data["roles"] as string[];
return roles && authService.hasRoles(roles);
};

View File

@ -0,0 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { HttpInterceptorFn } from "@angular/common/http";
import { csrfInterceptor } from "./csrf-interceptor";
describe("authInterceptor", () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => csrfInterceptor(req, next));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it("should be created", () => {
expect(interceptor).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { HttpInterceptorFn } from "@angular/common/http";
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
const getCookie = (name: string): string | null => {
const match = document.cookie.match(new RegExp("(^|;\\s*)(" + name + ")=([^;]*)"));
return match ? decodeURIComponent(match[3]) : null;
};
let headers = req.headers.set("Accept", "application/json");
const xsrfToken = getCookie("XSRF-TOKEN");
if (xsrfToken) {
headers = headers.set("X-XSRF-TOKEN", xsrfToken);
}
const clonedRequest = req.clone({
withCredentials: true,
headers: headers,
});
return next(clonedRequest);
};

View File

@ -18,7 +18,7 @@
<div class="footer-links"> <div class="footer-links">
<a href="">Home</a> <a href="">Home</a>
<a href="">Products</a> <a href="">Product</a>
<a href="">About Us</a> <a href="">About Us</a>
<a href="">Contact Us</a> <a href="">Contact Us</a>
</div> </div>

View File

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

View File

@ -1,8 +1,15 @@
import { Component } from "@angular/core"; import { Component, inject } from "@angular/core";
import { LucideAngularModule, User, ShoppingCart, Search } from "lucide-angular"; 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";
@Component({ @Component({
selector: "app-header", selector: "app-header",
imports: [LucideAngularModule], imports: [LucideAngularModule, Cart, AsyncPipe],
templateUrl: "./header.html", templateUrl: "./header.html",
styleUrl: "./header.css", styleUrl: "./header.css",
}) })
@ -10,4 +17,10 @@ export class Header {
readonly UserIcon = User; readonly UserIcon = User;
readonly CartIcon = ShoppingCart; readonly CartIcon = ShoppingCart;
readonly SearchIcon = Search; 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

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

@ -0,0 +1,25 @@
export interface PaginatedResponse<T> {
data: T[];
links: {
next: string | null;
prev: string | null;
last: string | null;
first: string | null;
};
meta: {
total: number;
per_page: number;
current_page: number;
last_page: number;
from: number;
to: number;
links: links[];
};
}
interface links {
url: string | null;
label: string;
active: boolean;
page: number | null;
}

View File

@ -0,0 +1,17 @@
import { PaginatedResponse } from "./paginated.model";
import { Category } from "../../features/product/services/category-service";
export interface ProductModel {
id: number;
title: string;
slug: string;
description: string;
actualPrice: number;
listPrice: number;
category: Category;
productImages: string[];
updatedAt: string;
isFavorite: boolean;
}
export interface ProductCollection extends PaginatedResponse<ProductModel> {}

View File

@ -6,3 +6,12 @@ export interface RegisterUserRequest{
password_confirmation: string | null; password_confirmation: string | null;
city: string | null; city: string | null;
} }
export interface User {
id: number;
name: string;
email: string;
mobileNumber: string;
city: string;
role: string;
}

View File

@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from "@angular/core/testing";
import { AuthService } from './auth-service'; import { AuthService } from "./auth-service";
describe('AuthService', () => { describe("AuthService", () => {
let service: AuthService; let service: AuthService;
beforeEach(() => { beforeEach(() => {
@ -10,7 +10,7 @@ describe('AuthService', () => {
service = TestBed.inject(AuthService); service = TestBed.inject(AuthService);
}); });
it('should be created', () => { it("should be created", () => {
expect(service).toBeTruthy(); expect(service).toBeTruthy();
}); });
}); });

View File

@ -0,0 +1,112 @@
import { computed, inject, Injectable, Signal, signal, WritableSignal } from "@angular/core";
import { RegisterUserRequest, User } from "../models/user.model";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { API_URL, BACKEND_URL } from "../tokens/api-url-tokens";
import { switchMap, tap } from "rxjs";
import { LocalStorageService } from "../services/local-storage.service";
export enum AuthState {
Loading = "loading",
Authenticated = "authenticated",
Unauthenticated = "unauthenticated",
}
/**
* UserService - manages user related operations
*
* ## Auth States
* - loading: user is being fetched from the server
* - authenticated: user is authenticated
* - unauthenticated: user is not authenticated
*/
@Injectable({
providedIn: "root",
})
export class AuthService {
// User states
readonly authState: WritableSignal<AuthState>;
readonly user: WritableSignal<User | null>;
readonly userRole: Signal<string | null>;
// Computed state for easy checking
readonly isAuthenticated: Signal<boolean>;
// Dependent services
private localStorage = inject(LocalStorageService);
private http: HttpClient = inject(HttpClient);
// Constants
private readonly userKey = "ekart_user";
private apiUrl = inject(API_URL);
private backendURL = inject(BACKEND_URL);
constructor() {
const cachedUser = this.localStorage.getItem<User>(this.userKey);
this.authState = signal<AuthState>(
cachedUser ? AuthState.Authenticated : AuthState.Unauthenticated,
);
this.user = signal<User | null>(cachedUser);
this.isAuthenticated = computed(() => !!this.user());
this.userRole = computed(() => this.user()?.role || null);
}
register(userRequest: RegisterUserRequest) {
return this.http.post(`${this.apiUrl}/register`, userRequest);
}
/**
* Laravel API expects the csrf cookie to be set before making a request.
* First set the cookie then attempt to login.
* If the login is successful, set the user in the state.
*/
login(credentials: { email: string; password: string }) {
return this.getCsrfCookie().pipe(
switchMap(() =>
this.http.post(`${this.backendURL}/login`, credentials, { observe: "response" }),
),
switchMap(() => this.getCurrentUser()),
);
}
getCurrentUser() {
return this.http.get<User>(`${this.apiUrl}/user`).pipe(
tap({
next: (user) => this.setAuth(user),
error: (error: HttpErrorResponse) => this.purgeAuth(),
}),
);
}
/**
* Check if current user has the role.
* Mostly used in role guard.
*/
hasRoles(roles: string[]) {
const role = this.userRole();
if (!role) return false;
return roles.includes(role);
}
logout() {
return this.http.post(`${this.backendURL}/logout`, {}).pipe(
tap({
next: () => this.purgeAuth(),
}),
);
}
private getCsrfCookie() {
return this.http.get(`${this.backendURL}/sanctum/csrf-cookie`);
}
private setAuth(user: User) {
this.localStorage.setItem<User>(this.userKey, user);
this.user.set(user);
this.authState.set(AuthState.Authenticated);
}
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,61 @@
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { effect, inject, Injectable, signal } from "@angular/core";
import { API_URL } from "../tokens/api-url-tokens";
import { CartItemModel, CartItemRequest, CartModel } from "../models/cart.model";
import { AuthService, AuthState } from "@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

@ -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,39 @@
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class LocalStorageService {
setItem<T>(key: string, value: T) {
try {
const item = JSON.stringify(value);
localStorage.setItem(key, item);
} catch (e) {
console.error("Error storing item in local storage: ", e);
}
}
getItem<T>(key: string): T | null {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
} catch (err) {
console.error("Error getting item from local storage: ", err);
return null;
}
}
/**
* @throws Error if item is not found
*/
removeItem(key: string) {
localStorage.removeItem(key);
}
/**
* @Throws Error if localstorage API is not available
*/
clear() {
localStorage.clear();
}
}

View File

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

View File

@ -1,7 +1,12 @@
import { InjectionToken } from "@angular/core"; import { InjectionToken } from "@angular/core";
import {environment} from '../../../environments/environment'; import { environment } from "../../../environments/environment";
export const API_URL = new InjectionToken<string>('API_URL', { export const API_URL = new InjectionToken<string>("API_URL", {
providedIn: "root", providedIn: "root",
factory: () => environment.apiUrl factory: () => environment.apiUrl,
}) });
export const BACKEND_URL = new InjectionToken<string>("API_URL", {
providedIn: "root",
factory: () => environment.backendUrl,
});

View File

@ -1,6 +1,8 @@
import { Routes } from "@angular/router"; import { Router, Routes } from "@angular/router";
import { Login } from "./components/login/login"; import { Login } from "./components/login/login";
import { Register } from "./components/register/register"; import { Register } from "./components/register/register";
import { inject } from "@angular/core";
import { AuthService } from "@core/services/auth-service";
export const AuthRoutes: Routes = [ export const AuthRoutes: Routes = [
{ {
@ -14,6 +16,19 @@ export const AuthRoutes: Routes = [
path: "register", path: "register",
component: Register, component: Register,
}, },
{
path: "logout",
component: Login,
canActivate: [
() => {
const authService = inject(AuthService);
const router = inject(Router);
authService.logout().subscribe(() => router.navigate(["/login"]));
return false;
},
],
},
], ],
}, },
]; ];

View File

@ -1,4 +1,11 @@
<section class="my-10 sm:my-20 flex justify-center items-center"> @if (successMessage) {
<div
class="px-4 py-3 mb-8 bg-teal-100 rounded-lg text-teal-800 text-sm mt-10 max-w-11/12 sm:max-w-8/12 mx-auto"
>
<p>{{successMessage}}</p>
</div>
}
<section class="my-5 sm:my-10 flex justify-center items-center">
<article class="card max-w-11/12 sm:max-w-8/12 grid md:grid-cols-2 lg:grid-cols-3 gap-4"> <article class="card max-w-11/12 sm:max-w-8/12 grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="md:col-span-1 lg:col-span-2"> <div class="md:col-span-1 lg:col-span-2">
<img <img
@ -13,21 +20,25 @@
<h2 class="text-xl text-gray-600">Get access to your Orders, Wishlist and Recommendations</h2> <h2 class="text-xl text-gray-600">Get access to your Orders, Wishlist and Recommendations</h2>
<form class="flex flex-col space-y-5"> <form [formGroup]="loginForm" (ngSubmit)="loginUser()" class="flex flex-col space-y-5">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Email</legend> <legend class="fieldset-legend">Email</legend>
<input type="text" class="input" placeholder="Enter email here" /> <input formControlName="email" type="text" class="input" placeholder="Enter email here" />
<p class="label">your-email-address@email.com</p> <p class="label">your-email-address@email.com</p>
<app-error fieldName="email" [control]="loginForm.get('email')" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Password</legend> <legend class="fieldset-legend">Password</legend>
<input type="password" class="input" placeholder="Type here" /> <input formControlName="password" type="password" class="input" placeholder="Type here" />
<app-error fieldName="password" [control]="loginForm.get('password')" />
</fieldset> </fieldset>
<button type="submit" class="btn btn-black py-2">Login</button> <button type="submit" class="btn btn-black py-2">Login</button>
</form> </form>
<a href="" class="text-xs text-gray-800 text-center w-full block hover:text-teal-600" <a
routerLink="/register"
class="text-xs text-gray-800 text-center w-full block hover:text-teal-600"
>New User ? Sign Up</a >New User ? Sign Up</a
> >
</article> </article>

View File

@ -1,9 +1,38 @@
import { Component } from "@angular/core"; import { Component, inject } from "@angular/core";
import { Router } 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";
@Component({ @Component({
selector: "app-login", selector: "app-login",
imports: [], imports: [ReactiveFormsModule, Error],
templateUrl: "./login.html", templateUrl: "./login.html",
styleUrl: "./login.css", styleUrl: "./login.css",
}) })
export class Login {} export class Login {
successMessage: string | undefined;
authService = inject(AuthService);
loginForm = new FormGroup({
email: new FormControl("", { validators: [Validators.required, Validators.email] }),
password: new FormControl("", { validators: [Validators.required] }),
});
constructor(private router: Router) {
const navigator = this.router.currentNavigation();
const state = navigator?.extras.state as { message?: string };
this.successMessage = state?.message;
}
loginUser() {
if (this.loginForm.invalid) {
this.loginForm.markAllAsTouched();
return;
}
this.authService.login(this.loginForm.value as { email: string; password: string }).subscribe({
next: () => this.router.navigate(["/"]),
});
}
}

View File

@ -1,44 +1,82 @@
<section class="my-10 sm:my-30 flex flex-col sm:flex-row space-x-20 justify-center items-center"> <section
class="my-10 md:my-30 flex flex-col md:flex-row space-x-20 space-y-10 justify-center items-center"
>
<article class="space-y-6"> <article class="space-y-6">
<h1 class="text-3xl text-gray-800 font-space font-bold">Register</h1> <h1 class="text-3xl text-gray-800 font-space font-bold">Register</h1>
<h2 class="text-xl text-gray-600">Sign up with your<br />email address to get started</h2> <h2 class="text-xl text-gray-600">Sign up with your<br />email address to get started</h2>
<div class="text-xs text-red-600 space-y-2">
@for (error of errors(); track error) {
<p>{{ error }}</p>
}
</div>
</article> </article>
<form [formGroup]="registerForm" (ngSubmit)="registerUser()" class="card max-w-11/12 sm:max-w-8/12 grid md:grid-cols-2 gap-4"> <form
<article class="space-y-4"> [formGroup]="registerForm"
(ngSubmit)="registerUser()"
class="card max-w-11/12 sm:max-w-8/12 grid md:grid-cols-2 gap-4"
>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Name</legend> <legend class="fieldset-legend">Name*</legend>
<input type="text" class="input" formControlName="name" placeholder="Jhon Doe" /> <input type="text" name="name" class="input" formControlName="name" placeholder="Jhon Doe" />
<app-error fieldName="name" [control]="registerForm.get('name')" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Mobile Number</legend> <legend class="fieldset-legend">Mobile Number*</legend>
<input type="text" class="input" formControlName="mobile_number" placeholder="+X1 XXXXXXXXXX" /> <input
type="text"
name="mobile_number"
class="input"
formControlName="mobile_number"
placeholder="+X1 XXXXXXXXXX"
/>
<app-error fieldName="mobile number" [control]="registerForm.get('mobile_number')" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Email</legend> <legend class="fieldset-legend">Email*</legend>
<input type="text" class="input" formControlName="email" placeholder="Enter email here" /> <input type="text" class="input" formControlName="email" placeholder="Enter email here" />
<p class="label">your-email-address@email.com</p> <p class="label">your-email-address@email.com</p>
<app-error fieldName="email" [control]="registerForm.get('email')" />
</fieldset> </fieldset>
</article>
<article class="space-y-4">
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Password</legend> <legend class="fieldset-legend">City*</legend>
<input
type="text"
name="city"
class="input"
formControlName="city"
placeholder="Your city name"
/>
<app-error fieldName="city" [control]="registerForm.get('city')" />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Password*</legend>
<input type="password" class="input" formControlName="password" placeholder="Type here" /> <input type="password" class="input" formControlName="password" placeholder="Type here" />
<app-error fieldName="password" [control]="registerForm.get('password')" />
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Confirm Password</legend> <legend class="fieldset-legend">Confirm Password*</legend>
<input type="text" class="input" formControlName="password_confirmation" placeholder="Type here" /> <input
type="text"
class="input"
formControlName="password_confirmation"
placeholder="Type here"
/>
<app-error [control]="registerForm.get('password_confirmation')" />
</fieldset> </fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">City</legend>
<input type="text" class="input" formControlName="city" placeholder="Your city name" />
</fieldset>
</article>
<div class="flex flex-col col-span-2 gap-y-2"> <div class="flex flex-col col-span-2 gap-y-2">
<button type="submit" class="btn btn-black py-2 w-full">Register</button> <button type="submit" [disabled]="registerForm.invalid" class="btn btn-black py-2 w-full">
<a href="" class="text-xs text-gray-800 text-center w-full block hover:text-teal-600" Register
</button>
<a
routerLink="/login"
class="text-xs text-gray-800 text-center w-full block hover:text-teal-600"
>Already have an account ? Login</a >Already have an account ? Login</a
> >
</div> </div>

View File

@ -1,30 +1,45 @@
import { Component, inject } from "@angular/core"; import { Component, inject, signal } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "../../services/auth-service"; import { AuthService } from "@core/services/auth-service";
import { RegisterUserRequest } from "../../../../core/models/user.model"; import { RegisterUserRequest } from "@core/models/user.model";
import { Error } from "@shared/components/error/error";
import { Router, RouterLink } from "@angular/router";
@Component({ @Component({
selector: "app-register", selector: "app-register",
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, Error, RouterLink],
templateUrl: "./register.html", templateUrl: "./register.html",
styleUrl: "./register.css", styleUrl: "./register.css",
}) })
export class Register { export class Register {
authService = inject(AuthService); authService = inject(AuthService);
router = inject(Router);
errors = signal<string[]>([]);
registerForm = new FormGroup({ registerForm = new FormGroup({
name :new FormControl(''), name: new FormControl("", { validators: [Validators.required] }),
email :new FormControl(''), email: new FormControl("", { validators: [Validators.required, Validators.email] }),
mobile_number : new FormControl(''), mobile_number: new FormControl("", { validators: [Validators.required] }),
password : new FormControl(''), password: new FormControl("", { validators: [Validators.required] }),
password_confirmation : new FormControl(''), password_confirmation: new FormControl("", { validators: [Validators.required] }),
city : new FormControl(''), city: new FormControl("", { validators: [Validators.required] }),
}); });
registerUser() { registerUser() {
this.authService.register(this.registerForm.value as RegisterUserRequest) // validation errors if user submit early
.subscribe(); if (this.registerForm.invalid) {
this.registerForm.markAllAsTouched();
return;
} }
this.authService.register(this.registerForm.value as RegisterUserRequest).subscribe({
next: () =>
this.router.navigate(["/login"], { state: { message: "Registration successful!" } }),
error: (error) => {
const errors: Record<number, string[]> = error?.error?.errors || {};
const errorMessages: string[] = Object.values(errors).flat();
this.errors.set(errorMessages);
},
});
}
} }

View File

@ -1,19 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { RegisterUserRequest } from '../../../core/models/user.model';
import { HttpClient } from '@angular/common/http';
import { API_URL } from '../../../core/tokens/api-url-tokens';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private http: HttpClient = inject(HttpClient);
private apiUrl = inject(API_URL);
register(userRequest: RegisterUserRequest) {
console.log(this.apiUrl);
return this.http.post(`${this.apiUrl}/register`, userRequest);
}
}

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

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

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

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

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

@ -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 @@
<p>payment works!</p>

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,9 @@
import { Component } from "@angular/core";
@Component({
selector: "app-payment",
imports: [],
templateUrl: "./payment.html",
styleUrl: "./payment.css",
})
export class Payment {}

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,23 @@
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,9 +1,9 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { Products } from "./products/products"; import { Product } from "../product/product";
@Component({ @Component({
selector: "app-home", selector: "app-home",
imports: [Products], imports: [Product],
templateUrl: "./home.html", templateUrl: "./home.html",
styleUrl: "./home.css", styleUrl: "./home.css",
}) })

View File

@ -1,17 +0,0 @@
<div class="card flex flex-col relative">
<!--Favorite button -->
<button
class="absolute right-6 top-6 transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full"
>
<lucide-angular [img]="HeartIcon" class="w-4 h-4 text-gray-500" />
</button>
<!--Product image-->
<div class="bg-gray-200 rounded-xl h-40">
<img src="https://placehold.co/400x600" alt="" class="object-cover rounded-xl w-full h-full" />
</div>
<p class="text-gray-400 text-sm">Product Name</p>
<p class="text-gray-400 text-xs">⭐4.5</p>
<p class="text-gray-400 text-xs">Price: 4999/-</p>
</div>

View File

@ -1,12 +0,0 @@
import { Component } from "@angular/core";
import { LucideAngularModule, Heart } from "lucide-angular";
@Component({
selector: "app-product-card",
standalone: true,
imports: [LucideAngularModule],
templateUrl: "./product-card.html",
})
export class ProductCard {
readonly HeartIcon = Heart;
}

View File

@ -1,14 +0,0 @@
<section
class="wrapper py-8 bg-gray-300 bg-cover bg-end bg-[linear-gradient(to_right,hsla(0,0%,90%,1.0),hsla(0,0%,90%,0.8)_20%,hsla(0,0%,100%,0)),url('/assets/images/watch-banner.jpg')]"
>
<p class="text-3xl text-gray-700 font-sans">Our Products</p>
</section>
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4 wrapper">
<app-product-card />
<app-product-card />
<app-product-card />
<app-product-card />
<app-product-card />
<app-product-card />
</section>

View File

@ -1,9 +0,0 @@
import { Component } from "@angular/core";
import { ProductCard } from "./componets/product-card/product-card";
@Component({
selector: "app-products",
imports: [ProductCard],
templateUrl: "./products.html",
})
export class Products {}

View File

@ -0,0 +1,157 @@
<section class="wrapper">
@if (successMessage()) {
<div
class="fixed top-5 right-5 z-50 flex items-center p-4 mb-4 text-green-800 rounded-lg bg-green-50 border border-green-200 shadow-lg animate-bounce-in"
role="alert"
>
<svg
aria-hidden="true"
class="flex-shrink-0 w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"
/>
</svg>
<div class="ms-3 text-sm font-medium">{{ successMessage() }}</div>
<button
(click)="successMessage.set(null)"
class="ms-auto -mx-1.5 -my-1.5 bg-green-50 text-green-500 rounded-lg p-1.5 hover:bg-green-200 inline-flex items-center justify-center h-8 w-8"
type="button"
>
<span class="sr-only">Close</span>
</button>
</div>
}
<h1 class="h1">Add Product</h1>
<section class="card">
<form
(ngSubmit)="submitProductForm()"
[formGroup]="productAddFrom"
class="flex flex-col sm:flex-row gap-4"
>
<fieldset class="flex flex-col space-y-4 min-w-0 flex-1">
<fieldset class="fieldset">
<legend class="fieldset-legend">Title</legend>
<input
class="input"
formControlName="title"
id="title"
placeholder="Enter product title"
type="text"
/>
<app-error [control]="productAddFrom.get('title')" fieldName="title" />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Description</legend>
<textarea
class="input"
formControlName="description"
id="description"
rows="5"
></textarea>
<app-error [control]="productAddFrom.get('description')" fieldName="description" />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Select category</legend>
<select class="input" formControlName="product_category_id" id="category">
<option disabled selected value="">Select Category</option>
@for (category of categories(); track category?.id) {
<option [value]="category?.id">{{ category.name }}</option>
}
</select>
<app-error [control]="productAddFrom.get('product_category_id')" fieldName="category" />
</fieldset>
</fieldset>
<fieldset class="flex flex-col space-y-4 min-w-0 flex-1">
<div class="flex gap-4">
<fieldset class="fieldset flex-1">
<legend class="fieldset-legend">List Price</legend>
<input
class="input"
formControlName="list_price"
id="list-price"
placeholder="$0"
type="number"
/>
<label class="label" for="list-price">Price to be shown as MRP</label>
<app-error [control]="productAddFrom.get('list_price')" fieldName="List Price" />
</fieldset>
<fieldset class="fieldset flex-1">
<legend class="fieldset-legend">Actual Price</legend>
<input
class="input"
formControlName="actual_price"
id="actual-price"
placeholder="$0"
type="number"
/>
<label class="label" for="actual-price">Price to be calculated when billing</label>
<app-error [control]="productAddFrom.get('actual_price')" fieldName="Actual Price" />
</fieldset>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Image</legend>
<div class="grid grid-cols-1 sm:grid-cols-3 sm:h-36 gap-4">
<app-image-input
(imageSelected)="openPreview($event)"
[bgImageUrl]="selectedImages()['image_1']?.url"
id="image_1"
/>
<app-image-input
(imageSelected)="openPreview($event)"
[bgImageUrl]="selectedImages()['image_2']?.url"
id="image_2"
/>
<app-image-input
(imageSelected)="openPreview($event)"
[bgImageUrl]="selectedImages()['image_3']?.url"
id="image_3"
/>
<dialog
#imageDialog
class="relative min-w-11/12 min-h-11/12 m-auto p-4 rounded-xl bg-gray-50 overflow-x-hidden backdrop:bg-black/50 backdrop:backdrop-blur-xs"
>
<div class="flex flex-col gap-4 overflow-hidden">
<div class="flex self-end gap-4 fixed top-10 right-10">
<button
(click)="closeDialog()"
class="btn btn-ghost bg-gray-50 py-1 px-4 shadow-xl"
type="button"
>
✕ Close
</button>
<button
(click)="confirmImage()"
class="btn btn-black py-1 px-4 shadow-xl"
type="button"
>
✓ Select
</button>
</div>
<div class="overflow-y-scroll rounded-lg">
<img
[src]="activeImage()?.url"
alt="Product Preview"
class="w-full h-auto object-contain"
/>
</div>
</div>
</dialog>
</div>
</fieldset>
<button class="btn btn-black py-2" type="submit">Add Product</button>
</fieldset>
</form>
</section>
</section>

View File

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

View File

@ -0,0 +1,112 @@
import { Component, ElementRef, inject, signal, ViewChild } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { ImageInput } from "../../../shared/components/image-input/image-input";
import { HttpClient } from "@angular/common/http";
import { API_URL } from "../../../core/tokens/api-url-tokens";
import { Error } from "../../../shared/components/error/error";
import { Category, CategoryService } from "../services/category-service";
import { forkJoin, switchMap } from "rxjs";
export interface ImageSelection {
id: string;
url: string;
file: File;
}
@Component({
selector: "app-add-product",
imports: [ReactiveFormsModule, ImageInput, Error],
templateUrl: "./add-product.html",
styleUrl: "./add-product.css",
})
export class AddProduct {
http = inject(HttpClient);
apiUrl = inject<string>(API_URL);
categoryService = inject(CategoryService);
@ViewChild("imageDialog") imageDialog!: ElementRef<HTMLDialogElement>;
activeImage = signal<ImageSelection | null>(null);
selectedImages = signal<Record<string, { url: string; file: File }>>({});
categories = signal<Category[]>([]);
successMessage = signal<string | null>(null);
productAddFrom = new FormGroup({
title: new FormControl("", {
validators: [Validators.required, Validators.minLength(3)],
}),
description: new FormControl("", {
validators: [Validators.required, Validators.minLength(10)],
}),
actual_price: new FormControl(0, { validators: [Validators.required, Validators.min(0)] }),
list_price: new FormControl(0, { validators: [Validators.required, Validators.min(0)] }),
product_category_id: new FormControl("", { validators: [Validators.required] }),
});
ngOnInit() {
this.categoryService.getCategories().subscribe({
next: (categories) => this.categories.set(categories),
error: (error) => console.log(error),
});
}
openPreview(image: ImageSelection) {
this.activeImage.set(image);
this.imageDialog.nativeElement.showModal();
}
confirmImage() {
// Add the current image to the selected images
const current = this.activeImage();
if (current) {
this.selectedImages.update((images) => ({ ...images, [current.id]: current }));
}
this.closeDialog();
}
closeDialog() {
this.activeImage.set(null);
this.imageDialog.nativeElement.close();
}
submitProductForm() {
if (this.productAddFrom.invalid) {
this.productAddFrom.markAllAsTouched();
return;
}
this.http
.post<{ id: string | number }>(`${this.apiUrl}/products`, this.productAddFrom.value)
.pipe(
switchMap((response) => {
const productId = response.id;
const images = Object.values(this.selectedImages());
if (images.length === 0) {
return [response];
}
const uploadRequests = images.map((img) => this.uploadImage(img.file, productId));
return forkJoin(uploadRequests);
}),
)
.subscribe({
next: (results) => {
this.successMessage.set("Product and images uploaded successfully!");
console.log("Product and all images uploaded successfully", results);
this.productAddFrom.reset();
this.selectedImages.set({});
setTimeout(() => this.successMessage.set(null), 3000);
},
error: (err) => console.error("Upload failed", err),
});
}
private uploadImage(file: File, productId: string | number) {
const formData = new FormData();
formData.append("image", file);
formData.append("product_id", productId.toString());
return this.http.post(`${this.apiUrl}/upload/images`, formData);
}
}

View File

@ -0,0 +1,29 @@
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
<app-favorite-button
[productId]="product.id"
[isFavorite]="product.isFavorite"
class="absolute top-5 right-5"
/>
<!--Product image-->
<div class="bg-gray-200 rounded-xl h-60">
<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-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

@ -0,0 +1,29 @@
import { Component, inject, Input } from "@angular/core";
import { ProductModel } from "../../../../core/models/product.model";
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],
templateUrl: "./product-card.html",
})
export class ProductCard {
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

@ -0,0 +1,11 @@
<section
class="wrapper py-8 bg-gray-300 bg-cover bg-end bg-[linear-gradient(to_right,hsla(0,0%,90%,1.0),hsla(0,0%,90%,0.8)_20%,hsla(0,0%,100%,0)),url('/assets/images/watch-banner.jpg')]"
>
<p class="text-3xl text-gray-700 font-sans">Our Product</p>
</section>
<section class="mt-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" />
}
</section>

View File

@ -0,0 +1,14 @@
import { Routes } from "@angular/router";
import { AddProduct } from "./add-product/add-product";
import { ShowProduct } from "./show-product/show-product";
export const productRoutes: Routes = [
{
path: "create",
component: AddProduct,
},
{
path: ":slug",
component: ShowProduct,
},
];

View File

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

View File

@ -0,0 +1,22 @@
import { Component, inject, signal } from "@angular/core";
import { ProductCard } from "./components/product-card/product-card";
import { HttpClient } from "@angular/common/http";
import { API_URL } from "../../core/tokens/api-url-tokens";
import { ProductModel } from "../../core/models/product.model";
import { ProductService } from "./services/product-service";
@Component({
selector: "app-products",
imports: [ProductCard],
templateUrl: "./product.html",
})
export class Product {
productService = inject(ProductService);
products = signal<ProductModel[]>([]);
ngOnInit() {
this.productService.getProducts().subscribe((data) => {
this.products.set(data.data);
});
}
}

View File

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

View File

@ -0,0 +1,21 @@
import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { API_URL } from "../../../core/tokens/api-url-tokens";
export interface Category {
id?: number;
name: string;
slug: string;
}
@Injectable({
providedIn: "root",
})
export class CategoryService {
private http = inject(HttpClient);
private apiUrl = inject(API_URL);
getCategories() {
return this.http.get<Category[]>(`${this.apiUrl}/categories`);
}
}

View File

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

View File

@ -0,0 +1,20 @@
import { inject, Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { API_URL } from "../../../core/tokens/api-url-tokens";
import { ProductCollection, ProductModel } from "../../../core/models/product.model";
@Injectable({
providedIn: "root",
})
export class ProductService {
http = inject(HttpClient);
apiUrl = inject(API_URL);
getProducts() {
return this.http.get<ProductCollection>(`${this.apiUrl}/products`);
}
getProduct(slug: string) {
return this.http.get<{ data: ProductModel }>(`${this.apiUrl}/products/${slug}`);
}
}

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