Compare commits

...

29 Commits

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

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

2
.gitignore vendored
View File

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

3
.oxfmtrc.json Normal file
View File

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

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

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

View File

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

View File

@ -1,5 +1,7 @@
import { Routes } from "@angular/router";
import { Home } from "./features/home/home";
import { authGuard } from "./core/guards/auth-guard";
import { roleGuard } from "./core/guards/role-guard";
export const routes: Routes = [
{
@ -10,4 +12,11 @@ export const routes: Routes = [
path: "",
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"] },
},
];

View File

@ -1,6 +1,6 @@
import { Component, signal } from "@angular/core";
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 { 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 "../../features/auth/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 "../../features/auth/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">
<a href="">Home</a>
<a href="">Products</a>
<a href="">Product</a>
<a href="">About Us</a>
<a href="">Contact Us</a>
</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"
>
<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 class="flex-1 grid grid-cols-[1fr_auto]">
<input
type="text"
class="w-full border border-gray-300 text-sm rounded-full rounded-r-none px-6 py-1"
placeholder="Search watches, brands, products..."
type="text"
/>
<button class="btn btn-ghost rounded-l-none! py-1 px-3 border-l-0!">
<lucide-angular [img]="SearchIcon" class="w-5" />
@ -20,23 +20,43 @@
<div class="flex space-x-4">
<div class="flex text-gray-600">
<button
class="btn btn-ghost py-1 px-2 rounded-r-none!"
popovertarget="popover-1"
style="anchor-name: --anchor-1"
class="btn btn-ghost py-1 px-2 rounded-r-none!"
>
<lucide-angular [img]="UserIcon" class="w-5" />
</button>
<button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
<button
class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0! relative"
popovertarget="popover-2"
style="anchor-name: --anchor-2"
>
<lucide-angular [img]="CartIcon" class="w-5" />
<span class="absolute top-0 text-xs ml-1">{{ cartItemCount | async }}</span>
</button>
</div>
</div>
</nav>
<ul class="dropdown" 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="">Orders</a></li>
<li><a class="block h-full w-full" href="">Wishlist</a></li>
<li><a class="block h-full w-full" href="">Notifications</a></li>
</ul>
<app-cart
[cart]="(cartItem$ | async)!"
id="popover-2"
class="dropdown"
popover
style="position-anchor: --anchor-2"
/>
</header>

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {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",
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 { Register } from "./components/register/register";
import { inject } from "@angular/core";
import { AuthService } from "./services/auth-service";
export const AuthRoutes: Routes = [
{
@ -14,6 +16,19 @@ export const AuthRoutes: Routes = [
path: "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">
<div class="md:col-span-1 lg:col-span-2">
<img
@ -13,21 +20,25 @@
<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">
<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>
<app-error fieldName="email" [control]="loginForm.get('email')" />
</fieldset>
<fieldset class="fieldset">
<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>
<button type="submit" class="btn btn-black py-2">Login</button>
</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
>
</article>

View File

@ -1,9 +1,38 @@
import { Component } from "@angular/core";
import { Component, inject } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "../../services/auth-service";
import { Error } from "../../../../shared/components/error/error";
@Component({
selector: "app-login",
imports: [],
imports: [RouterLink, ReactiveFormsModule, Error],
templateUrl: "./login.html",
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">
<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>
<div class="text-xs text-red-600 space-y-2">
@for (error of errors(); track error) {
<p>{{ error }}</p>
}
</div>
</article>
<form [formGroup]="registerForm" (ngSubmit)="registerUser()" class="card max-w-11/12 sm:max-w-8/12 grid md:grid-cols-2 gap-4">
<article class="space-y-4">
<form
[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">
<legend class="fieldset-legend">Name</legend>
<input type="text" class="input" formControlName="name" placeholder="Jhon Doe" />
<legend class="fieldset-legend">Name*</legend>
<input type="text" name="name" class="input" formControlName="name" placeholder="Jhon Doe" />
<app-error fieldName="name" [control]="registerForm.get('name')" />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Mobile Number</legend>
<input type="text" class="input" formControlName="mobile_number" placeholder="+X1 XXXXXXXXXX" />
<legend class="fieldset-legend">Mobile Number*</legend>
<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 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" />
<p class="label">your-email-address@email.com</p>
<app-error fieldName="email" [control]="registerForm.get('email')" />
</fieldset>
</article>
<article class="space-y-4">
<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" />
<app-error fieldName="password" [control]="registerForm.get('password')" />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend">Confirm Password</legend>
<input type="text" class="input" formControlName="password_confirmation" placeholder="Type here" />
<legend class="fieldset-legend">Confirm Password*</legend>
<input
type="text"
class="input"
formControlName="password_confirmation"
placeholder="Type here"
/>
<app-error [control]="registerForm.get('password_confirmation')" />
</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">
<button type="submit" class="btn btn-black py-2 w-full">Register</button>
<a href="" class="text-xs text-gray-800 text-center w-full block hover:text-teal-600"
<button type="submit" [disabled]="registerForm.invalid" class="btn btn-black py-2 w-full">
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
>
</div>

View File

@ -1,30 +1,45 @@
import { Component, inject } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
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 { Router, RouterLink } from "@angular/router";
@Component({
selector: "app-register",
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, Error, RouterLink],
templateUrl: "./register.html",
styleUrl: "./register.css",
})
export class Register {
authService = inject(AuthService);
router = inject(Router);
errors = signal<string[]>([]);
registerForm = new FormGroup({
name :new FormControl(''),
email :new FormControl(''),
mobile_number : new FormControl(''),
password : new FormControl(''),
password_confirmation : new FormControl(''),
city : new FormControl(''),
name: new FormControl("", { validators: [Validators.required] }),
email: new FormControl("", { validators: [Validators.required, Validators.email] }),
mobile_number: new FormControl("", { validators: [Validators.required] }),
password: new FormControl("", { validators: [Validators.required] }),
password_confirmation: new FormControl("", { validators: [Validators.required] }),
city: new FormControl("", { validators: [Validators.required] }),
});
registerUser() {
this.authService.register(this.registerForm.value as RegisterUserRequest)
.subscribe();
// validation errors if user submit early
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,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;
beforeEach(() => {
@ -10,7 +10,7 @@ describe('AuthService', () => {
service = TestBed.inject(AuthService);
});
it('should be created', () => {
it("should be created", () => {
expect(service).toBeTruthy();
});
});

View File

@ -1,19 +1,112 @@
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';
import { computed, inject, Injectable, Signal, signal, WritableSignal } from "@angular/core";
import { RegisterUserRequest, User } from "../../../core/models/user.model";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { API_URL, BACKEND_URL } from "../../../core/tokens/api-url-tokens";
import { switchMap, tap } from "rxjs";
import { LocalStorageService } from "../../../core/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',
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) {
console.log(this.apiUrl);
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

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

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

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

View File

@ -0,0 +1,109 @@
<div class="bg-gray-100 py-8 font-sans text-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav class="flex text-sm text-gray-500 mb-6 items-center space-x-2">
<a href="#" class="hover:text-gray-900">Home</a>
<span></span>
<a href="#" class="hover:text-gray-900">{{ product()?.category?.name }}</a>
<span></span>
<a href="#" class="hover:text-gray-900">{{ product()?.title }}</a>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div class="lg:col-span-4 flex flex-col gap-4">
<div class="card aspect-square relative">
<img
[src]="product()?.productImages?.[activeImageIndex()] ?? ''"
alt="Product Image"
class="w-full h-full object-cover object-center rounded-lg"
/>
<app-favorite-button
[productId]="product()!.id"
class="absolute top-5 right-5"
[isFavorite]="product()!.isFavorite"
/>
<button
(click)="prevImage()"
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 text-gray-200" />
</button>
<button
(click)="nextImage()"
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 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)"
>
<img [src]="image" alt="Thumbnail 1" class="w-full h-full object-cover rounded-lg" />
</button>
}
</div>
</div>
<div class="lg:col-span-5 flex flex-col space-y-5">
<div class="flex flex-col gap-1">
<p class="text-3xl font-semibold">{{ product()?.title }}</p>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400">Category:</span>
<a href="#" class="text-sm text-gray-600 hover:text-gray-900"
>{{ product()?.category?.name }}</a
>
</div>
<div class="flex items-center gap-2">
<div class="flex text-yellow-400 text-lg">
<span></span><span></span><span></span><span></span><span></span>
</div>
<span class="text-sm font-medium">4.8</span>
<span class="text-xs text-gray-400"></span>
<span class="text-sm text-gray-600 ml-2">112 Reviews</span>
</div>
</div>
<div class="min-h-40">
<h2 class="text-xl font-medium mb-3">Product Description</h2>
<p class="text-gray-700 text-sm leading-relaxed mb-4">{{ product()?.description }}</p>
</div>
<div>
<h3 class="text-lg font-medium mb-3">Customer Reviews</h3>
<ul class="list-disc list-outside ml-4 space-y-2 text-sm text-gray-700 relative pb-4">
<li>
"Rich in condensed nutrients using daily."
<span class="text-[10px] text-gray-400"></span>
</li>
<li>"Seedless & Eco-Peel it" <span class="text-[10px] text-gray-400"></span></li>
<li>
"Longer Shelf Life has encounant" <span class="text-[10px] text-gray-400"></span>
</li>
</ul>
</div>
</div>
<div class="lg:col-span-3 flex flex-col gap-6">
<div class="card">
<div class="flex justify-between items-start mb-6">
<div class="">
<p class="text-sm text-gray-400 line-through decoration-1">
Rs.{{ product()?.listPrice }}
</p>
<p class="text-3xl font-bold text-gray-900">Rs.{{ product()?.actualPrice }}</p>
</div>
</div>
<div class="flex flex-col gap-3">
<button (click)="addToCart()" class="w-full btn btn-ghost">Add to Cart</button>
<button class="w-full btn btn-primary">Buy Now</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,48 @@
import { Component, inject, Input, signal, WritableSignal } from "@angular/core";
import { ProductModel } from "../../../core/models/product.model";
import { ProductService } from "../services/product-service";
import { LucideAngularModule, Heart, ArrowRight, ArrowLeft } from "lucide-angular";
import { FavoriteButton } from "../../../shared/components/favorite-button/favorite-button";
import { CartService } from "@app/core/services/cart-service";
@Component({
selector: "app-show-product",
imports: [LucideAngularModule, FavoriteButton],
templateUrl: "./show-product.html",
styleUrl: "./show-product.css",
})
export class ShowProduct {
@Input() slug?: string;
HeartIcon = Heart;
ArrowRightIcon = ArrowRight;
ArrowLeftIcon = ArrowLeft;
productService = inject(ProductService);
cartService = inject(CartService);
product = signal<ProductModel | null>(null);
activeImageIndex: WritableSignal<number> = signal(0);
totalImageCount: number = 0;
ngOnInit() {
if (!this.slug) return;
this.productService.getProduct(this.slug).subscribe((data) => {
this.product.set(data.data);
this.totalImageCount = this.product()?.productImages?.length || 0;
});
}
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) =>
(index - 1 + this.product()?.productImages.length!) % this.product()?.productImages.length!,
);
}
}

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

@ -0,0 +1,49 @@
import { Component, Input } from "@angular/core";
import { AbstractControl } from "@angular/forms";
import { UpperCaseFirstPipe } from "../../pipes/upper-case-first-pipe";
@Component({
selector: "app-error",
imports: [UpperCaseFirstPipe],
template: `
<div class="min-h-4">
@if (this.control && this.control.touched) {
@for (error of getErrorMessages(); track error) {
<p class="ml-2 text-xs text-red-400">{{ error | upperCaseFirst }}</p>
}
}
</div>
`,
})
export class Error {
@Input() control!: AbstractControl | null;
@Input() fieldName = "This field";
getErrorMessages() {
const messages: string[] = [];
if (this.control && this.control.errors) {
const errors = this.control.errors;
if (errors["required"]) {
messages.push(`${this.fieldName} is required.`);
}
if (errors["email"]) {
messages.push(`Please enter a valid email address.`);
}
if (errors["minlength"]) {
messages.push(
`${this.fieldName} must be at least ${errors["minlength"].requiredLength} characters.`,
);
}
if (errors["pattern"]) {
messages.push(`${this.fieldName} is formatted incorrectly.`);
}
if (errors["serverError"]) {
messages.push(errors["serverError"]);
}
}
return messages;
}
}

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,29 @@
<div class="relative input-image input-image-ghost h-full w-full">
@if (bgImageUrl) {
<label
[for]="id"
[style.backgroundImage]="'url(' + bgImageUrl + ')'"
class="absolute inset-0 bg-cover bg-center bg-no-repeat p-4 w-full h-full flex flex-col space-y-4 justify-center items-center cursor-pointer rounded-xl"
>
<div class="absolute inset-0 bg-black/40 h-full rounded-xl"></div>
<lucide-angular [img]="cameraIcon" class="w-10 h-10 text-white stroke-1 z-10" />
<p class="text-white text-sm font-semibold z-10">Change image</p>
</label>
} @else {
<label
[for]="id"
class="absolute inset-0 p-4 w-full h-full flex flex-col space-y-4 justify-center items-center cursor-pointer hover:bg-gray-50 rounded-xl"
>
<lucide-angular [img]="cameraIcon" class="w-10 h-10 text-gray-400 stroke-1" />
<p class="text-gray-500 text-sm">Click to upload</p>
</label>
}
<input
(change)="handleFileSelect($event)"
[id]="id"
class="absolute opacity-0 w-full h-full top-0 right-0"
placeholder="Upload image"
type="file"
/>
</div>

View File

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

View File

@ -0,0 +1,26 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { Camera, LucideAngularModule } from "lucide-angular";
import { ImageSelection } from "../../../features/product/add-product/add-product";
@Component({
selector: "app-image-input",
imports: [LucideAngularModule],
templateUrl: "./image-input.html",
styleUrl: "./image-input.css",
})
export class ImageInput {
cameraIcon = Camera;
@Output() imageSelected = new EventEmitter<ImageSelection>();
@Input() id!: string;
@Input() bgImageUrl: string | undefined;
handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
const url = URL.createObjectURL(file);
this.imageSelected.emit({ id: this.id, url: url, file: file });
}
}
}

View File

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

View File

@ -0,0 +1,10 @@
import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
name: "upperCaseFirst",
})
export class UpperCaseFirstPipe implements PipeTransform {
transform(value: string): unknown {
return value.charAt(0).toUpperCase() + value.slice(1);
}
}

View File

@ -1,4 +1,5 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:8000/api',
apiUrl: "http://localhost:8000/api",
backendUrl: "http://localhost:8000",
};

View File

@ -1,4 +1,5 @@
export const environment = {
production: false,
apiUrl: 'http://my-dev-url',
apiUrl: "http://my-dev-url",
backendUrl: "http://my-dev-url",
};

View File

@ -13,7 +13,7 @@
}
body {
@apply bg-gray-100;
@apply bg-gray-100 antialiased m-0;
}
.wrapper {
@ -21,15 +21,19 @@ body {
}
.btn {
@apply rounded-full transition-all duration-200 font-medium ease-out flex justify-center active:translate-y-[1px];
@apply rounded-xl py-2 transition-all duration-200 font-medium ease-out flex justify-center active:translate-y-px disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-ghost {
@apply text-gray-600 border border-gray-300 hover:bg-gray-800 hover:text-gray-200;
@apply text-gray-600 border border-b-3 border-gray-300 hover:bg-gray-200/70 hover:text-gray-700;
}
.btn-black {
@apply text-gray-100 bg-gray-800 border border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400;
@apply text-gray-100 bg-gray-800 border border-b-3 border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400;
}
.btn-primary {
@apply text-blue-100 bg-blue-600 border border-b-3 border-blue-900 hover:bg-blue-700;
}
.card {
@ -39,6 +43,7 @@ body {
.fieldset {
@apply space-y-1;
}
.fieldset-legend {
@apply text-xs font-bold ml-2 text-gray-800;
}
@ -51,11 +56,32 @@ body {
@apply p-3 border border-gray-300 rounded-xl text-sm w-full;
}
.input-image {
@apply rounded-xl border bg-teal-100 border-teal-500 border-dashed;
}
.input-image-ghost {
@apply bg-gray-100 border-gray-300;
}
.dropdown {
position-area: span-left bottom;
@apply p-4 mt-2 border border-gray-300 shadow-lg rounded-xl space-y-2 text-gray-800;
}
.dropdown li {
@apply rounded-lg hover:bg-linear-to-r hover:from-teal-300 hover:to-transparent px-5 py-1;
@apply rounded-lg hover:bg-linear-to-r hover:bg-gray-100 px-5 py-1;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-space text-gray-800;
}
h1 {
@apply text-3xl my-4 ml-2;
}

View File

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