Compare commits
2 Commits
feature/or
...
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);
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
<svg height="1524" viewBox="55.2 38.3 464.5 287.8" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m519.7 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.2-143.9 143.5-143.9 143.7 64.4 143.7 143.9z" fill="#f79f1a"/><path d="m342.4 182.2c0 79.5-64.3 143.9-143.6 143.9s-143.6-64.4-143.6-143.9 64.3-143.9 143.6-143.9 143.6 64.4 143.6 143.9z" fill="#ea001b"/><path d="m287.4 68.9c-33.5 26.3-55 67.3-55 113.3s21.5 87 55 113.3c33.5-26.3 55-67.3 55-113.3s-21.5-86.9-55-113.3z" fill="#ff5f01"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 516 B |
@ -1 +0,0 @@
|
|||||||
<svg width="2500" height="1045" viewBox="0 0 512 214" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774zm96.142-66.986l-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022l.142-30.862zm71.112 41.386L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524zm38.257-10.524h35.698v124.444h-35.698V47.36zm0-10.809l35.698-7.68V0l-35.698 7.538V36.55zm109.938 8.391c-13.938 0-22.898 6.542-27.875 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716zm-8.533 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28zM512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658zm-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756h-36.694z" fill="#6772E5"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1 +0,0 @@
|
|||||||
<svg height="812" viewBox="0.5 0.5 999 323.684" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="M651.185.5c-70.933 0-134.322 36.766-134.322 104.694 0 77.9 112.423 83.28 112.423 122.415 0 16.478-18.884 31.229-51.137 31.229-45.773 0-79.984-20.611-79.984-20.611l-14.638 68.547s39.41 17.41 91.734 17.41c77.552 0 138.576-38.572 138.576-107.66 0-82.316-112.89-87.537-112.89-123.86 0-12.91 15.501-27.053 47.662-27.053 36.286 0 65.892 14.99 65.892 14.99l14.326-66.204S696.614.5 651.185.5zM2.218 5.497L.5 15.49s29.842 5.461 56.719 16.356c34.606 12.492 37.072 19.765 42.9 42.353l63.51 244.832h85.138L379.927 5.497h-84.942L210.707 218.67l-34.39-180.696c-3.154-20.68-19.13-32.477-38.685-32.477H2.218zm411.865 0L347.449 319.03h80.999l66.4-313.534h-80.765zm451.759 0c-19.532 0-29.88 10.457-37.474 28.73L709.699 319.03h84.942l16.434-47.468h103.483l9.994 47.468H999.5L934.115 5.497h-68.273zm11.047 84.707l25.178 117.653h-67.454z" fill="#1434cb"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 945 B |
@ -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,16 +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 { CartService } from "@app/core/services/cart-service";
|
|
||||||
import { Cart } from "@app/shared/components/cart/cart";
|
|
||||||
import { CartModel } from "@app/core/models/cart.model";
|
|
||||||
import { map } from "rxjs";
|
|
||||||
import { AsyncPipe } from "@angular/common";
|
|
||||||
import { RouterLink } from "@angular/router";
|
import { RouterLink } from "@angular/router";
|
||||||
|
import { AuthService, AuthState } from "../../../features/auth/services/auth-service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-header",
|
selector: "app-header",
|
||||||
imports: [LucideAngularModule, Cart, AsyncPipe, RouterLink],
|
imports: [LucideAngularModule, RouterLink],
|
||||||
templateUrl: "./header.html",
|
templateUrl: "./header.html",
|
||||||
styleUrl: "./header.css",
|
styleUrl: "./header.css",
|
||||||
})
|
})
|
||||||
@ -19,8 +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);
|
|
||||||
cartItems$ = this.cartService.cartItems$;
|
|
||||||
cartItemCount = this.cartItems$.pipe(map((cart: CartModel) => cart.itemsCount ?? 0));
|
|
||||||
protected readonly AuthState = AuthState;
|
protected readonly AuthState = AuthState;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,60 +0,0 @@
|
|||||||
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
|
|
||||||
import { effect, inject, Injectable } from "@angular/core";
|
|
||||||
import { API_URL } from "../tokens/api-url-tokens";
|
|
||||||
import { CartItemRequest, CartModel } from "../models/cart.model";
|
|
||||||
import { AuthService } from "@core/services/auth-service";
|
|
||||||
import { BehaviorSubject, tap } from "rxjs";
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class CartService {
|
|
||||||
private authService = inject(AuthService);
|
|
||||||
// dependencies
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
private apiUrl = inject(API_URL);
|
|
||||||
|
|
||||||
private _cartItems = new BehaviorSubject<CartModel>({} as CartModel);
|
|
||||||
|
|
||||||
cartItems$ = this._cartItems.asObservable();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
if (this.authService.isAuthenticated()) {
|
|
||||||
this.fetchCart();
|
|
||||||
} else {
|
|
||||||
this._cartItems.next({} as CartModel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCart() {
|
|
||||||
return this.http.get<CartModel>(this.apiUrl + "/cart").subscribe({
|
|
||||||
next: (data) => this._cartItems.next(data),
|
|
||||||
error: (error: HttpErrorResponse) => {
|
|
||||||
if (error.status === 401) {
|
|
||||||
this.authService.purgeAuth();
|
|
||||||
}
|
|
||||||
// show an error in toast
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
addToCart(data: CartItemRequest) {
|
|
||||||
return this.http
|
|
||||||
.post<CartModel>(this.apiUrl + "/cart", data)
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCart(data: CartItemRequest) {
|
|
||||||
return this.http
|
|
||||||
.patch<CartModel>(this.apiUrl + "/cart", data)
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromCart(productId: number) {
|
|
||||||
return this.http
|
|
||||||
.delete<CartModel>(this.apiUrl + "/cart", { body: { productId: productId } })
|
|
||||||
.pipe(tap((updatedCart: CartModel) => this._cartItems.next(updatedCart)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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`, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { Injectable } from "@angular/core";
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class SessionStorageService {
|
|
||||||
setItem<T>(key: string, value: T) {
|
|
||||||
try {
|
|
||||||
const item = JSON.stringify(value);
|
|
||||||
sessionStorage.setItem(key, item);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Could not set item", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getItem<T>(key: string): T | null {
|
|
||||||
try {
|
|
||||||
const item = sessionStorage.getItem(key);
|
|
||||||
return item ? (JSON.parse(item) as T) : null;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Could not get item", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Error if key is not found.
|
|
||||||
* @param key
|
|
||||||
*/
|
|
||||||
removeItem(key: string): void {
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
sessionStorage.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { SessionStorage } from "./session-storage.service";
|
|
||||||
|
|
||||||
describe("SessionStorage", () => {
|
|
||||||
let service: SessionStorage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
service = TestBed.inject(SessionStorage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be created", () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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,63 +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 && currentStepNumber() < 3) {
|
|
||||||
<app-go-back
|
|
||||||
[route]="steps[currentStepNumber() - 1].route"
|
|
||||||
[text]="steps[currentStepNumber() - 1 ].label"
|
|
||||||
class="ml-4 block"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div [class.md:grid-cols-6]="currentStepNumber() < 3" class="grid gap-10">
|
|
||||||
<div class="md:col-span-4">
|
|
||||||
<router-outlet />
|
|
||||||
</div>
|
|
||||||
@if (currentStepNumber() < 3) {
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<app-order-summery class="hidden md:block" />
|
|
||||||
<div class="card mt-4">
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<legend class="fieldset-legend">Have any coupon ?</legend>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input class="input" placeholder="Enter coupon here" type="text" />
|
|
||||||
<button class="btn btn-ghost px-4">Apply</button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
@if (addressIdControl.invalid && addressIdControl.touched) {
|
|
||||||
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
|
|
||||||
Please select an address
|
|
||||||
</div>
|
|
||||||
} @if (paymentMethodControl.invalid && paymentMethodControl.touched) {
|
|
||||||
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
|
|
||||||
Please select an payment checkout
|
|
||||||
</div>
|
|
||||||
} @if (currentStepNumber() === 1) {
|
|
||||||
<button
|
|
||||||
(click)="proceedToPayment()"
|
|
||||||
[disabled]="addressIdControl.invalid && addressIdControl.touched || orderCreationLoading()"
|
|
||||||
class="btn btn-primary w-full mt-4"
|
|
||||||
>
|
|
||||||
<app-loading-spinner [isLoading]="orderCreationLoading()">
|
|
||||||
Proceed to payment
|
|
||||||
</app-loading-spinner>
|
|
||||||
</button>
|
|
||||||
} @if (currentStepNumber() === 2) {
|
|
||||||
<button
|
|
||||||
(click)="proceedToCheckout()"
|
|
||||||
[disabled]="paymentMethodControl.invalid && paymentMethodControl.touched || proceedToPaymentLoading()"
|
|
||||||
class="btn btn-primary w-full mt-4"
|
|
||||||
>
|
|
||||||
<app-loading-spinner [isLoading]="proceedToPaymentLoading()">
|
|
||||||
Proceed to checkout
|
|
||||||
</app-loading-spinner>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { Routes } from "@angular/router";
|
|
||||||
import { Address } from "./address/address";
|
|
||||||
import { Checkout } from "./checkout";
|
|
||||||
import { Payment } from "@app/features/checkout/payment/payment";
|
|
||||||
import Confirmation from "@app/features/checkout/confirmation/confirmation";
|
|
||||||
|
|
||||||
export const checkoutRoutes: Routes = [
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
component: Checkout,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "address",
|
|
||||||
component: Address,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "payment",
|
|
||||||
component: Payment,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "confirmation",
|
|
||||||
component: Confirmation,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -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,109 +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 { CheckoutResponse, OrderService } from "@app/features/checkout/services/order-service";
|
|
||||||
import { CartService } from "@core/services/cart-service";
|
|
||||||
import { CartModel } from "@core/models/cart.model";
|
|
||||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
|
||||||
import { filter, finalize, map } from "rxjs";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-checkout",
|
|
||||||
providers: [AddressService],
|
|
||||||
imports: [RouterOutlet, Stepper, OrderSummery, GoBack, LoadingSpinner],
|
|
||||||
templateUrl: "./checkout.html",
|
|
||||||
styleUrl: "./checkout.css",
|
|
||||||
})
|
|
||||||
export class Checkout implements OnInit {
|
|
||||||
steps: Steps[] = [
|
|
||||||
{ label: "Cart", route: "" },
|
|
||||||
{ label: "Address", route: "/checkout/address" },
|
|
||||||
{ label: "Payment", route: "/checkout/payment" },
|
|
||||||
{ label: "Confirmation", route: "/checkout/confirmation" },
|
|
||||||
];
|
|
||||||
destroyRef = inject(DestroyRef);
|
|
||||||
orderCreationLoading = signal(false);
|
|
||||||
proceedToPaymentLoading = signal(false);
|
|
||||||
|
|
||||||
private addressService = inject(AddressService);
|
|
||||||
addressIdControl = this.addressService.addressIdControl;
|
|
||||||
|
|
||||||
private orderService = inject(OrderService);
|
|
||||||
paymentMethodControl = this.orderService.paymentMethodForm;
|
|
||||||
|
|
||||||
private cartService = inject(CartService);
|
|
||||||
private cart: CartModel | undefined;
|
|
||||||
private router = inject(Router);
|
|
||||||
protected currentStepNumber = toSignal(
|
|
||||||
this.router.events.pipe(
|
|
||||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd), // Added TS type guard
|
|
||||||
map((event: NavigationEnd) => {
|
|
||||||
// Strip query params and fragments to ensure exact matching
|
|
||||||
const cleanUrl = event.urlAfterRedirects.split("?")[0].split("#")[0];
|
|
||||||
|
|
||||||
// Exact match comparison
|
|
||||||
const activeIndex = this.steps.findIndex(
|
|
||||||
(step) => cleanUrl === step.route || (step.route !== "" && cleanUrl.endsWith(step.route)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return activeIndex !== -1 ? activeIndex : 0;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ initialValue: 1 },
|
|
||||||
);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.cartService.cartItems$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((cart) => {
|
|
||||||
this.cart = cart;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected proceedToPayment() {
|
|
||||||
if (this.addressIdControl.invalid) {
|
|
||||||
this.addressIdControl.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.orderCreationLoading.set(true);
|
|
||||||
this.orderService
|
|
||||||
.createOrder({ cartId: this.cart!.id, addressId: this.addressIdControl.value! })
|
|
||||||
.pipe(
|
|
||||||
finalize(() => this.orderCreationLoading.set(false)),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe(async () => {
|
|
||||||
await this.router.navigate(["/checkout/payment"]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected proceedToCheckout() {
|
|
||||||
if (this.paymentMethodControl.invalid) {
|
|
||||||
this.paymentMethodControl.markAsTouched();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.proceedToPaymentLoading.set(true);
|
|
||||||
this.orderService
|
|
||||||
.checkout(this.paymentMethodControl.value!)
|
|
||||||
.pipe(
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
finalize(() => this.proceedToPaymentLoading.set(false)),
|
|
||||||
)
|
|
||||||
.subscribe((response) => this.handleCheckout(response));
|
|
||||||
}
|
|
||||||
private handleCheckout(data: CheckoutResponse): void {
|
|
||||||
if (data.success) {
|
|
||||||
if (data.method === "stripeCheckout") {
|
|
||||||
this.handleStripeCheckout(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private handleStripeCheckout(data: CheckoutResponse) {
|
|
||||||
console.log(data);
|
|
||||||
if (data.redirectUrl) {
|
|
||||||
window.location.href = data.redirectUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,3 +0,0 @@
|
|||||||
<div class="card min-h-20">
|
|
||||||
<ng-content />
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import { PaymentMethodCard } from "./payment-method-card";
|
|
||||||
|
|
||||||
describe("PaymentMethodCard", () => {
|
|
||||||
let component: PaymentMethodCard;
|
|
||||||
let fixture: ComponentFixture<PaymentMethodCard>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [PaymentMethodCard],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(PaymentMethodCard);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { Component } from "@angular/core";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-payment-method-card",
|
|
||||||
imports: [],
|
|
||||||
templateUrl: "./payment-method-card.html",
|
|
||||||
styleUrl: "./payment-method-card.css",
|
|
||||||
})
|
|
||||||
export class PaymentMethodCard {}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<div class="flex flex-col gap-4 items-center justify-center">
|
|
||||||
<app-loading-spinner [isLoading]="isProcessing()">
|
|
||||||
<div class="card max-w-md text-center flex items-center flex-col gap-4">
|
|
||||||
<div
|
|
||||||
[class.bg-green-100]="paymentData()!.isSuccess"
|
|
||||||
[class.bg-red-100]="!paymentData()!.isSuccess"
|
|
||||||
class="rounded-full p-4"
|
|
||||||
>
|
|
||||||
@if (paymentData()!.isSuccess) {
|
|
||||||
<lucide-angular [img]="BadgeCheck" class="w-6 h-6 text-green-500" />
|
|
||||||
} @else {
|
|
||||||
<lucide-angular [img]="BadgeQuestionMark" class="w-6 h-6 text-red-500" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
[class.text-green-500]="paymentData()!.isSuccess"
|
|
||||||
[class.text-red-500]="!paymentData()!.isSuccess"
|
|
||||||
class="text-xl font-medium"
|
|
||||||
>
|
|
||||||
{{ paymentData()!.message }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-400">
|
|
||||||
@if (paymentData()!.isSuccess) { Your payment processed successfully. You will receive a
|
|
||||||
confirmation email shortly. } @else { If money is debited then wait for few hours then check
|
|
||||||
payment status. }
|
|
||||||
</p>
|
|
||||||
@if (paymentData()!.isSuccess) {
|
|
||||||
<article class="rounded-xl bg-gray-100 px-3 py-4 text-gray-500 text-sm w-full space-y-4">
|
|
||||||
<article class="flex justify-between border-b border-b-gray-200 pb-4">
|
|
||||||
<p class="font-medium">Amount</p>
|
|
||||||
<p>Rs.{{ paymentData()?.amount }}</p>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="flex justify-between">
|
|
||||||
<p class="font-medium">Transaction ID</p>
|
|
||||||
<p class="truncate">{{ paymentData()?.transactionId }}</p>
|
|
||||||
</article>
|
|
||||||
<article class="flex justify-between">
|
|
||||||
<p class="font-medium">Payment method</p>
|
|
||||||
<p>{{ paymentData()?.paymentMethod | uppercase }}</p>
|
|
||||||
</article>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</app-loading-spinner>
|
|
||||||
<app-go-back class="w-min-content! flex-nowrap" route="/" text="Continue Shopping" />
|
|
||||||
</div>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
|
||||||
|
|
||||||
import Confirmation from "./confirmation";
|
|
||||||
|
|
||||||
describe("Confirmation", () => {
|
|
||||||
let component: Confirmation;
|
|
||||||
let fixture: ComponentFixture<Confirmation>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [Confirmation],
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(Confirmation);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
await fixture.whenStable();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create", () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
|
|
||||||
import { BadgeCheck, BadgeQuestionMark, LucideAngularModule } from "lucide-angular";
|
|
||||||
import { GoBack } from "@shared/components/go-back/go-back";
|
|
||||||
import { ActivatedRoute } from "@angular/router";
|
|
||||||
import { LoadingSpinner } from "@shared/components/loading-spinner/loading-spinner";
|
|
||||||
import {
|
|
||||||
OrderService,
|
|
||||||
PaymentVerificationResponse,
|
|
||||||
} from "@app/features/checkout/services/order-service";
|
|
||||||
import { finalize, tap } from "rxjs";
|
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
||||||
import { UpperCasePipe } from "@angular/common";
|
|
||||||
import { CartService } from "@core/services/cart-service";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-confirmation",
|
|
||||||
imports: [LucideAngularModule, GoBack, LoadingSpinner, UpperCasePipe],
|
|
||||||
templateUrl: "./confirmation.html",
|
|
||||||
styleUrl: "./confirmation.css",
|
|
||||||
})
|
|
||||||
export default class Confirmation implements OnInit {
|
|
||||||
destroyRef = inject(DestroyRef);
|
|
||||||
protected paymentData = signal<PaymentVerificationResponse | null>(null);
|
|
||||||
protected isProcessing = signal(false);
|
|
||||||
protected readonly BadgeCheck = BadgeCheck;
|
|
||||||
protected readonly BadgeQuestionMark = BadgeQuestionMark;
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private orderService = inject(OrderService);
|
|
||||||
private cartService = inject(CartService);
|
|
||||||
private orderId: string | null = null;
|
|
||||||
private sessionId: string | null = null;
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.orderId = this.route.snapshot.queryParamMap.get("order_id");
|
|
||||||
this.sessionId = this.route.snapshot.queryParamMap.get("session_id");
|
|
||||||
this.checkConfirmation();
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkConfirmation() {
|
|
||||||
if (!this.orderId || !this.sessionId) {
|
|
||||||
console.error("Missing order ID or session ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.isProcessing.set(true);
|
|
||||||
const orderId = parseInt(this.orderId!);
|
|
||||||
this.orderService
|
|
||||||
.verifyPayment(orderId, this.sessionId!)
|
|
||||||
.pipe(
|
|
||||||
tap((data) => {
|
|
||||||
this.paymentData.set(data);
|
|
||||||
if (data && data.isSuccess) {
|
|
||||||
this.cartService.fetchCart();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
finalize(() => this.isProcessing.set(false)),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
console.log("Payment verified successfully");
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this.isProcessing.set(false);
|
|
||||||
console.error(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<div class="flex flex-col gap-4">
|
|
||||||
<app-payment-method-card>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<input
|
|
||||||
(change)="onPaymentMethodSelected('stripeCheckout')"
|
|
||||||
[formControl]="paymentMethodForm"
|
|
||||||
name="payment-method"
|
|
||||||
type="radio"
|
|
||||||
value="stripeCheckout"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex justify-between mb-2">
|
|
||||||
<div class="">
|
|
||||||
<img alt="" class="w-25" src="/assets/images/stripe-4.svg" />
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="card p-3! flex justify-center items-center">
|
|
||||||
<img alt="" class="h-3" src="/assets/images/visa-10.svg" />
|
|
||||||
</div>
|
|
||||||
<div class="card p-3! flex justify-center items-center">
|
|
||||||
<img alt="" class="h-5" src="/assets/images/mastercard-modern-design-.svg" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600">Pay using card via stripe checkout.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-payment-method-card>
|
|
||||||
<app-payment-method-card>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<input
|
|
||||||
(change)="onPaymentMethodSelected('cashOnDelivery')"
|
|
||||||
[formControl]="paymentMethodForm"
|
|
||||||
name="payment-method"
|
|
||||||
type="radio"
|
|
||||||
value="cashOnDelivery"
|
|
||||||
/>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<div class="text-gray-700">
|
|
||||||
<lucide-angular [img]="coinsIcon" class="h-10" />
|
|
||||||
</div>
|
|
||||||
<div class="">
|
|
||||||
<p class="text-xl font-medium text-gray-700">Cash on Delivery</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600">Additional charges may apply.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-payment-method-card>
|
|
||||||
</div>
|
|
||||||
@ -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,28 +0,0 @@
|
|||||||
import { Component, inject, OnInit } from "@angular/core";
|
|
||||||
import { PaymentMethodCard } from "@app/features/checkout/components/payment-method-card/payment-method-card";
|
|
||||||
import { Coins, LucideAngularModule } from "lucide-angular";
|
|
||||||
import { ReactiveFormsModule } from "@angular/forms";
|
|
||||||
import { OrderService } from "@app/features/checkout/services/order-service";
|
|
||||||
import { ActivatedRoute } from "@angular/router";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: "app-payment",
|
|
||||||
imports: [PaymentMethodCard, LucideAngularModule, ReactiveFormsModule],
|
|
||||||
templateUrl: "./payment.html",
|
|
||||||
styleUrl: "./payment.css",
|
|
||||||
})
|
|
||||||
export class Payment implements OnInit {
|
|
||||||
coinsIcon = Coins;
|
|
||||||
private orderService = inject(OrderService);
|
|
||||||
protected paymentMethodForm = this.orderService.paymentMethodForm;
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private orderId: string | null = null;
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.orderId = this.route.snapshot.paramMap.get("order_id");
|
|
||||||
}
|
|
||||||
|
|
||||||
onPaymentMethodSelected(paymentMethod: string) {
|
|
||||||
this.paymentMethodForm.setValue(paymentMethod);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,79 +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";
|
|
||||||
import { FormControl, Validators } from "@angular/forms";
|
|
||||||
import { tap } from "rxjs";
|
|
||||||
import { SessionStorageService } from "@core/services/session-storage.service";
|
|
||||||
|
|
||||||
export interface PaymentVerificationResponse {
|
|
||||||
isSuccess: boolean;
|
|
||||||
message: string;
|
|
||||||
amount: number | null;
|
|
||||||
transactionId: string | null;
|
|
||||||
paymentMethod: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckoutResponse {
|
|
||||||
success: boolean;
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
method: string;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
}
|
|
||||||
export interface OrderRequest {
|
|
||||||
addressId: number;
|
|
||||||
cartId: number;
|
|
||||||
}
|
|
||||||
export interface OrderResponse {
|
|
||||||
orderId: number;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: "root",
|
|
||||||
})
|
|
||||||
export class OrderService {
|
|
||||||
public paymentMethodForm = new FormControl<string | null>(null, Validators.required);
|
|
||||||
private currentOrderId: number | null = null;
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
private apiUrl = inject(API_URL);
|
|
||||||
private authService = inject(AuthService);
|
|
||||||
private user = this.authService.user;
|
|
||||||
private sessionStorage = inject(SessionStorageService);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const cachedOrderId = this.sessionStorage.getItem<number>("orderId");
|
|
||||||
if (cachedOrderId) {
|
|
||||||
this.currentOrderId = cachedOrderId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createOrder(data: OrderRequest) {
|
|
||||||
return this.http
|
|
||||||
.post<OrderResponse>(`${this.apiUrl}/users/${this.user()?.id}/orders`, data)
|
|
||||||
.pipe(
|
|
||||||
tap((response) => {
|
|
||||||
this.currentOrderId = response.orderId;
|
|
||||||
this.sessionStorage.setItem<number>("orderId", response.orderId);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkout(mode: string) {
|
|
||||||
return this.http.post<CheckoutResponse>(
|
|
||||||
`${this.apiUrl}/orders/${this.currentOrderId}/payments`,
|
|
||||||
{
|
|
||||||
mode: mode,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyPayment(orderId: number, sessionId: string) {
|
|
||||||
return this.http.post<PaymentVerificationResponse>(`${this.apiUrl}/payments/verify`, {
|
|
||||||
orderId: orderId,
|
|
||||||
sessionId: sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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">
|
<p class="text-gray-400 text-xs">⭐4.5</p>
|
||||||
{{ product.title }}
|
<p class="text-gray-400 text-xs">
|
||||||
</p>
|
Price:
|
||||||
<p class="text-gray-400 text-xs">⭐4.5</p>
|
<span class="line-through italic mr-1">{{product.actualPrice}}</span>
|
||||||
</div>
|
<span class="font-bold">{{product.listPrice}}/-</span>
|
||||||
<div class="flex justify-between mt-4">
|
</p>
|
||||||
<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>
|
</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,36 +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.cursor-block]="isLoading()"
|
|
||||||
[class.opacity-40]="isLoading()"
|
|
||||||
[class.pointer-events-none]="isLoading()"
|
|
||||||
class="rounded-none!"
|
|
||||||
>
|
|
||||||
@for (item of cart.items; track item.id) {
|
|
||||||
<app-cart-item
|
|
||||||
(productDeleteEvent)="removeProduct($event)"
|
|
||||||
(qtyChangeEvent)="updateProductQty($event)"
|
|
||||||
[cartItem]="item"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
@if (cart.itemsCount > 0) {
|
|
||||||
|
|
||||||
<div class="flex justify-between mt-4 px-2 font-bold text-lg">
|
|
||||||
<p>Total</p>
|
|
||||||
<p>Rs. {{ cart.totalPrice }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<li class="pt-4! mt-4 border-t border-gray-200 rounded-none!">
|
|
||||||
<a [routerLink]="`/checkout/address`" class="btn btn-primary px-4">Proceed to checkout</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@else{
|
|
||||||
<li><a class="block h-full w-full">Cart is empty !</a></li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
@ -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-max my-4 text-gray-600 hover:text-blue-500 text-sm">
|
|
||||||
<lucide-angular [img]="MoveLeftIcon" class="w-4" />
|
|
||||||
<p class="font-medium">{{ text }}</p>
|
|
||||||
</a>
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user