Compare commits
2 Commits
feature/ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b99d43f53 | ||
|
|
194424bfb2 |
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"angular-cli": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@angular/cli", "mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -38,8 +38,6 @@ yarn-error.log
|
|||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
*.cache
|
|
||||||
.php-cs-fixer.dist.php
|
|
||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"indexer.exclude_patterns": ["/node_modules/**/*", "/backend/**/*"]
|
|
||||||
}
|
|
||||||
@ -2,8 +2,7 @@
|
|||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
"cli": {
|
||||||
"packageManager": "npm",
|
"packageManager": "npm"
|
||||||
"analytics": false
|
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@ -31,7 +31,7 @@ SESSION_DRIVER=database
|
|||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=localhost
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@ -64,4 +64,3 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
FRONTEND_URL=http://localhost:4200
|
FRONTEND_URL=http://localhost:4200
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost:4200
|
|
||||||
|
|||||||
@ -22,7 +22,6 @@ public function __construct(
|
|||||||
public array $productImages,
|
public array $productImages,
|
||||||
public ?string $updatedAt = null,
|
public ?string $updatedAt = null,
|
||||||
public ?string $createdAt = null,
|
public ?string $createdAt = null,
|
||||||
public ?bool $isFavorite = null
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +41,6 @@ public function toArray(): array
|
|||||||
$this->productImages),
|
$this->productImages),
|
||||||
'updatedAt' => $this->updatedAt,
|
'updatedAt' => $this->updatedAt,
|
||||||
'createdAt' => $this->createdAt,
|
'createdAt' => $this->createdAt,
|
||||||
'isFavorite' => $this->isFavorite,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,8 +57,6 @@ public static function fromModel(Product $product): self
|
|||||||
productImages: $product->images->map(fn (ProductImage $productImage) => ProductImageDTO::fromModel($productImage))->all(),
|
productImages: $product->images->map(fn (ProductImage $productImage) => ProductImageDTO::fromModel($productImage))->all(),
|
||||||
updatedAt: $product->updated_at,
|
updatedAt: $product->updated_at,
|
||||||
createdAt: $product->created_at,
|
createdAt: $product->created_at,
|
||||||
// this column is added by where exists query
|
|
||||||
isFavorite: $product->favorited_by_exists,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Resources\FavouriteProductResource;
|
|
||||||
use App\Models\FavouriteProduct;
|
|
||||||
use App\Models\Product;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class FavouriteProductController extends Controller
|
|
||||||
{
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
return FavouriteProductResource::collection(FavouriteProduct::all());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(Request $request, Product $product)
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var User $user
|
|
||||||
*/
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
$changes = $user->favoriteProducts()->toggle($product);
|
|
||||||
Log::info('hi again');
|
|
||||||
// If changes has any item, that means a product has been attached.
|
|
||||||
$isFavorite = count($changes['attached']) > 0;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => $isFavorite ? 'Product added to favorites' : 'Product removed from favorites',
|
|
||||||
'isFavorite' => $isFavorite,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,16 +6,14 @@
|
|||||||
use App\Http\Requests\CreateProductRequest;
|
use App\Http\Requests\CreateProductRequest;
|
||||||
use App\Http\Resources\ProductResource;
|
use App\Http\Resources\ProductResource;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Queries\GetProductsQuery;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
public function index(GetProductsQuery $getProductsQuery)
|
public function index()
|
||||||
{
|
{
|
||||||
$products = $getProductsQuery->get(Auth::user());
|
$paginator = Product::query()->with(['category:id,name,slug', 'images:id,path,product_id'])->paginate();
|
||||||
$paginatedDtos = $products->through(fn ($product) => ProductDTO::fromModel($product));
|
$paginatedDtos = $paginator->through(fn ($product) => ProductDTO::fromModel($product));
|
||||||
|
|
||||||
return ProductResource::collection($paginatedDtos);
|
return ProductResource::collection($paginatedDtos);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
|
||||||
|
|
||||||
use App\Models\FavouriteProduct;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/** @mixin FavouriteProduct */
|
|
||||||
class FavouriteProductResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
|
|
||||||
'user_id' => $this->user_id,
|
|
||||||
'product_id' => $this->product_id,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -30,7 +30,6 @@ public function toArray(Request $request): array
|
|||||||
return Storage::disk('public')->url($productImage->path);
|
return Storage::disk('public')->url($productImage->path);
|
||||||
}, $this->resource->productImages),
|
}, $this->resource->productImages),
|
||||||
'updatedAt' => $this->resource->updatedAt,
|
'updatedAt' => $this->resource->updatedAt,
|
||||||
'isFavorite' => $this->resource->isFavorite,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@ -31,17 +28,6 @@ public function images(): HasMany
|
|||||||
return $this->hasMany(ProductImage::class, 'product_id', 'id');
|
return $this->hasMany(ProductImage::class, 'product_id', 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function favoritedBy(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(User::class, 'favorite_products');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Scope]
|
|
||||||
protected function active(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('is_active', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::saving(function ($product) {
|
static::saving(function ($product) {
|
||||||
@ -50,11 +36,4 @@ protected static function booted(): void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function casts()
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'is_active' => 'boolean',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,7 @@
|
|||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
use Notifiable;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@ -52,14 +50,4 @@ protected function casts(): array
|
|||||||
'role' => UserRoles::class,
|
'role' => UserRoles::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function favoriteProducts()
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Product::class, 'favorite_products', 'user_id', 'product_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasFavorited(Product $product): bool
|
|
||||||
{
|
|
||||||
return $this->favoriteProducts()->where('product_id', $product->id)->exists();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Queries;
|
|
||||||
|
|
||||||
use App\Models\Product;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
final readonly class GetProductsQuery
|
|
||||||
{
|
|
||||||
public function get(?User $user = null): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
return Product::query()
|
|
||||||
->active()
|
|
||||||
->when($user, function (Builder $query) use ($user) {
|
|
||||||
$query->withExists(
|
|
||||||
[
|
|
||||||
'favoritedBy' => fn (Builder $query) => $query->where('user_id', $user->id),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
})
|
|
||||||
->with(['category:id,name,slug', 'images:id,path,product_id'])
|
|
||||||
->paginate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Product;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('favorite_products', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignIdFor(User::class);
|
|
||||||
$table->foreignIdFor(Product::class);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['user_id', 'product_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('favorite_products');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('products', function (Blueprint $table) {
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('products', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('is_active');
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AuthenticatedUserController;
|
use App\Http\Controllers\AuthenticatedUserController;
|
||||||
use App\Http\Controllers\FavouriteProductController;
|
|
||||||
use App\Http\Controllers\ProductCategoryController;
|
use App\Http\Controllers\ProductCategoryController;
|
||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
use App\Http\Controllers\ProductImagesController;
|
use App\Http\Controllers\ProductImagesController;
|
||||||
@ -16,9 +15,6 @@
|
|||||||
Route::get('/user', [AuthenticatedUserController::class, 'show']);
|
Route::get('/user', [AuthenticatedUserController::class, 'show']);
|
||||||
Route::post('/logout', [AuthenticatedUserController::class, 'destroy']);
|
Route::post('/logout', [AuthenticatedUserController::class, 'destroy']);
|
||||||
Route::post('/upload/images', action: [ProductImagesController::class, 'store']);
|
Route::post('/upload/images', action: [ProductImagesController::class, 'store']);
|
||||||
|
|
||||||
// Favorites
|
|
||||||
Route::post('/products/{product}/favorite', [FavouriteProductController::class, 'toggle']);
|
|
||||||
});
|
});
|
||||||
Route::get('/categories', [ProductCategoryController::class, 'index']);
|
Route::get('/categories', [ProductCategoryController::class, 'index']);
|
||||||
Route::apiResource('products', ProductController::class);
|
Route::apiResource('products', ProductController::class);
|
||||||
|
|||||||
@ -9,6 +9,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withInterceptors([csrfInterceptor])),
|
provideHttpClient(withFetch(), withInterceptors([csrfInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: Home,
|
component: Home,
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
@ -19,9 +20,4 @@ export const routes: Routes = [
|
|||||||
canActivate: [authGuard, roleGuard],
|
canActivate: [authGuard, roleGuard],
|
||||||
data: { roles: ["admin", "broker"] },
|
data: { roles: ["admin", "broker"] },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "checkout",
|
|
||||||
loadChildren: () =>
|
|
||||||
import("./features/checkout/checkout.routes").then((routes) => routes.checkoutRoutes),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { CanActivateFn, Router } from "@angular/router";
|
import { CanActivateFn, Router } from "@angular/router";
|
||||||
import { inject } from "@angular/core";
|
import { inject } from "@angular/core";
|
||||||
import { AuthService } from "@core/services/auth-service";
|
import { AuthService } from "../../features/auth/services/auth-service";
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = (route, state) => {
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { CanActivateFn } from "@angular/router";
|
import { CanActivateFn } from "@angular/router";
|
||||||
import { inject } from "@angular/core";
|
import { inject } from "@angular/core";
|
||||||
import { AuthService } from "@core/services/auth-service";
|
import { AuthService } from "../../features/auth/services/auth-service";
|
||||||
|
|
||||||
export const roleGuard: CanActivateFn = (route, state) => {
|
export const roleGuard: CanActivateFn = (route, state) => {
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
class="bg-gray-50 wrapper py-4 flex gap-x-5 sm:gap-x-10 items-center shadow-lg shadow-gray-400/20"
|
class="bg-gray-50 wrapper py-4 flex gap-x-5 sm:gap-x-10 items-center shadow-lg shadow-gray-400/20"
|
||||||
>
|
>
|
||||||
<div class="">
|
<div class="">
|
||||||
<a class="px-3 py-1 bg-blue-600 text-white" routerLink="/">eKart</a>
|
<a class="px-3 py-1 bg-gray-800 text-white" href="/">eKart</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 grid grid-cols-[1fr_auto]">
|
<div class="flex-1 grid grid-cols-[1fr_auto]">
|
||||||
@ -20,24 +20,18 @@
|
|||||||
<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!"
|
class="btn btn-ghost py-1 px-2 rounded-r-none!"
|
||||||
popovertarget="popover-1"
|
popovertarget="popover-1"
|
||||||
style="anchor-name: --anchor-1"
|
style="anchor-name: --anchor-1"
|
||||||
>
|
>
|
||||||
<lucide-angular [img]="UserIcon" class="w-5" />
|
<lucide-angular [img]="UserIcon" class="w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
|
||||||
class="btn btn-ghost py-1 px-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" id="popover-1" popover style="position-anchor: --anchor-1">
|
<ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1">
|
||||||
@if (authService.authState() === AuthState.Unauthenticated) {
|
@if (authService.authState() === AuthState.Unauthenticated) {
|
||||||
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
|
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
|
||||||
@ -51,12 +45,4 @@
|
|||||||
<li><a class="block h-full w-full" href="">Wishlist</a></li>
|
<li><a class="block h-full w-full" href="">Wishlist</a></li>
|
||||||
<li><a class="block h-full w-full" href="">Notifications</a></li>
|
<li><a class="block h-full w-full" href="">Notifications</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<app-cart
|
|
||||||
[cart]="(cartItems$ | async)!"
|
|
||||||
id="popover-2"
|
|
||||||
class="dropdown"
|
|
||||||
popover
|
|
||||||
style="position-anchor: --anchor-2"
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from "@angular/core";
|
||||||
import { LucideAngularModule, Search, ShoppingCart, User } from "lucide-angular";
|
import { LucideAngularModule, Search, ShoppingCart, User } from "lucide-angular";
|
||||||
import { AuthService, AuthState } from "@core/services/auth-service";
|
import { RouterLink } from "@angular/router";
|
||||||
import { CartService } from "@app/core/services/cart-service";
|
import { AuthService, AuthState } from "../../../features/auth/services/auth-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, Cart, AsyncPipe],
|
imports: [LucideAngularModule, RouterLink],
|
||||||
templateUrl: "./header.html",
|
templateUrl: "./header.html",
|
||||||
styleUrl: "./header.css",
|
styleUrl: "./header.css",
|
||||||
})
|
})
|
||||||
@ -18,9 +14,5 @@ export class Header {
|
|||||||
readonly CartIcon = ShoppingCart;
|
readonly CartIcon = ShoppingCart;
|
||||||
readonly SearchIcon = Search;
|
readonly SearchIcon = Search;
|
||||||
readonly authService = inject(AuthService);
|
readonly authService = inject(AuthService);
|
||||||
readonly cartService = inject(CartService);
|
|
||||||
protected readonly AuthState = AuthState;
|
protected readonly AuthState = AuthState;
|
||||||
|
|
||||||
cartItems$ = this.cartService.cartItems$;
|
|
||||||
cartItemCount = this.cartItems$.pipe(map((cart: CartModel) => cart.itemsCount ?? 0));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
export interface CartItemModel {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
subtotal: number;
|
|
||||||
image: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CartModel {
|
|
||||||
id: number;
|
|
||||||
itemsCount: number;
|
|
||||||
totalPrice: number;
|
|
||||||
items: CartItemModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CartItemRequest {
|
|
||||||
productId: number;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@ export interface ProductModel {
|
|||||||
category: Category;
|
category: Category;
|
||||||
productImages: string[];
|
productImages: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductCollection extends PaginatedResponse<ProductModel> {}
|
export interface ProductCollection extends PaginatedResponse<ProductModel> {}
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { CartService } from "./cart-service";
|
|
||||||
|
|
||||||
describe("CartService", () => {
|
|
||||||
let service: CartService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(CartService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be created", () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
|
|
||||||
import { effect, inject, Injectable, signal } from "@angular/core";
|
|
||||||
import { API_URL } from "../tokens/api-url-tokens";
|
|
||||||
import { CartItemModel, CartItemRequest, CartModel } from "../models/cart.model";
|
|
||||||
import { AuthService, AuthState } from "@core/services/auth-service";
|
|
||||||
import { Cart } from "@app/shared/components/cart/cart";
|
|
||||||
import { BehaviorSubject, tap } from "rxjs";
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class CartService {
|
|
||||||
private authService = inject(AuthService);
|
|
||||||
// dependencies
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
private apiUrl = inject(API_URL);
|
|
||||||
|
|
||||||
private _cartItems = new BehaviorSubject<CartModel>({} as CartModel);
|
|
||||||
|
|
||||||
cartItems$ = this._cartItems.asObservable();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
if (this.authService.isAuthenticated()) {
|
|
||||||
this.fetchCart();
|
|
||||||
} else {
|
|
||||||
this._cartItems.next({} as CartModel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetchCart() {
|
|
||||||
return this.http.get<CartModel>(this.apiUrl + "/cart").subscribe({
|
|
||||||
next: (data) => this._cartItems.next(data),
|
|
||||||
error: (error: HttpErrorResponse) => {
|
|
||||||
if (error.status === 401) {
|
|
||||||
this.authService.purgeAuth();
|
|
||||||
}
|
|
||||||
// show an error in toast
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addToCart(data: CartItemRequest) {
|
|
||||||
return this.http
|
|
||||||
.post<CartModel>(this.apiUrl + "/cart", data)
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCart(data: CartItemRequest) {
|
|
||||||
return this.http
|
|
||||||
.patch<CartModel>(this.apiUrl + "/cart", data)
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromCart(productId: number) {
|
|
||||||
return this.http
|
|
||||||
.delete<CartModel>(this.apiUrl + "/cart", { body: { productId: productId } })
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { FavoriteService } from "./favorite-service";
|
|
||||||
|
|
||||||
describe("FavoriteService", () => {
|
|
||||||
let service: FavoriteService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(FavoriteService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be created", () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { HttpClient } from "@angular/common/http";
|
|
||||||
import { inject, Injectable } from "@angular/core";
|
|
||||||
import { API_URL } from "../tokens/api-url-tokens";
|
|
||||||
|
|
||||||
export interface FavoriteResponse {
|
|
||||||
message: string;
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class FavoriteService {
|
|
||||||
http = inject(HttpClient);
|
|
||||||
apiUrl = inject(API_URL);
|
|
||||||
|
|
||||||
toggle(productId: number) {
|
|
||||||
return this.http.post<FavoriteResponse>(`${this.apiUrl}/products/${productId}/favorite`, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,7 @@ 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 { inject } from "@angular/core";
|
||||||
import { AuthService } from "@core/services/auth-service";
|
import { AuthService } from "./services/auth-service";
|
||||||
|
|
||||||
export const AuthRoutes: Routes = [
|
export const AuthRoutes: Routes = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Component, inject } from "@angular/core";
|
import { Component, inject } from "@angular/core";
|
||||||
import { Router } from "@angular/router";
|
import { Router, RouterLink } from "@angular/router";
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
import { AuthService } from "@core/services/auth-service";
|
import { AuthService } from "../../services/auth-service";
|
||||||
import { Error } from "@app/shared/components/error/error";
|
import { Error } from "../../../../shared/components/error/error";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-login",
|
selector: "app-login",
|
||||||
imports: [ReactiveFormsModule, Error],
|
imports: [RouterLink, ReactiveFormsModule, Error],
|
||||||
templateUrl: "./login.html",
|
templateUrl: "./login.html",
|
||||||
styleUrl: "./login.css",
|
styleUrl: "./login.css",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Component, inject, signal } from "@angular/core";
|
import { Component, inject, signal } from "@angular/core";
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||||
import { AuthService } from "@core/services/auth-service";
|
import { AuthService } from "../../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 { Error } from "../../../../shared/components/error/error";
|
||||||
import { Router, RouterLink } from "@angular/router";
|
import { Router, RouterLink } from "@angular/router";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { computed, inject, Injectable, Signal, signal, WritableSignal } from "@angular/core";
|
import { computed, inject, Injectable, Signal, signal, WritableSignal } from "@angular/core";
|
||||||
import { RegisterUserRequest, User } from "../models/user.model";
|
import { RegisterUserRequest, User } from "../../../core/models/user.model";
|
||||||
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
|
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
|
||||||
import { API_URL, BACKEND_URL } from "../tokens/api-url-tokens";
|
import { API_URL, BACKEND_URL } from "../../../core/tokens/api-url-tokens";
|
||||||
import { switchMap, tap } from "rxjs";
|
import { switchMap, tap } from "rxjs";
|
||||||
import { LocalStorageService } from "../services/local-storage.service";
|
import { LocalStorageService } from "../../../core/services/local-storage.service";
|
||||||
|
|
||||||
export enum AuthState {
|
export enum AuthState {
|
||||||
Loading = "loading",
|
Loading = "loading",
|
||||||
@ -104,7 +104,7 @@ export class AuthService {
|
|||||||
this.authState.set(AuthState.Authenticated);
|
this.authState.set(AuthState.Authenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
purgeAuth() {
|
private purgeAuth() {
|
||||||
this.localStorage.removeItem(this.userKey);
|
this.localStorage.removeItem(this.userKey);
|
||||||
this.user.set(null);
|
this.user.set(null);
|
||||||
this.authState.set(AuthState.Unauthenticated);
|
this.authState.set(AuthState.Unauthenticated);
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<div class="col-span-2 flex flex-col space-y-4">
|
|
||||||
@for (address of addresses(); track address.id) {
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<input [formControl]="addressIdControl" name="address" type="radio" value="{{address.id}}" />
|
|
||||||
<app-address-select
|
|
||||||
(addressUpdated)="updateAddress($event)"
|
|
||||||
[address]="address"
|
|
||||||
[isProcessing]="isProcessing()"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<app-address-form
|
|
||||||
(submitAddress)="createNewAddress($event)"
|
|
||||||
[isProcessing]="isProcessing()"
|
|
||||||
class="ml-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { Address } from "./address";
|
|
||||||
|
|
||||||
describe("Address", () => {
|
|
||||||
let component: Address;
|
|
||||||
let fixture: ComponentFixture<Address>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Address],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Address);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
|
|
||||||
import { AddressForm } from "../components/address-form/address-form";
|
|
||||||
import { AddressSelect } from "../components/address-select/address-select";
|
|
||||||
import {
|
|
||||||
AddressRequest,
|
|
||||||
AddressResponse,
|
|
||||||
AddressService,
|
|
||||||
} from "@app/features/checkout/services/address-service";
|
|
||||||
import { AuthService } from "@core/services/auth-service";
|
|
||||||
import { User } from "@core/models/user.model";
|
|
||||||
import { finalize, of, switchMap } from "rxjs";
|
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
||||||
import { ReactiveFormsModule } from "@angular/forms";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-address",
|
|
||||||
imports: [AddressSelect, AddressForm, ReactiveFormsModule],
|
|
||||||
templateUrl: "./address.html",
|
|
||||||
styleUrl: "./address.css",
|
|
||||||
})
|
|
||||||
export class Address implements OnInit {
|
|
||||||
addressService = inject(AddressService);
|
|
||||||
authService = inject(AuthService);
|
|
||||||
addressIdControl = this.addressService.addressIdControl;
|
|
||||||
isProcessing = signal(false);
|
|
||||||
|
|
||||||
// I am subscribing to the observable instead of using toSignal(),
|
|
||||||
// i have to destroy the subscription manually.
|
|
||||||
destroyRef = inject(DestroyRef);
|
|
||||||
protected addresses = signal<AddressResponse[]>([]);
|
|
||||||
private user: User | undefined;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.authService
|
|
||||||
.getCurrentUser()
|
|
||||||
.pipe(
|
|
||||||
switchMap((user) => {
|
|
||||||
this.user = user;
|
|
||||||
if (user?.id) {
|
|
||||||
return this.addressService.fetchAddresses(user.id);
|
|
||||||
}
|
|
||||||
return of({ data: [] });
|
|
||||||
}),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (addresses) => {
|
|
||||||
this.addresses.set(addresses.data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createNewAddress(addressData: AddressRequest) {
|
|
||||||
this.isProcessing.set(true);
|
|
||||||
this.addressService
|
|
||||||
.createAddress(this.user!.id, addressData)
|
|
||||||
.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
finalize(() => this.isProcessing.set(false)),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (address) => this.addresses.update((addresses) => [...addresses, address]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
protected updateAddress(addressData: AddressResponse) {
|
|
||||||
this.isProcessing.set(true);
|
|
||||||
this.addressService
|
|
||||||
.updateAddress(addressData.id, addressData)
|
|
||||||
.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
finalize(() => this.isProcessing.set(false)),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (address) =>
|
|
||||||
this.addresses.update((addresses) =>
|
|
||||||
addresses.map((a) => (a.id === address.id ? address : a)),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
<section class="wrapper my-10">
|
|
||||||
<div class="wrapper grid place-content-center w-full">
|
|
||||||
<app-stepper [currentStep]="currentStepNumber()" [steps]="steps" />
|
|
||||||
</div>
|
|
||||||
<section class="mt-10">
|
|
||||||
@if (currentStepNumber() > 0) {
|
|
||||||
<app-go-back
|
|
||||||
[route]="steps[currentStepNumber() - 1].route"
|
|
||||||
[text]="steps[currentStepNumber() - 1 ].label"
|
|
||||||
class="ml-4 block"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-6 gap-10">
|
|
||||||
<div class="md:col-span-4">
|
|
||||||
<router-outlet />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<app-order-summery class="hidden md:block" />
|
|
||||||
<div class="card mt-4">
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<legend class="fieldset-legend">Have any coupon ?</legend>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input class="input" placeholder="Enter coupon here" type="text" />
|
|
||||||
<button class="btn btn-ghost px-4">Apply</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
@if (addressIdControl.invalid && addressIdControl.touched) {
|
|
||||||
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
|
|
||||||
Please select an address
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="proceedToPayment()"
|
|
||||||
[disabled]="addressIdControl.invalid && addressIdControl.touched || orderCreationLoading()"
|
|
||||||
class="btn btn-primary w-full mt-4"
|
|
||||||
>
|
|
||||||
<app-loading-spinner [isLoading]="orderCreationLoading()">
|
|
||||||
Proceed to payment
|
|
||||||
</app-loading-spinner>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { Routes } from "@angular/router";
|
|
||||||
import { Address } from "./address/address";
|
|
||||||
import { Checkout } from "./checkout";
|
|
||||||
import { Payment } from "@app/features/checkout/payment/payment";
|
|
||||||
|
|
||||||
export const checkoutRoutes: Routes = [
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
component: Checkout,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "address",
|
|
||||||
component: Address,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "payment",
|
|
||||||
component: Payment,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { Checkout } from "./checkout";
|
|
||||||
|
|
||||||
describe("Checkout", () => {
|
|
||||||
let component: Checkout;
|
|
||||||
let fixture: ComponentFixture<Checkout>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Checkout],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Checkout);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
|
|
||||||
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
|
|
||||||
import { Stepper, Steps } from "@app/shared/components/stepper/stepper";
|
|
||||||
import { OrderSummery } from "@app/features/checkout/components/order-summery/order-summery";
|
|
||||||
import { GoBack } from "@shared/components/go-back/go-back";
|
|
||||||
import { AddressService } from "@app/features/checkout/services/address-service";
|
|
||||||
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
|
|
||||||
import { OrderService } from "@app/features/checkout/services/order-service";
|
|
||||||
import { CartService } from "@core/services/cart-service";
|
|
||||||
import { CartModel } from "@core/models/cart.model";
|
|
||||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
|
||||||
import { filter, finalize, map } from "rxjs";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-checkout",
|
|
||||||
providers: [AddressService],
|
|
||||||
imports: [RouterOutlet, Stepper, OrderSummery, GoBack, LoadingSpinner],
|
|
||||||
templateUrl: "./checkout.html",
|
|
||||||
styleUrl: "./checkout.css",
|
|
||||||
})
|
|
||||||
export class Checkout implements OnInit {
|
|
||||||
steps: Steps[] = [
|
|
||||||
{ label: "Cart", route: "" },
|
|
||||||
{ label: "Address", route: "/checkout/address" },
|
|
||||||
{ label: "Payment", route: "/checkout/payment" },
|
|
||||||
{ label: "Confirm", route: "/checkout/address" },
|
|
||||||
];
|
|
||||||
destroyRef = inject(DestroyRef);
|
|
||||||
orderCreationLoading = signal(false);
|
|
||||||
private addressService = inject(AddressService);
|
|
||||||
addressIdControl = this.addressService.addressIdControl;
|
|
||||||
private orderService = inject(OrderService);
|
|
||||||
private cartService = inject(CartService);
|
|
||||||
private cart: CartModel | undefined;
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
protected currentStepNumber = toSignal(
|
|
||||||
this.router.events.pipe(
|
|
||||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd), // Added TS type guard
|
|
||||||
map((event: NavigationEnd) => {
|
|
||||||
// Strip query params and fragments to ensure exact matching
|
|
||||||
const cleanUrl = event.urlAfterRedirects.split("?")[0].split("#")[0];
|
|
||||||
|
|
||||||
// Exact match comparison
|
|
||||||
const activeIndex = this.steps.findIndex(
|
|
||||||
(step) => cleanUrl === step.route || (step.route !== "" && cleanUrl.endsWith(step.route)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return activeIndex !== -1 ? activeIndex : 0;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ initialValue: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.cartService.cartItems$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((cart) => {
|
|
||||||
this.cart = cart;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected proceedToPayment() {
|
|
||||||
if (this.addressIdControl.invalid) {
|
|
||||||
this.addressIdControl.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.orderCreationLoading.set(true);
|
|
||||||
this.orderService
|
|
||||||
.createOrder({ cartId: this.cart!.id, addressId: this.addressIdControl.value! })
|
|
||||||
.pipe(
|
|
||||||
finalize(() => this.orderCreationLoading.set(false)),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe(async () => {
|
|
||||||
await this.router.navigate(["/checkout/payment"]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<details [open]="isEditing()" class="card p-0!" title="Click to add a new address">
|
|
||||||
<summary class="p-6">
|
|
||||||
<label class="font-medium text-gray-600 ml-2" for="currentAddress"
|
|
||||||
>{{isEditing() ? 'Update address' : 'Add new address'}}</label
|
|
||||||
>
|
|
||||||
</summary>
|
|
||||||
<form
|
|
||||||
(ngSubmit)="submitForm()"
|
|
||||||
[formGroup]="addressForm"
|
|
||||||
class="w-full flex flex-col gap-y-2 pt-0 p-4"
|
|
||||||
>
|
|
||||||
<fieldset class="flex space-x-4 w-full">
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">First Name</legend>
|
|
||||||
<input class="input" formControlName="firstName" placeholder="Example: Jhon" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('firstName')" fieldName="First name" />
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">Last Name</legend>
|
|
||||||
<input class="input" formControlName="lastName" placeholder="Example: Doe" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('lastName')" fieldName="Last name" />
|
|
||||||
</fieldset>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">Street Address</legend>
|
|
||||||
<input class="input" formControlName="street" placeholder="Your street address" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('street')" fieldName="Street address" />
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="flex space-x-4 w-full">
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">City</legend>
|
|
||||||
<input class="input" formControlName="city" placeholder="Your city" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('city')" fieldName="City" />
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">State</legend>
|
|
||||||
<input class="input" formControlName="state" placeholder="State Name" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('state')" fieldName="State" />
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="fieldset w-full">
|
|
||||||
<legend class="fieldset-legend">Pin Code</legend>
|
|
||||||
<input class="input" formControlName="pinCode" placeholder="7XX XX1" type="text" />
|
|
||||||
<app-error [control]="addressForm.get('pinCode')" fieldName="Pin Code" />
|
|
||||||
</fieldset>
|
|
||||||
</fieldset>
|
|
||||||
<div class="ml-auto flex space-x-4">
|
|
||||||
<button (click)="cancelEditing()" class="btn btn-ghost px-3 text-sm" type="button">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary min-w-50 px-3 text-sm">
|
|
||||||
<app-loading-spinner [isLoading]="isProcessing()">
|
|
||||||
{{isEditing() ? 'Update this address' : 'Use this address'}}
|
|
||||||
</app-loading-spinner>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { AddressForm } from "./address-form";
|
|
||||||
|
|
||||||
describe("AddressForm", () => {
|
|
||||||
let component: AddressForm;
|
|
||||||
let fixture: ComponentFixture<AddressForm>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [AddressForm],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AddressForm);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { Component, EventEmitter, input, Input, Output, signal } from "@angular/core";
|
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
|
|
||||||
import { Error } from "@app/shared/components/error/error";
|
|
||||||
import { AddressRequest, AddressResponse } from "@app/features/checkout/services/address-service";
|
|
||||||
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-address-form",
|
|
||||||
imports: [ReactiveFormsModule, Error, LoadingSpinner],
|
|
||||||
templateUrl: "./address-form.html",
|
|
||||||
styleUrl: "./address-form.css",
|
|
||||||
})
|
|
||||||
export class AddressForm {
|
|
||||||
@Output() submitAddress: EventEmitter<AddressRequest> = new EventEmitter<AddressRequest>();
|
|
||||||
@Output() updateAddress: EventEmitter<AddressResponse> = new EventEmitter<AddressResponse>();
|
|
||||||
@Output() editingCanceled: EventEmitter<void> = new EventEmitter<void>();
|
|
||||||
|
|
||||||
isProcessing = input<boolean>(false);
|
|
||||||
|
|
||||||
addressForm = new FormGroup({
|
|
||||||
firstName: new FormControl("", {
|
|
||||||
validators: [Validators.required, Validators.pattern("^[a-zA-Z]\\S+$")],
|
|
||||||
}),
|
|
||||||
lastName: new FormControl("", {
|
|
||||||
validators: [Validators.required, Validators.pattern("^[a-zA-Z]\\S+$")],
|
|
||||||
}),
|
|
||||||
street: new FormControl("", { validators: Validators.required }),
|
|
||||||
city: new FormControl("", { validators: Validators.required }),
|
|
||||||
state: new FormControl("", { validators: Validators.required }),
|
|
||||||
pinCode: new FormControl("", {
|
|
||||||
validators: [Validators.required, Validators.pattern("^[0-9]{6}$")],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
protected isEditing = signal(false);
|
|
||||||
protected address = signal<AddressResponse | null>(null);
|
|
||||||
|
|
||||||
@Input() set initialData(address: AddressResponse) {
|
|
||||||
if (address) {
|
|
||||||
this.addressForm.patchValue(address);
|
|
||||||
this.address.set(address);
|
|
||||||
this.isEditing.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submitForm() {
|
|
||||||
if (this.addressForm.invalid) {
|
|
||||||
this.addressForm.markAllAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emittedData = this.addressForm.getRawValue() as AddressRequest;
|
|
||||||
|
|
||||||
if (this.isEditing()) {
|
|
||||||
const mergedData = { ...this.address(), ...emittedData };
|
|
||||||
this.updateAddress.emit(mergedData as unknown as AddressResponse);
|
|
||||||
} else {
|
|
||||||
this.submitAddress.emit(emittedData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEditing() {
|
|
||||||
this.addressForm.reset();
|
|
||||||
this.editingCanceled.emit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
@if (isEditing()) {
|
|
||||||
<app-address-form
|
|
||||||
(editingCanceled)="cancelEditing()"
|
|
||||||
(updateAddress)="updateAddress($event)"
|
|
||||||
[initialData]="address"
|
|
||||||
[isProcessing]="isProcessing()"
|
|
||||||
/>
|
|
||||||
} @else{
|
|
||||||
<div class="flex justify-between card">
|
|
||||||
<div class="flex space-x-4 items-center">
|
|
||||||
<p class="text-gray-600 font-medium">{{[address.firstName, address.lastName] | fullname}}</p>
|
|
||||||
<p class="text-gray-400 text-sm">
|
|
||||||
{{`${address.street}, ${address.city}, ${address.pinCode}`}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button (click)="editForm()" class="btn btn-ghost text-sm px-2">Edit</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { AddressSelect } from "./address-select";
|
|
||||||
|
|
||||||
describe("AddressSelect", () => {
|
|
||||||
let component: AddressSelect;
|
|
||||||
let fixture: ComponentFixture<AddressSelect>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [AddressSelect],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AddressSelect);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { Component, effect, EventEmitter, Input, input, Output, signal } from "@angular/core";
|
|
||||||
import { AddressResponse } from "@app/features/checkout/services/address-service";
|
|
||||||
import { FullnamePipe } from "@shared/pipes/fullname-pipe";
|
|
||||||
import { AddressForm } from "@app/features/checkout/components/address-form/address-form";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-address-select",
|
|
||||||
imports: [FullnamePipe, AddressForm],
|
|
||||||
templateUrl: "./address-select.html",
|
|
||||||
styleUrl: "./address-select.css",
|
|
||||||
})
|
|
||||||
export class AddressSelect {
|
|
||||||
@Input() address!: AddressResponse;
|
|
||||||
@Output() addressUpdated = new EventEmitter<AddressResponse>();
|
|
||||||
|
|
||||||
isProcessing = input<boolean>(false);
|
|
||||||
protected isEditing = signal(false);
|
|
||||||
|
|
||||||
// Track if THIS specific component triggered the API call
|
|
||||||
private isLocallySubmitted = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const processing = this.isProcessing();
|
|
||||||
|
|
||||||
if (!processing && this.isLocallySubmitted) {
|
|
||||||
this.isEditing.set(false);
|
|
||||||
this.isLocallySubmitted = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
editForm() {
|
|
||||||
this.isEditing.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelEditing() {
|
|
||||||
this.isEditing.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAddress(address: AddressResponse) {
|
|
||||||
this.isLocallySubmitted = true;
|
|
||||||
this.addressUpdated.emit(address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<div class="card">
|
|
||||||
<p class="text-gray-800 font-medium text-xl">Order Summery</p>
|
|
||||||
@if (cartItems | async; as cart) { @for (item of cart.items; track item.id) {
|
|
||||||
<article
|
|
||||||
class="mt-4 pb-4 border-b border-b-gray-400 border-dashed flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<div class="w-15 h-15 rounded-lg aspect-square relative overflow-hidden">
|
|
||||||
<img ngSrc="{{item.image}}" fill class="object-cover" alt="product image" />
|
|
||||||
</div>
|
|
||||||
<article>
|
|
||||||
<h2>{{item.title}}</h2>
|
|
||||||
<p class="text-gray-600 text-sm">Rs. {{item.price}} x {{item.quantity}}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-800 font-medium">Rs. {{item.subtotal}}</p>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
<article class="mt-4 flex justify-between items-center text-lg">
|
|
||||||
<p class="text-gray-800 font-medium">Total</p>
|
|
||||||
<p class="text-gray-800 font-medium">Rs. {{cart.totalPrice}}</p>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { OrderSummery } from "./order-summery";
|
|
||||||
|
|
||||||
describe("OrderSummery", () => {
|
|
||||||
let component: OrderSummery;
|
|
||||||
let fixture: ComponentFixture<OrderSummery>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [OrderSummery],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(OrderSummery);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from "@angular/core";
|
|
||||||
import { CartService } from "@core/services/cart-service";
|
|
||||||
import { CartModel } from "@core/models/cart.model";
|
|
||||||
import { Observable } from "rxjs";
|
|
||||||
import { AsyncPipe, NgOptimizedImage } from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-order-summery",
|
|
||||||
imports: [AsyncPipe, NgOptimizedImage],
|
|
||||||
templateUrl: "./order-summery.html",
|
|
||||||
styleUrl: "./order-summery.css",
|
|
||||||
})
|
|
||||||
export class OrderSummery implements OnInit {
|
|
||||||
cartService = inject(CartService);
|
|
||||||
cartItems: Observable<CartModel> | undefined;
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.cartItems = this.cartService.cartItems$;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<p>payment works!</p>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { Payment } from "./payment";
|
|
||||||
|
|
||||||
describe("Payment", () => {
|
|
||||||
let component: Payment;
|
|
||||||
let fixture: ComponentFixture<Payment>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Payment],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Payment);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { Component } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-payment",
|
|
||||||
imports: [],
|
|
||||||
templateUrl: "./payment.html",
|
|
||||||
styleUrl: "./payment.css",
|
|
||||||
})
|
|
||||||
export class Payment {}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { AddressService } from "./address-service";
|
|
||||||
|
|
||||||
describe("AddressService", () => {
|
|
||||||
let service: AddressService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(AddressService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be created", () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { inject, Injectable } from "@angular/core";
|
|
||||||
import { HttpClient } from "@angular/common/http";
|
|
||||||
import { API_URL } from "@core/tokens/api-url-tokens";
|
|
||||||
import { PaginatedResponse } from "@core/models/paginated.model";
|
|
||||||
import { FormControl, Validators } from "@angular/forms";
|
|
||||||
|
|
||||||
export interface AddressRequest {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
pinCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AddressResponse extends AddressRequest {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AddressService {
|
|
||||||
http = inject(HttpClient);
|
|
||||||
apiUrl = inject(API_URL);
|
|
||||||
|
|
||||||
addressIdControl = new FormControl<number | null>(null, Validators.required);
|
|
||||||
|
|
||||||
fetchAddresses(userId: number) {
|
|
||||||
return this.http.get<PaginatedResponse<AddressResponse>>(
|
|
||||||
`${this.apiUrl}/user/${userId}/addresses`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createAddress(userId: number, data: AddressRequest) {
|
|
||||||
return this.http.post<AddressResponse>(`${this.apiUrl}/user/${userId}/addresses`, data);
|
|
||||||
}
|
|
||||||
updateAddress(addressId: number, data: AddressRequest) {
|
|
||||||
return this.http.patch<AddressResponse>(`${this.apiUrl}/addresses/${addressId}`, data);
|
|
||||||
}
|
|
||||||
deleteAddress(userId: number, addressId: number) {
|
|
||||||
return this.http.delete<AddressResponse>(
|
|
||||||
`${this.apiUrl}/user/${userId}/addresses/${addressId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { OrderService } from "./order-service";
|
|
||||||
|
|
||||||
describe("OrderService", () => {
|
|
||||||
let service: OrderService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(OrderService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be created", () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import { inject, Injectable } from "@angular/core";
|
|
||||||
import { API_URL } from "@core/tokens/api-url-tokens";
|
|
||||||
import { HttpClient } from "@angular/common/http";
|
|
||||||
import { AuthService } from "@core/services/auth-service";
|
|
||||||
|
|
||||||
export interface OrderRequest {
|
|
||||||
addressId: number;
|
|
||||||
cartId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class OrderService {
|
|
||||||
http = inject(HttpClient);
|
|
||||||
apiUrl = inject(API_URL);
|
|
||||||
private authService = inject(AuthService);
|
|
||||||
private user = this.authService.user;
|
|
||||||
|
|
||||||
createOrder(data: OrderRequest) {
|
|
||||||
return this.http.post(`${this.apiUrl}/users/${this.user()?.id}/orders`, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +1,21 @@
|
|||||||
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
|
<div class="card flex flex-col relative cursor-pointer" (click)="goToProductDetails()">
|
||||||
<app-favorite-button
|
<!--Favorite button -->
|
||||||
[productId]="product.id"
|
<button
|
||||||
[isFavorite]="product.isFavorite"
|
class="absolute right-6 top-6 transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full"
|
||||||
class="absolute top-5 right-5"
|
>
|
||||||
/>
|
<lucide-angular [img]="HeartIcon" class="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<!--Product image-->
|
<!--Product image-->
|
||||||
<div class="bg-gray-200 rounded-xl h-60">
|
<div class="bg-gray-200 rounded-xl h-40">
|
||||||
<img [src]="product.productImages[0]" alt="" class="object-cover rounded-xl w-full h-full" />
|
<img [src]="product.productImages[0]" alt="" class="object-cover rounded-xl w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between mt-4">
|
<p class="text-gray-400 text-sm truncate">{{product.title}}</p>
|
||||||
<p class="text-sm truncate font-medium text-gray-800 hover:text-blue-500">
|
|
||||||
{{ product.title }}
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-400 text-xs">⭐4.5</p>
|
<p class="text-gray-400 text-xs">⭐4.5</p>
|
||||||
</div>
|
<p class="text-gray-400 text-xs">
|
||||||
<div class="flex justify-between mt-4">
|
Price:
|
||||||
<div class="">
|
<span class="line-through italic mr-1">{{product.actualPrice}}</span>
|
||||||
<p class="text-gray-400 text-xs line-through italic">Rs. {{ product.listPrice }}</p>
|
<span class="font-bold">{{product.listPrice}}/-</span>
|
||||||
<p class="font-medium text-lg">Rs. {{ product.actualPrice }}</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button (click)="addToCart($event)" class="btn btn-primary p-3" title="Add to cart">
|
|
||||||
<lucide-angular [img]="ShoppingCartIcon" class="w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,29 +1,22 @@
|
|||||||
import { Component, inject, Input } from "@angular/core";
|
import { Component, inject, Input } from "@angular/core";
|
||||||
|
import { LucideAngularModule, Heart } from "lucide-angular";
|
||||||
import { ProductModel } from "../../../../core/models/product.model";
|
import { ProductModel } from "../../../../core/models/product.model";
|
||||||
|
import { BACKEND_URL } from "../../../../core/tokens/api-url-tokens";
|
||||||
import { Router } from "@angular/router";
|
import { Router } from "@angular/router";
|
||||||
import { FavoriteButton } from "../../../../shared/components/favorite-button/favorite-button";
|
|
||||||
import { LucideAngularModule, ShoppingCart } from "lucide-angular";
|
|
||||||
import { CartService } from "@app/core/services/cart-service";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-product-card",
|
selector: "app-product-card",
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FavoriteButton, LucideAngularModule],
|
imports: [LucideAngularModule],
|
||||||
templateUrl: "./product-card.html",
|
templateUrl: "./product-card.html",
|
||||||
})
|
})
|
||||||
export class ProductCard {
|
export class ProductCard {
|
||||||
|
readonly HeartIcon = Heart;
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
readonly ShoppingCartIcon = ShoppingCart;
|
|
||||||
readonly cartService = inject(CartService);
|
|
||||||
|
|
||||||
@Input() product!: ProductModel;
|
@Input() product!: ProductModel;
|
||||||
|
|
||||||
goToProductDetails() {
|
goToProductDetails() {
|
||||||
this.router.navigate(["/products", this.product.slug]);
|
this.router.navigate(["/products", this.product.slug]);
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(event: Event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.cartService.addToCart({ productId: this.product.id, quantity: 1 }).subscribe();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<p class="text-3xl text-gray-700 font-sans">Our Product</p>
|
<p class="text-3xl text-gray-700 font-sans">Our Product</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-10 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-4 wrapper">
|
<section class="mt-4 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4 wrapper">
|
||||||
@for (product of products(); track product) {
|
@for (product of products(); track product) {
|
||||||
<app-product-card [product]="product" />
|
<app-product-card [product]="product" />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core";
|
|||||||
import { HttpClient } from "@angular/common/http";
|
import { HttpClient } from "@angular/common/http";
|
||||||
import { API_URL } from "../../../core/tokens/api-url-tokens";
|
import { API_URL } from "../../../core/tokens/api-url-tokens";
|
||||||
import { ProductCollection, ProductModel } from "../../../core/models/product.model";
|
import { ProductCollection, ProductModel } from "../../../core/models/product.model";
|
||||||
|
import { map } from "rxjs";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: "root",
|
providedIn: "root",
|
||||||
|
|||||||
@ -16,27 +16,28 @@
|
|||||||
alt="Product Image"
|
alt="Product Image"
|
||||||
class="w-full h-full object-cover object-center rounded-lg"
|
class="w-full h-full object-cover object-center rounded-lg"
|
||||||
/>
|
/>
|
||||||
<app-favorite-button
|
<button
|
||||||
[productId]="product()!.id"
|
class="absolute top-2 right-2 p-2! rounded-full! btn btn-ghost border-none! bg-gray-50"
|
||||||
class="absolute top-5 right-5"
|
>
|
||||||
[isFavorite]="product()!.isFavorite"
|
<lucide-angular [img]="HeartIcon" class="w-4 h-4" />
|
||||||
/>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="prevImage()"
|
(click)="prevImage()"
|
||||||
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
|
class="absolute top-45 left-2 p-2! rounded-full! btn btn-ghost"
|
||||||
>
|
>
|
||||||
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4 text-gray-200" />
|
<lucide-angular [img]="ArrowLeftIcon" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="nextImage()"
|
(click)="nextImage()"
|
||||||
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost backdrop-blur-xs"
|
class="absolute top-45 right-2 p-2! rounded-full! btn btn-ghost"
|
||||||
>
|
>
|
||||||
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4 text-gray-200" />
|
<lucide-angular [img]="ArrowRightIcon" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-4 gap-3">
|
<div class="grid grid-cols-4 gap-3">
|
||||||
@for (image of product()?.productImages; track image) {
|
@for (image of product()?.productImages; track image) {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="card p-2! aspect-square cursor-pointer"
|
class="card p-2! aspect-square cursor-pointer"
|
||||||
(click)="this.activeImageIndex.set($index)"
|
(click)="this.activeImageIndex.set($index)"
|
||||||
@ -90,16 +91,38 @@
|
|||||||
<div class="lg:col-span-3 flex flex-col gap-6">
|
<div class="lg:col-span-3 flex flex-col gap-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex justify-between items-start mb-6">
|
<div class="flex justify-between items-start mb-6">
|
||||||
<div class="">
|
<div class="flex items-baseline gap-2">
|
||||||
<p class="text-sm text-gray-400 line-through decoration-1">
|
<span class="text-xl text-gray-400 line-through decoration-1"
|
||||||
Rs.{{ product()?.listPrice }}
|
>Rs.{{ product()?.actualPrice }}</span
|
||||||
</p>
|
>
|
||||||
<p class="text-3xl font-bold text-gray-900">Rs.{{ product()?.actualPrice }}</p>
|
<span class="text-3xl font-bold text-gray-900">Rs.{{ product()?.listPrice }}</span>
|
||||||
|
<span class="text-xs text-gray-400 ml-1"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="text-gray-400 hover:text-red-500">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mb-6 overflow-hidden">
|
||||||
|
<button class="px-4 py-2 btn btn-ghost rounded-r-none!">−</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="1"
|
||||||
|
class="w-12 text-center btn btn-ghost rounded-none! border-x-0!"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<button class="px-4 py-2 btn btn-ghost rounded-l-none!">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<button (click)="addToCart()" class="w-full btn btn-ghost">Add to Cart</button>
|
<button class="w-full btn btn-ghost">Add to Cart</button>
|
||||||
<button class="w-full btn btn-primary">Buy Now</button>
|
<button class="w-full btn btn-primary">Buy Now</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,12 +2,10 @@ import { Component, inject, Input, signal, WritableSignal } from "@angular/core"
|
|||||||
import { ProductModel } from "../../../core/models/product.model";
|
import { ProductModel } from "../../../core/models/product.model";
|
||||||
import { ProductService } from "../services/product-service";
|
import { ProductService } from "../services/product-service";
|
||||||
import { LucideAngularModule, Heart, ArrowRight, ArrowLeft } from "lucide-angular";
|
import { LucideAngularModule, Heart, ArrowRight, ArrowLeft } from "lucide-angular";
|
||||||
import { FavoriteButton } from "../../../shared/components/favorite-button/favorite-button";
|
|
||||||
import { CartService } from "@app/core/services/cart-service";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-show-product",
|
selector: "app-show-product",
|
||||||
imports: [LucideAngularModule, FavoriteButton],
|
imports: [LucideAngularModule],
|
||||||
templateUrl: "./show-product.html",
|
templateUrl: "./show-product.html",
|
||||||
styleUrl: "./show-product.css",
|
styleUrl: "./show-product.css",
|
||||||
})
|
})
|
||||||
@ -18,7 +16,6 @@ export class ShowProduct {
|
|||||||
ArrowRightIcon = ArrowRight;
|
ArrowRightIcon = ArrowRight;
|
||||||
ArrowLeftIcon = ArrowLeft;
|
ArrowLeftIcon = ArrowLeft;
|
||||||
productService = inject(ProductService);
|
productService = inject(ProductService);
|
||||||
cartService = inject(CartService);
|
|
||||||
product = signal<ProductModel | null>(null);
|
product = signal<ProductModel | null>(null);
|
||||||
activeImageIndex: WritableSignal<number> = signal(0);
|
activeImageIndex: WritableSignal<number> = signal(0);
|
||||||
totalImageCount: number = 0;
|
totalImageCount: number = 0;
|
||||||
@ -31,14 +28,9 @@ export class ShowProduct {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart() {
|
|
||||||
this.cartService.addToCart({ productId: this.product()!.id, quantity: 1 }).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
nextImage() {
|
nextImage() {
|
||||||
this.activeImageIndex.update((index) => (index + 1) % this.totalImageCount);
|
this.activeImageIndex.update((index) => (index + 1) % this.totalImageCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
prevImage() {
|
prevImage() {
|
||||||
this.activeImageIndex.update(
|
this.activeImageIndex.update(
|
||||||
(index) =>
|
(index) =>
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
<li class="px-2! py-4! border-b border-b-gray-200">
|
|
||||||
<div class="flex space-x-5">
|
|
||||||
<div class="w-20 h-20 bg-gray-100 p-2 rounded-xl">
|
|
||||||
<img [src]="cartItem.image" class="object-cover h-full w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 flex flex-col">
|
|
||||||
<p class="text-sm truncate max-w-30 mt-2">{{ cartItem.title }}</p>
|
|
||||||
<div class="mt-auto flex space-x-4 items-center text-gray-600">
|
|
||||||
<p class="text-sm">Rs. {{ cartItem.price }} x {{ cartItem.quantity }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col justify-between">
|
|
||||||
<div class="mt-2 flex space-x-2 items-center">
|
|
||||||
<p class="font-medium">Rs. {{ cartItem.subtotal }}</p>
|
|
||||||
<button (click)="removeProduct()" class="active:scale-80 py-0" title="Remove Item">
|
|
||||||
<lucide-angular [name]="TrashIcon" class="w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex max-h-7 text-xs text-center text-gray-700">
|
|
||||||
<button (click)="decrementQty()" class="btn btn-ghost py-1! px-2 rounded-r-none!">-</button>
|
|
||||||
<p class="w-7 text-center py-1 px-2 border-y border-y-gray-300">{{ cartItem.quantity }}</p>
|
|
||||||
<button (click)="incrementQty()" class="btn py-1! btn-ghost px-2 rounded-l-none!">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { CartItem } from "./cart-item";
|
|
||||||
|
|
||||||
describe("CartItem", () => {
|
|
||||||
let component: CartItem;
|
|
||||||
let fixture: ComponentFixture<CartItem>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [CartItem],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(CartItem);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { Component, EventEmitter, Input, Output, signal } from "@angular/core";
|
|
||||||
import { CartItemModel, CartItemRequest } from "@app/core/models/cart.model";
|
|
||||||
import { LucideAngularModule, Trash } from "lucide-angular";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-cart-item",
|
|
||||||
imports: [LucideAngularModule],
|
|
||||||
templateUrl: "./cart-item.html",
|
|
||||||
styleUrl: "./cart-item.css",
|
|
||||||
})
|
|
||||||
export class CartItem {
|
|
||||||
@Input() cartItem!: CartItemModel;
|
|
||||||
@Output() qtyChangeEvent = new EventEmitter<CartItemRequest>();
|
|
||||||
@Output() productDeleteEvent = new EventEmitter<number>();
|
|
||||||
TrashIcon = Trash;
|
|
||||||
|
|
||||||
incrementQty() {
|
|
||||||
if (this.cartItem.quantity < 10) {
|
|
||||||
this.cartItem.quantity += 1;
|
|
||||||
this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decrementQty() {
|
|
||||||
if (this.cartItem.quantity > 1) {
|
|
||||||
this.cartItem.quantity -= 1;
|
|
||||||
this.qtyChangeEvent.emit({ productId: this.cartItem.id, quantity: this.cartItem.quantity });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeProduct() {
|
|
||||||
this.productDeleteEvent.emit(this.cartItem.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<ul>
|
|
||||||
@if (authService.authState() === AuthState.Unauthenticated) {
|
|
||||||
<li><a class="block h-full w-full" routerLink="/login">Login to access cart</a></li>
|
|
||||||
} @else if (authService.authState() === AuthState.Loading) {
|
|
||||||
<li><a class="block h-full w-full">Loading</a></li>
|
|
||||||
} @else {
|
|
||||||
<ol
|
|
||||||
[class.pointer-events-none]="isLoading()"
|
|
||||||
[class.opacity-40]="isLoading()"
|
|
||||||
[class.cursor-block]="isLoading()"
|
|
||||||
class="rounded-none!"
|
|
||||||
>
|
|
||||||
@for (item of cart.items; track item.id) {
|
|
||||||
<app-cart-item
|
|
||||||
(qtyChangeEvent)="updateProductQty($event)"
|
|
||||||
(productDeleteEvent)="removeProduct($event)"
|
|
||||||
[cartItem]="item"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div class="flex justify-between mt-4 px-2 font-bold text-lg">
|
|
||||||
<p>Total</p>
|
|
||||||
<p>Rs. {{ cart.totalPrice }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<li class="pt-4! mt-4 border-t border-gray-200 rounded-none!">
|
|
||||||
<a [routerLink]="`/checkout/address`" class="btn btn-primary px-4">Proceed to checkout</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { Cart } from "./cart";
|
|
||||||
|
|
||||||
describe("Cart", () => {
|
|
||||||
let component: Cart;
|
|
||||||
let fixture: ComponentFixture<Cart>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Cart],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Cart);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import { Component, computed, inject, Input, signal } from "@angular/core";
|
|
||||||
import { CartItemModel, CartItemRequest, CartModel } from "@app/core/models/cart.model";
|
|
||||||
import { CartItem } from "../cart-item/cart-item";
|
|
||||||
import { AuthService, AuthState } from "@core/services/auth-service";
|
|
||||||
import { CartService } from "@app/core/services/cart-service";
|
|
||||||
import { finalize, tap } from "rxjs";
|
|
||||||
import { RouterLink } from "@angular/router";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-cart",
|
|
||||||
imports: [CartItem, RouterLink],
|
|
||||||
templateUrl: "./cart.html",
|
|
||||||
styleUrl: "./cart.css",
|
|
||||||
})
|
|
||||||
export class Cart {
|
|
||||||
@Input() cart!: CartModel;
|
|
||||||
|
|
||||||
isLoading = signal(false);
|
|
||||||
|
|
||||||
protected readonly authService = inject(AuthService);
|
|
||||||
protected readonly cartService = inject(CartService);
|
|
||||||
protected readonly AuthState = AuthState;
|
|
||||||
|
|
||||||
updateProductQty(cartItem: CartItemRequest) {
|
|
||||||
this.isLoading.set(true);
|
|
||||||
|
|
||||||
this.cartService
|
|
||||||
.updateCart(cartItem)
|
|
||||||
.pipe(finalize(() => this.isLoading.set(false)))
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeProduct(productId: number) {
|
|
||||||
this.isLoading.set(true);
|
|
||||||
|
|
||||||
this.cartService
|
|
||||||
.removeFromCart(productId)
|
|
||||||
.pipe(finalize(() => this.isLoading.set(false)))
|
|
||||||
.subscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<!--Favorite button -->
|
|
||||||
<button
|
|
||||||
(click)="$event.stopPropagation(); toggleFavorite($event)"
|
|
||||||
class="transition-all duration-300 ease active:scale-80 hover:bg-gray-100 p-1 rounded-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<lucide-angular
|
|
||||||
[img]="HeartIcon"
|
|
||||||
[class.text-gray-500]="!isFavorite"
|
|
||||||
[class.text-red-400]="isFavorite"
|
|
||||||
[class]="isFavorite ? 'fill-red-500' : 'fill-none'"
|
|
||||||
class="w-4 h-4 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { FavoriteButton } from "./favorite-button";
|
|
||||||
|
|
||||||
describe("FavoriteButton", () => {
|
|
||||||
let component: FavoriteButton;
|
|
||||||
let fixture: ComponentFixture<FavoriteButton>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [FavoriteButton],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(FavoriteButton);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { Component, inject, Input } from "@angular/core";
|
|
||||||
import { HeartIcon, LucideAngularModule } from "lucide-angular";
|
|
||||||
import { FavoriteService } from "../../../core/services/favorite-service";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-favorite-button",
|
|
||||||
imports: [LucideAngularModule],
|
|
||||||
templateUrl: "./favorite-button.html",
|
|
||||||
styleUrl: "./favorite-button.css",
|
|
||||||
})
|
|
||||||
export class FavoriteButton {
|
|
||||||
@Input({ required: true }) productId!: number;
|
|
||||||
@Input() isFavorite = false;
|
|
||||||
|
|
||||||
favoriteService = inject(FavoriteService);
|
|
||||||
|
|
||||||
HeartIcon = HeartIcon;
|
|
||||||
|
|
||||||
toggleFavorite(event: Event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
this.isFavorite = !this.isFavorite;
|
|
||||||
|
|
||||||
this.favoriteService.toggle(this.productId).subscribe({
|
|
||||||
next: (response) => {
|
|
||||||
this.isFavorite = response.isFavorite;
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
// Revert the state incase of error
|
|
||||||
this.isFavorite = !this.isFavorite;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
<a [routerLink]="route" class="flex space-x-2 w-min my-4 text-gray-600 hover:text-blue-500 text-sm">
|
|
||||||
<lucide-angular [img]="MoveLeftIcon" class="w-4" />
|
|
||||||
<p class="font-medium">{{ text }}</p>
|
|
||||||
</a>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { GoBack } from "./go-back";
|
|
||||||
|
|
||||||
describe("GoBack", () => {
|
|
||||||
let component: GoBack;
|
|
||||||
let fixture: ComponentFixture<GoBack>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [GoBack],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(GoBack);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import { Component, Input } from "@angular/core";
|
|
||||||
import { RouterLink } from "@angular/router";
|
|
||||||
import { LucideAngularModule, MoveLeft } from "lucide-angular";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-go-back",
|
|
||||||
imports: [RouterLink, LucideAngularModule],
|
|
||||||
templateUrl: "./go-back.html",
|
|
||||||
styleUrl: "./go-back.css",
|
|
||||||
})
|
|
||||||
export class GoBack {
|
|
||||||
@Input() route: string = "#";
|
|
||||||
@Input() text: string = "";
|
|
||||||
MoveLeftIcon = MoveLeft;
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { LoadingSpinner } from "./loading-spinner";
|
|
||||||
|
|
||||||
describe("LoadingSpinner", () => {
|
|
||||||
let component: LoadingSpinner;
|
|
||||||
let fixture: ComponentFixture<LoadingSpinner>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [LoadingSpinner],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(LoadingSpinner);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { Component, input } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-loading-spinner",
|
|
||||||
standalone: true,
|
|
||||||
imports: [],
|
|
||||||
template: `
|
|
||||||
@if (isLoading()) {
|
|
||||||
<div class="grid w-full place-items-center overflow-x-scroll lg:overflow-visible">
|
|
||||||
<svg
|
|
||||||
class="text-gray-300 animate-spin w-6"
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M32 3C35.8083 3 39.5794 3.75011 43.0978 5.20749C46.6163 6.66488 49.8132 8.80101 52.5061 11.4939C55.199 14.1868 57.3351 17.3837 58.7925 20.9022C60.2499 24.4206 61 28.1917 61 32C61 35.8083 60.2499 39.5794 58.7925 43.0978C57.3351 46.6163 55.199 49.8132 52.5061 52.5061C49.8132 55.199 46.6163 57.3351 43.0978 58.7925C39.5794 60.2499 35.8083 61 32 61C28.1917 61 24.4206 60.2499 20.9022 58.7925C17.3837 57.3351 14.1868 55.199 11.4939 52.5061C8.801 49.8132 6.66487 46.6163 5.20749 43.0978C3.7501 39.5794 3 35.8083 3 32C3 28.1917 3.75011 24.4206 5.2075 20.9022C6.66489 17.3837 8.80101 14.1868 11.4939 11.4939C14.1868 8.80099 17.3838 6.66487 20.9022 5.20749C24.4206 3.7501 28.1917 3 32 3L32 3Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M32 3C36.5778 3 41.0906 4.08374 45.1692 6.16256C49.2477 8.24138 52.7762 11.2562 55.466 14.9605C58.1558 18.6647 59.9304 22.9531 60.6448 27.4748C61.3591 31.9965 60.9928 36.6232 59.5759 40.9762"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="text-gray-900"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<ng-content />
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class LoadingSpinner {
|
|
||||||
isLoading = input<boolean>(false);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<ol class="flex">
|
|
||||||
@for (step of steps; track step) {
|
|
||||||
<li class="flex flex-col items-start">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<a
|
|
||||||
[routerLink]="$index <= currentStep ? step.route : ''"
|
|
||||||
[class.cursor-not-allowed]="$index > currentStep"
|
|
||||||
[class.bg-blue-600]="$index <= currentStep"
|
|
||||||
class="btn py-0! px-1 rounded-full! w-min"
|
|
||||||
>
|
|
||||||
<lucide-angular
|
|
||||||
[class.text-white]="$index <= currentStep"
|
|
||||||
[class.text-gray-400]="$index > currentStep"
|
|
||||||
[img]="$index <= currentStep ? CheckIcon : CirecleIcon"
|
|
||||||
class="w-4"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Connected line -->
|
|
||||||
@if (!$last) {
|
|
||||||
<hr
|
|
||||||
[class.border-blue-600]="$index < currentStep"
|
|
||||||
[class.border-gray-200]="$index >= currentStep"
|
|
||||||
class="border w-20"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-600">{{ step.label }}</p>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { Stepper } from "./stepper";
|
|
||||||
|
|
||||||
describe("Stepper", () => {
|
|
||||||
let component: Stepper;
|
|
||||||
let fixture: ComponentFixture<Stepper>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Stepper],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Stepper);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { Component, Input } from "@angular/core";
|
|
||||||
import { Check, Circle, LucideAngularModule } from "lucide-angular";
|
|
||||||
import { RouterLink } from "@angular/router";
|
|
||||||
|
|
||||||
export type Steps = {
|
|
||||||
label: string;
|
|
||||||
route: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-stepper",
|
|
||||||
imports: [LucideAngularModule, RouterLink],
|
|
||||||
templateUrl: "./stepper.html",
|
|
||||||
styleUrl: "./stepper.css",
|
|
||||||
})
|
|
||||||
export class Stepper {
|
|
||||||
@Input() currentStep: number = 0;
|
|
||||||
@Input() steps: Steps[] = [];
|
|
||||||
|
|
||||||
CheckIcon = Check;
|
|
||||||
CirecleIcon = Circle;
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { FullnamePipe } from "./fullname-pipe";
|
|
||||||
|
|
||||||
describe("FullnamePipe", () => {
|
|
||||||
it("create an instance", () => {
|
|
||||||
const pipe = new FullnamePipe();
|
|
||||||
expect(pipe).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from "@angular/core";
|
|
||||||
import { TitleCasePipe } from "@angular/common";
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: "fullname",
|
|
||||||
})
|
|
||||||
export class FullnamePipe implements PipeTransform {
|
|
||||||
titlecase = new TitleCasePipe();
|
|
||||||
|
|
||||||
transform(values: string[]): unknown {
|
|
||||||
return this.titlecase.transform(values.join(" "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 antialiased m-0;
|
@apply bg-gray-100 antialiased m-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
@ -25,19 +25,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply text-gray-600 border border-b-3 border-gray-300 hover:bg-gray-200/70 hover:text-gray-700;
|
@apply text-gray-600 border border-gray-300 hover:bg-gray-800 hover:text-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-black {
|
.btn-black {
|
||||||
@apply text-gray-100 bg-gray-800 border border-b-3 border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400;
|
@apply text-gray-100 bg-gray-800 border border-gray-800 hover:bg-gray-200 hover:text-gray-800 hover:border-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply text-blue-100 bg-blue-600 border-b border-b-3 border-blue-900 hover:bg-blue-700;
|
@apply text-gray-100 bg-blue-700 border border-blue-800 hover:bg-blue-200 hover:text-blue-800 hover:border-blue-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white rounded-xl border-2 border-gray-200 p-4;
|
@apply bg-gray-50 rounded-xl border border-gray-300 hover:border-gray-400 p-4 hover:shadow-xl transition-all duration-300 ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldset {
|
.fieldset {
|
||||||
@ -70,7 +70,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown li {
|
.dropdown li {
|
||||||
@apply rounded-lg hover:bg-linear-to-r hover:bg-gray-100 px-5 py-1;
|
@apply rounded-lg hover:bg-linear-to-r hover:from-teal-300 hover:to-transparent px-5 py-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
|||||||
@ -13,14 +13,7 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "preserve",
|
"module": "preserve"
|
||||||
"baseUrl": "./src", // Paths are resolved relative to the baseUrl
|
|
||||||
"paths": {
|
|
||||||
"@app/*": ["app/*"],
|
|
||||||
"@shared/*": ["app/shared/*"],
|
|
||||||
"@core/*": ["app/core/*"],
|
|
||||||
"@env/*": ["environments/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user