Compare commits

..

32 Commits

Author SHA1 Message Date
kusowl
a0e5cda432 Merge branch 'feature/address-ui' into staging 2026-03-17 18:22:42 +05:30
kusowl
11115c8dc0 Merge branch 'feature/address-api' into staging 2026-03-17 18:22:35 +05:30
kusowl
bb3aafd89e feature: show order summary on address page 2026-03-17 17:05:49 +05:30
kusowl
419e8281e2 BREAKING CHANGE: change obervable name from cartItem$ to cartItems$ 2026-03-17 16:10:05 +05:30
kusowl
63b3d06d3a BREAKING CHANGE: remove wrapping for individual address resource 2026-03-17 11:00:32 +05:30
kusowl
24bdfe9cc6 feature: fetch, edit and add address
- fetch existing addresses from api,
- user can edit existing address
- user can add new address
2026-03-17 10:58:22 +05:30
kusowl
3059a923b4 refactor: move auth service to core 2026-03-16 13:05:06 +05:30
kusowl
6d1cb81e6b wip: checkout page
- add button to go address page in cart ui
- add template for address page
2026-03-13 18:54:48 +05:30
kusowl
3ae3374eec chore: formatting
- updated format via pint
2026-03-13 18:09:06 +05:30
kusowl
c27ae1969f feature: add address api
- users can save, edi and delete addresses
- each user can have multiple address
- used shallow routes for address
2026-03-13 18:08:45 +05:30
kusowl
61ecbec994 chore: add command to generate dto
- add make:dto command which generates dto in App\Data namespace
- varient: --input (default), --output
2026-03-13 11:28:22 +05:30
kusowl
03f044b8d3 chore: add ide helper
- add laravel ide helper package for better LSP support
- update dependencies
2026-03-13 10:08:55 +05:30
kusowl
136d6cf97e Merge branch 'backend' into staging 2026-03-12 10:47:06 +05:30
kusowl
3b5bf80f39 Merge branch 'frontend' into staging 2026-03-12 10:45:49 +05:30
kusowl
50c956c051 fix: make header logo navigation by router 2026-03-12 10:43:38 +05:30
kusowl
ad957efcf0 feature: add to cart
- make the cart service dependable on BehavorialSubject, migrated from
siganls
- implement add to cart service
2026-03-11 19:00:24 +05:30
kusowl
27a04c6458 minor: some quick design and color changes
make the dropdown hover color from gradient to simple gray shade
make the button 3d
change the product card design in home page
add add to cart button in the home page
change design of button ghost
2026-03-11 11:25:07 +05:30
kusowl
2b88cee10b feature: user can change quantity and remove products 2026-03-10 19:07:42 +05:30
kusowl
5bbec0ee2b feature: update quantity and remove product from cart
add endpoint for update quantity of products (min:1, max:10)
add endpoint for removing product from cart
2026-03-10 19:01:52 +05:30
kusowl
1656739ecd feature: add and fetch products in cart api
- schema for cart and product
- define relationship, DTO, Resource and API collections
- Add post and get endpoint for cart api
2026-03-09 19:07:27 +05:30
kusowl
9000ea0052 feature: fetch cart products from api
- show total cart item count on header
- fetch and show cart items on cart modal
2026-03-09 19:04:31 +05:30
kusowl
0faccba476 Merge branch 'frontend' into staging 2026-03-05 18:28:08 +05:30
kusowl
3c2233d53e fix: make favorite button in product show page sync with db 2026-03-05 18:08:52 +05:30
kusowl
95afd46406 fix: add isFavorite on Product show response 2026-03-05 18:07:46 +05:30
kusowl
0f56303d59 fix sanctum and session environment variables 2026-03-05 14:43:21 +05:30
kusowl
a4eebef321 Merge branch 'feature/products' into staging 2026-03-05 13:48:30 +05:30
kusowl
a57566c1fe Merge branch 'backend' into staging 2026-03-05 13:48:24 +05:30
kusowl
7e1ecf35b9 make favorite state persistant with api 2026-03-05 13:34:37 +05:30
kusowl
ae008fbc9c chore: add isFavorite in produtcs response
- refactor code to use query
- add active column in products
2026-03-05 13:32:49 +05:30
kusowl
8ef4383bd9 feature: endpoint to favorite product 2026-03-05 10:32:40 +05:30
kusowl
b575b42f22 Merge remote-tracking branch 'origin/feature/products' into fix/history-issue
# Conflicts:
#	src/app/features/product/components/product-card/product-card.html
#	src/app/features/product/components/product-card/product-card.ts
#	src/app/features/product/services/product-service.ts
2026-03-03 17:40:12 +05:30
kusowl
553637d8e2 fix: commit whole changes 2026-03-03 17:27:30 +05:30
136 changed files with 34197 additions and 299 deletions

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

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

2
.gitignore vendored
View File

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

3
.phpactor.json Normal file
View File

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

1
.rgignore Normal file
View File

@ -0,0 +1 @@
backend/

View File

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

View File

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

2346
backend/.phpstorm.meta.php Normal file

File diff suppressed because it is too large Load Diff

28541
backend/_ide_helper.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
<?php
namespace App\Actions;
use App\Data\AddToCartDTO;
use App\Data\CartDTO;
use App\Enums\CartStatus;
use App\Models\Product;
use App\Models\User;
final readonly class AddProductToCartAction
{
/**
* Execute the action.
*/
public function execute(AddToCartDTO $cartData, User $user)
{
$cart = $user->carts()->active()->firstOrCreate([
'status' => CartStatus::Active,
]);
$price = Product::query()->whereId($cartData->productId)->value('actual_price');
$productInCart = $cart->products()->find($cartData->productId);
if ($productInCart) {
$newQuantity = $productInCart->pivot->quantity + $cartData->quantity;
$cart->products()->updateExistingPivot($cartData->productId, [
'quantity' => $newQuantity,
'price' => $price,
]);
} else {
$cart->products()->attach($cartData->productId, [
'quantity' => $cartData->quantity,
'price' => $price,
]);
}
return CartDTO::fromModel($cart->load(['products' => function ($query) {
$query->withPivot('quantity', 'price');
}]));
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions;
use App\Models\Address;
final readonly class DeleteUserAddressAction
{
/**
* Execute the action.
*/
public function execute(Address $address)
{
$address->users()->detach();
$address->delete();
return response()->noContent();
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Data\CartDTO;
use App\Models\User;
final readonly class GetActiveUserCartAction
{
/**
* Execute the action.
*/
public function execute(User $user)
{
return CartDTO::fromModel($user->carts()->active()->withProducts()->first());
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions;
use App\Models\User;
final readonly class RemoveProductFromCartAction
{
public function __construct(private GetActiveUserCartAction $activeCartAction) {}
/**
* Execute the action.
*/
public function execute(int $productId, User $user)
{
$cart = $user->carts()->active()->sole();
$cart->products()->detach($productId);
return $this->activeCartAction->execute($user);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Actions;
use App\Data\AddUserAddressRequestDTO;
use App\Data\UserAddressResponseDTO;
use App\Models\User;
final readonly class SaveUserAddressAction
{
/**
* Execute the action.
*/
public function execute(AddUserAddressRequestDTO $data, User $user)
{
return UserAddressResponseDTO::fromModel(
$user
->addresses()
->create($data->toArray())
);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions;
use App\Data\AddToCartDTO;
use App\Data\CartDTO;
use App\Models\User;
use InvalidArgumentException;
final readonly class UpdateProductInCartAction
{
/**
* Execute the action.
*
* @throws InvalidArgumentException
*/
public function execute(AddToCartDTO $cartData, User $user)
{
$cart = $user->carts()->active()->sole();
$productInCart = $cart->products()->find($cartData->productId);
throw_if($productInCart === null, new InvalidArgumentException('Product not found'));
$cart->products()->updateExistingPivot($cartData->productId, [
'quantity' => $cartData->quantity,
]);
return CartDTO::fromModel($cart->load(['products' => function ($query) {
$query->withPivot('quantity', 'price');
}]));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions;
use App\Data\UpdateUserAddressRequestDTO;
use App\Data\UserAddressResponseDTO;
use App\Models\Address;
final readonly class UpdateUserAddressAction
{
/**
* Execute the action.
*/
public function execute(UpdateUserAddressRequestDTO $data, Address $address)
{
$address->update($data->toArray());
$address->refresh();
return UserAddressResponseDTO::fromModel($address);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\GeneratorCommand;
use Symfony\Component\Console\Input\InputOption;
class MakeDtoCommand extends GeneratorCommand
{
/**
* The console command name and signature.
*
* @var string
*/
protected $name = 'make:dto';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new Data Transfer Object class';
/**
* The type of class being generated.
*
* @var string
*/
protected $type = 'DTO';
/**
* Get the stub file for the generator.
*/
protected function getStub(): string
{
if ($this->option('output')) {
return base_path('stubs/dto.output.stub');
}
return base_path('stubs/dto.input.stub');
}
/**
* Get the default namespace for the class.
*
* @param string $rootNamespace
*/
protected function getDefaultNamespace($rootNamespace): string
{
return $rootNamespace.'\Data';
}
/**
* Get the console command options.
*/
protected function getOptions(): array
{
return [
['input', 'i', InputOption::VALUE_NONE, 'Generate an Input DTO (default)'],
['output', 'o', InputOption::VALUE_NONE, 'Generate an Output DTO'],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Data;
use App\Contracts\InputDataTransferObject;
use Illuminate\Foundation\Http\FormRequest;
final readonly class AddToCartDTO implements InputDataTransferObject
{
public function __construct(
public int $productId,
public int $quantity
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'productId' => $this->productId,
'quantity' => $this->quantity,
];
}
public static function fromRequest(FormRequest $request): InputDataTransferObject
{
return new self(
productId: $request->productId,
quantity: $request->quantity
);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Data;
use App\Contracts\InputDataTransferObject;
use Illuminate\Foundation\Http\FormRequest;
final readonly class AddUserAddressRequestDTO implements InputDataTransferObject
{
public function __construct(
public string $firstName,
public string $lastName,
public string $street,
public string $city,
public string $state,
public string $pinCode
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'street' => $this->street,
'city' => $this->city,
'state' => $this->state,
'pin' => $this->pinCode,
];
}
public static function fromRequest(FormRequest $request): InputDataTransferObject
{
return new self(
firstName: $request->firstName,
lastName: $request->lastName,
street: $request->street,
city: $request->city,
state: $request->state,
pinCode: $request->pinCode
);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Data;
use App\Contracts\OutputDataTransferObject;
use App\Models\Cart;
final readonly class CartDTO implements OutputDataTransferObject
{
/**
* @param CartItemDTO[] $items
*/
public function __construct(
public int $id,
public ?int $itemsCount = null,
public ?int $totalPrice = null,
public array $items = []
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'itemsCount' => $this->itemsCount,
'totalPrice' => $this->totalPrice,
'items' => $this->items,
];
}
public static function fromModel(Cart $cart)
{
return new self(
id: $cart->id,
itemsCount: $cart->products->count(),
totalPrice: $cart->products->sum(fn ($product) => $product->pivot->price * $product->pivot->quantity),
items: $cart->products->map(fn ($product) => new CartItemDTO(
id: $product->id,
title: $product->title,
quantity: $product->pivot->quantity,
price: $product->actual_price,
subtotal: $product->actual_price * $product->pivot->quantity,
image: $product->images->first()->path,
))->toArray()
);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Data;
use App\Contracts\OutputDataTransferObject;
final readonly class CartItemDTO implements OutputDataTransferObject
{
public function __construct(
public int $id,
public string $title,
public int $quantity,
public float $price,
public float $subtotal,
public string $image
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'quantity' => $this->quantity,
'price' => $this->price,
'subtotal' => $this->subtotal,
'image' => $this->image,
];
}
}

View File

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

View File

@ -0,0 +1,47 @@
<?php
namespace App\Data;
use App\Contracts\InputDataTransferObject;
use Illuminate\Foundation\Http\FormRequest;
final readonly class UpdateUserAddressRequestDTO implements InputDataTransferObject
{
public function __construct(
public ?string $firstName = null,
public ?string $lastName = null,
public ?string $street = null,
public ?string $city = null,
public ?string $state = null,
public ?string $pinCode = null,
) {}
/**
* @return array<string = null = null, mixed>
*/
public function toArray(): array
{
$data = [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'street' => $this->street,
'city' => $this->city,
'state' => $this->state,
'pin' => $this->pinCode,
];
return array_filter($data, fn ($value) => $value !== null);
}
public static function fromRequest(FormRequest $request): InputDataTransferObject
{
return new self(
firstName: $request->input('firstName'),
lastName: $request->input('lastName'),
street: $request->input('street'),
city: $request->input('city'),
state: $request->input('state'),
pinCode: $request->input('pinCode'),
);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Data;
use App\Contracts\OutputDataTransferObject;
use App\Models\Address;
final readonly class UserAddressResponseDTO implements OutputDataTransferObject
{
public function __construct(
public int $id,
public string $firstName,
public string $lastName,
public string $street,
public string $city,
public string $state,
public string $pinCode
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'firstName' => $this->firstName,
'lastName' => $this->lastName,
'street' => $this->street,
'city' => $this->city,
'state' => $this->state,
'pinCode' => $this->pinCode,
];
}
public static function fromModel(Address $address): OutputDataTransferObject
{
return new self(
id: $address->id,
firstName: $address->first_name,
lastName: $address->last_name,
street: $address->street,
city: $address->city,
state: $address->state,
pinCode: $address->pin
);
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum CartStatus: string
{
case Active = 'active'; // freshly created
case Converted = 'converted'; // user ordered
case Abandoned = 'abandoned'; // older than 24hrs
case Expired = 'expired'; // left for a long period
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers;
use App\Actions\AddProductToCartAction;
use App\Actions\GetActiveUserCartAction;
use App\Actions\RemoveProductFromCartAction;
use App\Actions\UpdateProductInCartAction;
use App\Data\AddToCartDTO;
use App\Http\Requests\AddProductToCartRequest;
use App\Http\Requests\RemoveProductFromCartRequest;
use App\Http\Requests\UpdateProductInCartRequest;
use App\Http\Resources\CartResource;
use Illuminate\Support\Facades\Auth;
class CartController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(AddProductToCartRequest $request, AddProductToCartAction $addProductAction)
{
$addToCartData = AddToCartDTO::fromRequest($request);
$cart = $addProductAction->execute($addToCartData, Auth::user());
return new CartResource($cart);
}
/**
* Display the specified resource.
*/
public function show(GetActiveUserCartAction $action)
{
return new CartResource($action->execute(Auth::user()));
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateProductInCartRequest $request, UpdateProductInCartAction $action)
{
$updateCartData = AddToCartDTO::fromRequest($request);
$cart = $action->execute($updateCartData, $request->user());
return new CartResource($cart);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(RemoveProductFromCartRequest $request, RemoveProductFromCartAction $action)
{
$user = $request->user();
try {
$cart = $action->execute($request->productId, $user);
return new CartResource($cart);
} catch (ModelNotFoundException $e) {
Log::error('No active cart found when removing a product from cart.', [
'user' => $user->id,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'No active cart found.',
], 404);
} catch (MultipleRecordsFoundException $e) {
Log::error('Multiple active carts found for the user', [
'user' => $user->id,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'Multiple Active carts found.',
], 409);
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Actions\DeleteUserAddressAction;
use App\Actions\SaveUserAddressAction;
use App\Actions\UpdateUserAddressAction;
use App\Data\AddUserAddressRequestDTO;
use App\Data\UpdateUserAddressRequestDTO;
use App\Data\UserAddressResponseDTO;
use App\Http\Requests\AddUserAddressRequest;
use App\Http\Requests\UpdateUserAddressRequest;
use App\Http\Resources\AddressResource;
use App\Models\Address;
use Illuminate\Http\Request;
class UserAddressController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$addresses = $request->user()->addresses;
$data = $addresses->map(fn ($address) => UserAddressResponseDTO::fromModel($address));
return AddressResource::collection($data);
}
/**
* Store a newly created resource in storage.
*/
public function store(AddUserAddressRequest $request, SaveUserAddressAction $action)
{
return new AddressResource($action->execute(AddUserAddressRequestDTO::fromRequest($request), $request->user()));
}
/**
* Display the specified resource.
*/
public function show(Address $address)
{
return new AddressResource(UserAddressResponseDTO::fromModel($address));
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserAddressRequest $request, Address $address, UpdateUserAddressAction $action)
{
return new AddressResource($action->execute(UpdateUserAddressRequestDTO::fromRequest($request), $address));
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Address $address, DeleteUserAddressAction $action)
{
return $action->execute($address);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class AddProductToCartRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'productId' => 'required|exists:products,id',
'quantity' => 'required|numeric|min:1',
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class AddUserAddressRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'firstName' => 'required|string|min:3|max:50',
'lastName' => 'required|string|min:3|max:50',
'street' => 'required|string|min:3|max:100',
'city' => 'required|string|min:3|max:20',
'state' => 'required|string|min:3|max:40',
'pinCode' => 'required|string|min:3|max:10',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
@ -18,7 +19,7 @@ public function authorize(): bool
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class RemoveProductFromCartRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'productId' => ['required', 'exists:products,id'],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProductInCartRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'productId' => ['required', 'exists:products,id'],
'quantity' => ['required', 'min:0', 'max:10'],
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserAddressRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'firstName' => 'sometimes|string|min:3|max:50',
'lastName' => 'sometimes|string|min:3|max:50',
'street' => 'sometimes|string|min:3|max:100',
'city' => 'sometimes|string|min:3|max:20',
'state' => 'sometimes|string|min:3|max:40',
'pinCode' => 'sometimes|string|min:3|max:10',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UploadImageRequest extends FormRequest
@ -17,7 +18,7 @@ public function authorize(): bool
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class AddressCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use App\Data\UserAddressResponseDTO;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property UserAddressResponseDTO $resource
*/
class AddressResource extends JsonResource
{
public static $wrap = null;
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'firstName' => $this->resource->firstName,
'lastName' => $this->resource->lastName,
'street' => $this->resource->street,
'city' => $this->resource->city,
'state' => $this->resource->state,
'pinCode' => $this->resource->pinCode,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class CartItemCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use App\Data\CartItemDTO;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
/**
* @property CartItemDTO $resource
*/
class CartItemResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'title' => $this->resource->title,
'quantity' => $this->resource->quantity,
'price' => $this->resource->price,
'subtotal' => $this->resource->subtotal,
'image' => Storage::disk('public')->url($this->resource->image),
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use App\Data\CartDTO;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property CartDTO $resource
*/
class CartResource extends JsonResource
{
public static $wrap = null;
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'itemsCount' => $this->resource->itemsCount,
'totalPrice' => $this->resource->totalPrice,
'items' => CartItemResource::collection($this->resource->items),
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon;
/**
* @property-read User|null $user
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address query()
*
* @property-read Collection<int, User> $users
* @property-read int|null $users_count
* @property int $id
* @property string $first_name
* @property string $last_name
* @property string $street
* @property string $city
* @property string $state
* @property string $pin
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereCity($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereFirstName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereLastName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address wherePin($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereState($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereStreet($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Address whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Address extends Model
{
protected $fillable = ['first_name', 'last_name', 'street', 'city', 'state', 'pin'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\CartStatus;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property CartStatus $status
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Collection<int, Product> $products
* @property-read int|null $products_count
* @property-read User|null $user
*
* @method static Builder<static>|Cart active()
* @method static Builder<static>|Cart newModelQuery()
* @method static Builder<static>|Cart newQuery()
* @method static Builder<static>|Cart query()
* @method static Builder<static>|Cart whereCreatedAt($value)
* @method static Builder<static>|Cart whereId($value)
* @method static Builder<static>|Cart whereStatus($value)
* @method static Builder<static>|Cart whereUpdatedAt($value)
* @method static Builder<static>|Cart whereUserId($value)
* @method static Builder<static>|Cart withProducts()
*
* @mixin \Eloquent
*/
class Cart extends Model
{
protected $fillable = ['user_id', 'status'];
protected function casts()
{
return [
'status' => CartStatus::class,
];
}
public function products()
{
return $this->belongsToMany(Product::class)
->withPivot('price', 'quantity')
->withTimestamps();
}
public function user()
{
return $this->belongsTo(User::class);
}
#[Scope]
protected function active(Builder $query)
{
return $query->where('status', CartStatus::Active);
}
#[Scope]
protected function withProducts(Builder $query)
{
return $query->with(['products' => function ($product) {
$product->withPivot('quantity', 'price');
}]);
}
}

View File

@ -2,11 +2,52 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* @property int $id
* @property string $title
* @property string|null $slug
* @property string $description
* @property numeric $actual_price
* @property numeric $list_price
* @property int $product_category_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $is_active
* @property-read Collection<int, Cart> $carts
* @property-read int|null $carts_count
* @property-read ProductCategory|null $category
* @property-read Collection<int, User> $favoritedBy
* @property-read int|null $favorited_by_count
* @property-read Collection<int, ProductImage> $images
* @property-read int|null $images_count
*
* @method static Builder<static>|Product active()
* @method static Builder<static>|Product newModelQuery()
* @method static Builder<static>|Product newQuery()
* @method static Builder<static>|Product query()
* @method static Builder<static>|Product whereActualPrice($value)
* @method static Builder<static>|Product whereCreatedAt($value)
* @method static Builder<static>|Product whereDescription($value)
* @method static Builder<static>|Product whereId($value)
* @method static Builder<static>|Product whereIsActive($value)
* @method static Builder<static>|Product whereListPrice($value)
* @method static Builder<static>|Product whereProductCategoryId($value)
* @method static Builder<static>|Product whereSlug($value)
* @method static Builder<static>|Product whereTitle($value)
* @method static Builder<static>|Product whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Product extends Model
{
protected $fillable = [
@ -28,6 +69,22 @@ public function images(): HasMany
return $this->hasMany(ProductImage::class, 'product_id', 'id');
}
public function favoritedBy(): BelongsToMany
{
return $this->belongsToMany(User::class, 'favorite_products');
}
public function carts()
{
return $this->belongsToMany(Cart::class);
}
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('is_active', true);
}
protected static function booted(): void
{
static::saving(function ($product) {
@ -36,4 +93,11 @@ protected static function booted(): void
}
});
}
protected function casts()
{
return [
'is_active' => 'boolean',
];
}
}

View File

@ -3,7 +3,26 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $name
* @property string $slug
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory whereSlug($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductCategory whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class ProductCategory extends Model
{
//

View File

@ -4,7 +4,27 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $path
* @property int $product_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Product|null $product
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage wherePath($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage whereProductId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ProductImage whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class ProductImage extends Model
{
protected $fillable = [

View File

@ -4,14 +4,59 @@
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserRoles;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $mobile_number
* @property string $city
* @property UserRoles $role
* @property-read Collection<int, Cart> $carts
* @property-read int|null $carts_count
* @property-read Collection<int, Product> $favoriteProducts
* @property-read int|null $favorite_products_count
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
*
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereCity($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereEmailVerifiedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereMobileNumber($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRememberToken($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereRole($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|User whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/** @use HasFactory<UserFactory> */
use HasFactory;
use Notifiable;
/**
* The attributes that are mass assignable.
@ -50,4 +95,24 @@ protected function casts(): array
'role' => UserRoles::class,
];
}
public function favoriteProducts(): BelongsToMany
{
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();
}
public function carts()
{
return $this->hasMany(Cart::class);
}
public function addresses(): BelongsToMany
{
return $this->belongsToMany(Address::class);
}
}

View File

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

View File

@ -1,5 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
App\Providers\AppServiceProvider::class,
AppServiceProvider::class,
];

View File

@ -15,6 +15,7 @@
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.6",
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
@ -55,7 +56,10 @@
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan ide-helper:generate",
"@php artisan ide-helper:meta"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""

679
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View File

@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
@ -59,7 +60,7 @@
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@ -79,7 +80,7 @@
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

@ -1,5 +1,8 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
@ -76,9 +79,9 @@
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View File

@ -2,12 +2,13 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{

View File

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

View File

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

View File

@ -0,0 +1,31 @@
<?php
use App\Enums\CartStatus;
use App\Models\User;
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::create('carts', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class);
$table->enum('status', array_column(CartStatus::cases(), 'value'));
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('carts');
}
};

View File

@ -0,0 +1,35 @@
<?php
use App\Models\Cart;
use App\Models\Product;
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::create('cart_product', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Cart::class);
$table->foreignIdFor(Product::class);
$table->decimal('price', 10, 2);
$table->integer('quantity');
$table->timestamps();
$table->unique(['cart_id', 'product_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cart_product');
}
};

View File

@ -0,0 +1,33 @@
<?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::create('addresses', function (Blueprint $table) {
$table->id();
$table->string('first_name');
$table->string('last_name');
$table->string('street');
$table->string('city');
$table->string('state');
$table->string('pin');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('addresses');
}
};

View File

@ -0,0 +1,29 @@
<?php
use App\Models\Address;
use App\Models\User;
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::create('address_user', function (Blueprint $table) {
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Address::class)->constrained()->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('address_user');
}
};

View File

@ -1,10 +1,13 @@
<?php
use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\CartController;
use App\Http\Controllers\FavouriteProductController;
use App\Http\Controllers\ProductCategoryController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProductImagesController;
use App\Http\Controllers\RegisteredUserController;
use App\Http\Controllers\UserAddressController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
@ -15,6 +18,15 @@
Route::get('/user', [AuthenticatedUserController::class, 'show']);
Route::post('/logout', [AuthenticatedUserController::class, 'destroy']);
Route::post('/upload/images', action: [ProductImagesController::class, 'store']);
// Favorites
Route::post('/products/{product}/favorite', [FavouriteProductController::class, 'toggle']);
Route::apiSingleton('/cart', CartController::class)
->creatable()
->destroyable();
Route::apiResource('user.addresses', UserAddressController::class)->shallow();
});
Route::get('/categories', [ProductCategoryController::class, 'index']);
Route::apiResource('products', ProductController::class);

View File

@ -0,0 +1,30 @@
<?php
namespace {{ namespace }};
use App\Contracts\InputDataTransferObject;
use Illuminate\Foundation\Http\FormRequest;
final readonly class {{ class }} implements InputDataTransferObject
{
public function __construct(
// TODO: Define your properties here
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
// TODO: Map properties to array
];
}
public static function fromRequest(FormRequest $request): InputDataTransferObject
{
return new self(
// TODO: Map request data to properties
);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace {{ namespace }};
use App\Contracts\OutputDataTransferObject;
use Illuminate\Database\Eloquent\Model;
final readonly class {{ class }} implements OutputDataTransferObject
{
public function __construct(
// TODO: Define your properties here
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
// TODO: Map properties to array
];
}
public static function fromModel(Model $model): OutputDataTransferObject
{
return new self(
// TODO: Map model data to properties
);
}
}

View File

@ -1,5 +1,7 @@
<?php
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -11,7 +13,7 @@
|
*/
pest()->extend(Tests\TestCase::class)
pest()->extend(TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');

View File

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

View File

@ -7,7 +7,6 @@ export const routes: Routes = [
{
path: "",
component: Home,
canActivate: [authGuard],
},
{
path: "",
@ -20,4 +19,9 @@ export const routes: Routes = [
canActivate: [authGuard, roleGuard],
data: { roles: ["admin", "broker"] },
},
{
path: "checkout",
loadChildren: () =>
import("./features/checkout/checkout.routes").then((routes) => routes.checkoutRoutes),
},
];

View File

@ -1,6 +1,6 @@
import { CanActivateFn, Router } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "../../features/auth/services/auth-service";
import { AuthService } from "@core/services/auth-service";
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);

View File

@ -1,6 +1,6 @@
import { CanActivateFn } from "@angular/router";
import { inject } from "@angular/core";
import { AuthService } from "../../features/auth/services/auth-service";
import { AuthService } from "@core/services/auth-service";
export const roleGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);

View File

@ -3,7 +3,7 @@
class="bg-gray-50 wrapper py-4 flex gap-x-5 sm:gap-x-10 items-center shadow-lg shadow-gray-400/20"
>
<div class="">
<a class="px-3 py-1 bg-gray-800 text-white" href="/">eKart</a>
<a class="px-3 py-1 bg-blue-600 text-white" routerLink="/">eKart</a>
</div>
<div class="flex-1 grid grid-cols-[1fr_auto]">
@ -20,18 +20,24 @@
<div class="flex space-x-4">
<div class="flex text-gray-600">
<button
class="btn btn-ghost py-1 px-2 rounded-r-none!"
class="btn btn-ghost py-1 px-3 rounded-r-none!"
popovertarget="popover-1"
style="anchor-name: --anchor-1"
>
<lucide-angular [img]="UserIcon" class="w-5" />
</button>
<button class="btn btn-ghost py-1 px-2 rounded-l-none! border-l-0!">
<button
class="btn btn-ghost py-1 px-3 rounded-l-none! border-l-0! relative"
popovertarget="popover-2"
style="anchor-name: --anchor-2"
>
<lucide-angular [img]="CartIcon" class="w-5" />
<span class="absolute top-0 text-xs ml-1">{{ cartItemCount | async }}</span>
</button>
</div>
</div>
</nav>
<ul class="dropdown" id="popover-1" popover style="position-anchor: --anchor-1">
@if (authService.authState() === AuthState.Unauthenticated) {
<li><a class="block h-full w-full" routerLink="/login">Login</a></li>
@ -45,4 +51,12 @@
<li><a class="block h-full w-full" href="">Wishlist</a></li>
<li><a class="block h-full w-full" href="">Notifications</a></li>
</ul>
<app-cart
[cart]="(cartItems$ | async)!"
id="popover-2"
class="dropdown"
popover
style="position-anchor: --anchor-2"
/>
</header>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { Router, Routes } from "@angular/router";
import { Login } from "./components/login/login";
import { Register } from "./components/register/register";
import { inject } from "@angular/core";
import { AuthService } from "./services/auth-service";
import { AuthService } from "@core/services/auth-service";
export const AuthRoutes: Routes = [
{

View File

@ -1,12 +1,12 @@
import { Component, inject } from "@angular/core";
import { Router, RouterLink } from "@angular/router";
import { Router } from "@angular/router";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "../../services/auth-service";
import { Error } from "../../../../shared/components/error/error";
import { AuthService } from "@core/services/auth-service";
import { Error } from "@app/shared/components/error/error";
@Component({
selector: "app-login",
imports: [RouterLink, ReactiveFormsModule, Error],
imports: [ReactiveFormsModule, Error],
templateUrl: "./login.html",
styleUrl: "./login.css",
})

View File

@ -1,8 +1,8 @@
import { Component, inject, signal } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { AuthService } from "../../services/auth-service";
import { RegisterUserRequest } from "../../../../core/models/user.model";
import { Error } from "../../../../shared/components/error/error";
import { AuthService } from "@core/services/auth-service";
import { RegisterUserRequest } from "@core/models/user.model";
import { Error } from "@shared/components/error/error";
import { Router, RouterLink } from "@angular/router";
@Component({

View File

@ -0,0 +1,49 @@
<section class="my-10">
<div class="">
<app-go-back route="/" text="Home" />
<div class="grid grid-cols-3 gap-x-10">
<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
type="radio"
name="address"
value="{{address.id}}"
[formControl]="addressIdControl"
/>
<app-address-select
class="flex-1"
[address]="address"
(addressUpdated)="updateAddress($event)"
/>
</div>
}
<app-address-form class="ml-5" (submitAddress)="createNewAddress($event)" />
</div>
<div>
<app-order-summery />
<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 placeholder="Enter coupon here" type="text" class="input" />
<button class="btn btn-ghost px-4">Apply</button>
</div>
</fieldset>
@if (addressIdControl.invalid && addressIdControl.touched) {
<div class="text-red-500 text-sm p-4 mt-4 rounded-xl bg-red-50">
Please select an address
</div>
}
<button
class="btn btn-primary w-full mt-4"
(click)="proceedToPayment()"
[disabled]="addressIdControl.invalid && addressIdControl.touched"
>
Proceed to payment
</button>
</div>
</div>
</div>
</div>
</section>

View File

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

View File

@ -0,0 +1,76 @@
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { AddressForm } from "../components/address-form/address-form";
import { GoBack } from "@app/shared/components/go-back/go-back";
import { AddressSelect } from "../components/address-select/address-select";
import { OrderSummery } from "../components/order-summery/order-summery";
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 { of, switchMap } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
@Component({
selector: "app-address",
imports: [AddressSelect, AddressForm, GoBack, OrderSummery, ReactiveFormsModule],
templateUrl: "./address.html",
styleUrl: "./address.css",
})
export class Address implements OnInit {
addressService = inject(AddressService);
authService = inject(AuthService);
// I am subscribing to the observable instead of using toSignal(),
// i have to destroy the subscription manually.
destroyRef = inject(DestroyRef);
protected addresses = signal<AddressResponse[]>([]);
protected addressIdControl = new FormControl<number | null>(null, Validators.required);
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.addressService.createAddress(this.user!.id, addressData).subscribe({
next: (address) => this.addresses.update((addresses) => [...addresses, address]),
});
}
protected updateAddress(addressData: AddressResponse) {
console.log(addressData);
this.addressService.updateAddress(addressData.id, addressData).subscribe({
next: (address) =>
this.addresses.update((addresses) =>
addresses.map((a) => (a.id === address.id ? address : a)),
),
});
}
protected proceedToPayment() {
if (this.addressIdControl.invalid) {
this.addressIdControl.markAsTouched();
return;
}
}
}

View File

View File

@ -0,0 +1,6 @@
<section class="wrapper my-10">
<div class="wrapper grid place-content-center w-full">
<app-stepper [steps]="steps" [currentStep]="1" />
</div>
<router-outlet />
</section>

View File

@ -0,0 +1,16 @@
import { Routes } from "@angular/router";
import { Address } from "./address/address";
import { Checkout } from "./checkout";
export const checkoutRoutes: Routes = [
{
path: "",
component: Checkout,
children: [
{
path: "address/:cartId",
component: Address,
},
],
},
];

View File

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

View File

@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { Stepper, Steps } from "@app/shared/components/stepper/stepper";
@Component({
selector: "app-checkout",
imports: [RouterOutlet, Stepper],
templateUrl: "./checkout.html",
styleUrl: "./checkout.css",
})
export class Checkout {
steps: Steps[] = [
{ label: "Cart" },
{ label: "Address" },
{ label: "Payment" },
{ label: "Confirm" },
];
}

View File

@ -0,0 +1,59 @@
<details
class="card p-0!"
title="Click to add a new address"
[open]="isEditing()"
>
<summary class="p-6">
<label for="currentAddress" class="font-medium text-gray-600 ml-2"
>{{isEditing() ? 'Update address' : 'Add new address'}}</label
>
</summary>
<form
[formGroup]="addressForm"
(ngSubmit)="submitForm()"
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 type="text" formControlName="firstName" class="input" placeholder="Example: Jhon" />
<app-error fieldName="First name" [control]="addressForm.get('firstName')" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Last Name</legend>
<input type="text" class="input" formControlName="lastName" placeholder="Example: Doe" />
<app-error fieldName="Last name" [control]="addressForm.get('lastName')" />
</fieldset>
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Street Address</legend>
<input type="text" class="input" formControlName="street" placeholder="Your street address" />
<app-error fieldName="Street address" [control]="addressForm.get('street')" />
</fieldset>
<fieldset class="flex space-x-4 w-full">
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">City</legend>
<input type="text" class="input" formControlName="city" placeholder="Your city" />
<app-error fieldName="City" [control]="addressForm.get('city')" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">State</legend>
<input type="text" class="input" formControlName="state" placeholder="State Name" />
<app-error fieldName="State" [control]="addressForm.get('state')" />
</fieldset>
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">Pin Code</legend>
<input type="text" class="input" formControlName="pinCode" placeholder="7XX XX1" />
<app-error fieldName="Pin Code" [control]="addressForm.get('pinCode')" />
</fieldset>
</fieldset>
<div class="ml-auto flex space-x-4">
<button type="button" (click)="cancelEditing()" class="btn btn-ghost px-3 text-sm">
Cancel
</button>
<button class="btn btn-primary px-3 text-sm">
{{isEditing() ? 'Update this address' : 'Use this address'}}
</button>
</div>
</form>
</details>

View File

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

View File

@ -0,0 +1,64 @@
import { Component, EventEmitter, 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";
@Component({
selector: "app-address-form",
imports: [ReactiveFormsModule, Error],
templateUrl: "./address-form.html",
styleUrl: "./address-form.css",
})
export class AddressForm {
@Input() set initialData(address: AddressResponse) {
if (address) {
this.addressForm.patchValue(address);
this.address.set(address);
this.isEditing.set(true);
}
}
@Output() submitAddress: EventEmitter<AddressRequest> = new EventEmitter<AddressRequest>();
@Output() updateAddress: EventEmitter<AddressResponse> = new EventEmitter<AddressResponse>();
@Output() editingCanceled: EventEmitter<void> = new EventEmitter<void>();
protected isEditing = signal(false);
protected address = signal<AddressResponse | null>(null);
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}$")],
}),
});
submitForm() {
if (this.addressForm.invalid) {
this.addressForm.markAllAsTouched();
return;
}
const emittedData = this.addressForm.getRawValue() as AddressRequest;
this.addressForm.reset();
if (this.isEditing()) {
const mergedData = { ...this.address(), ...emittedData };
console.log(mergedData);
this.updateAddress.emit(mergedData as unknown as AddressResponse);
} else {
this.submitAddress.emit(emittedData);
}
}
cancelEditing() {
this.addressForm.reset();
this.editingCanceled.emit();
}
}

View File

@ -0,0 +1,19 @@
@if (!isEditing()) {
<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>
} @else{
<app-address-form
[initialData]="address"
(editingCanceled)="cancelEditing()"
(updateAddress)="updateAddress($event)"
/>
}

View File

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

View File

@ -0,0 +1,30 @@
import { Component, EventEmitter, 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: EventEmitter<AddressResponse> = new EventEmitter<AddressResponse>();
protected isEditing = signal(false);
editForm() {
this.isEditing.set(true);
}
cancelEditing() {
this.isEditing.set(false);
}
updateAddress(address: AddressResponse) {
this.isEditing.set(false);
this.addressUpdated.emit(address);
}
}

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