diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 9efe7dc..7353c22 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ yarn-error.log testem.log /typings __screenshots__/ +*.cache +.php-cs-fixer.dist.php # System files .DS_Store diff --git a/backend/app/Actions/UploadImageAction.php b/backend/app/Actions/UploadImageAction.php index cd11381..1c0e21b 100644 --- a/backend/app/Actions/UploadImageAction.php +++ b/backend/app/Actions/UploadImageAction.php @@ -8,7 +8,7 @@ { public function execute(UploadImageDTO $uploadImageDTO): void { - $path = $uploadImageDTO->image->store('public/images'); + $path = $uploadImageDTO->image->store('images', 'public'); $uploadImageDTO->product->images()->create([ 'path' => $path, ]); diff --git a/backend/app/Data/ProductDTO.php b/backend/app/Data/ProductDTO.php index ea34b90..4190d09 100644 --- a/backend/app/Data/ProductDTO.php +++ b/backend/app/Data/ProductDTO.php @@ -14,11 +14,15 @@ public function __construct( public int $id, public string $title, + public string $slug, public string $description, public int $actualPrice, public int $listPrice, public ProductCategoryDTO $category, public array $productImages, + public ?string $updatedAt = null, + public ?string $createdAt = null, + public ?bool $isFavorite = null ) {} /** @@ -29,12 +33,16 @@ public function toArray(): array return [ 'id' => $this->id, 'title' => $this->title, + 'slug' => $this->slug, 'description' => $this->description, 'actualPrice' => $this->actualPrice, 'listPrice' => $this->listPrice, 'category' => $this->category->toArray(), 'productImage' => array_map(fn (ProductImageDTO $productImage) => $productImage->toArray(), $this->productImages), + 'updatedAt' => $this->updatedAt, + 'createdAt' => $this->createdAt, + 'isFavorite' => $this->isFavorite, ]; } @@ -43,11 +51,16 @@ public static function fromModel(Product $product): self return new self( id: $product->id, title: $product->title, + slug: $product->slug, description: $product->description, actualPrice: $product->actual_price, listPrice: $product->list_price, category: ProductCategoryDTO::fromModel($product->category), 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, ); } } diff --git a/backend/app/Http/Controllers/FavouriteProductController.php b/backend/app/Http/Controllers/FavouriteProductController.php new file mode 100644 index 0000000..c7a99ac --- /dev/null +++ b/backend/app/Http/Controllers/FavouriteProductController.php @@ -0,0 +1,36 @@ +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, + ]); + } +} diff --git a/backend/app/Http/Controllers/ProductController.php b/backend/app/Http/Controllers/ProductController.php index 9081473..c003c10 100644 --- a/backend/app/Http/Controllers/ProductController.php +++ b/backend/app/Http/Controllers/ProductController.php @@ -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); } @@ -23,7 +25,12 @@ public function store(CreateProductRequest $request) return Product::create($request->validated()); } - public function show(Product $product) {} + public function show(string $slug) + { + $product = Product::where('slug', $slug)->with(['category:id,name,slug', 'images:id,path,product_id'])->firstOrFail(); + + return new ProductResource(ProductDTO::fromModel($product)); + } public function update(Request $request, Product $product) {} diff --git a/backend/app/Http/Controllers/ProductImagesController.php b/backend/app/Http/Controllers/ProductImagesController.php index 0ecd954..f906a45 100644 --- a/backend/app/Http/Controllers/ProductImagesController.php +++ b/backend/app/Http/Controllers/ProductImagesController.php @@ -16,11 +16,6 @@ public function store(UploadImageRequest $request, UploadImageAction $action) return response()->json(['message' => 'Image uploaded successfully']); } - public function show(ProductImages $productImages) - { - return $productImages; - } - public function destroy(ProductImages $productImages) { $productImages->delete(); diff --git a/backend/app/Http/Requests/UploadImageRequest.php b/backend/app/Http/Requests/UploadImageRequest.php index c5b523a..c214fa9 100644 --- a/backend/app/Http/Requests/UploadImageRequest.php +++ b/backend/app/Http/Requests/UploadImageRequest.php @@ -22,7 +22,7 @@ public function authorize(): bool public function rules(): array { return [ - 'image' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:2048', + 'image' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:10240', 'product_id' => 'required|exists:products,id', ]; } diff --git a/backend/app/Http/Resources/FavouriteProductResource.php b/backend/app/Http/Resources/FavouriteProductResource.php new file mode 100644 index 0000000..4ad6a80 --- /dev/null +++ b/backend/app/Http/Resources/FavouriteProductResource.php @@ -0,0 +1,23 @@ + $this->id, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + + 'user_id' => $this->user_id, + 'product_id' => $this->product_id, + ]; + } +} diff --git a/backend/app/Http/Resources/ProductResource.php b/backend/app/Http/Resources/ProductResource.php index ea6b9f8..9749042 100644 --- a/backend/app/Http/Resources/ProductResource.php +++ b/backend/app/Http/Resources/ProductResource.php @@ -3,8 +3,10 @@ namespace App\Http\Resources; use App\Data\ProductDTO; +use App\Data\ProductImageDTO; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Facades\Storage; /** * @property ProductDTO $resource @@ -13,6 +15,22 @@ class ProductResource extends JsonResource { public function toArray(Request $request): array { - return $this->resource->toArray(); + return [ + 'id' => $this->resource->id, + 'title' => $this->resource->title, + 'slug' => $this->resource->slug, + 'description' => $this->resource->description, + 'actualPrice' => $this->resource->actualPrice, + 'listPrice' => $this->resource->listPrice, + 'category' => [ + 'name' => $this->resource->category->name, + 'slug' => $this->resource->category->slug, + ], + 'productImages' => array_map(function (ProductImageDTO $productImage) { + return Storage::disk('public')->url($productImage->path); + }, $this->resource->productImages), + 'updatedAt' => $this->resource->updatedAt, + 'isFavorite' => $this->resource->isFavorite, + ]; } } diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index c0725a3..ef200b6 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -2,14 +2,19 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; class Product extends Model { protected $fillable = [ 'title', + 'slug', 'actual_price', 'list_price', 'description', @@ -25,4 +30,31 @@ public function images(): HasMany { return $this->hasMany(ProductImage::class, 'product_id', 'id'); } + + public function favoritedBy(): BelongsToMany + { + return $this->belongsToMany(User::class, 'favorite_products'); + } + + #[Scope] + protected function active(Builder $query): Builder + { + return $query->where('is_active', true); + } + + protected static function booted(): void + { + static::saving(function ($product) { + if (empty($product->slug) || $product->isDirty('title')) { + $product->slug = Str::slug($product->title); + } + }); + } + + protected function casts() + { + return [ + 'is_active' => 'boolean', + ]; + } } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index eb74b44..a0ab8b4 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -11,7 +11,9 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory; + + use Notifiable; /** * The attributes that are mass assignable. @@ -50,4 +52,14 @@ protected function casts(): array 'role' => UserRoles::class, ]; } + + public function favoriteProducts() + { + return $this->belongsToMany(Product::class, 'favorite_products', 'user_id', 'product_id'); + } + + public function hasFavorited(Product $product): bool + { + return $this->favoriteProducts()->where('product_id', $product->id)->exists(); + } } diff --git a/backend/app/Queries/GetProductsQuery.php b/backend/app/Queries/GetProductsQuery.php new file mode 100644 index 0000000..4d413c5 --- /dev/null +++ b/backend/app/Queries/GetProductsQuery.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/backend/database/migrations/2026_02_26_073626_create_products_table.php b/backend/database/migrations/2026_02_26_073626_create_products_table.php index 6dc7a2f..4692626 100644 --- a/backend/database/migrations/2026_02_26_073626_create_products_table.php +++ b/backend/database/migrations/2026_02_26_073626_create_products_table.php @@ -1,6 +1,6 @@ -string('slug')->unique()->after('title')->nullable(); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('slug'); + }); + } +}; diff --git a/backend/database/migrations/2026_03_02_133303_create_favourite_products_table.php b/backend/database/migrations/2026_03_02_133303_create_favourite_products_table.php new file mode 100644 index 0000000..0975051 --- /dev/null +++ b/backend/database/migrations/2026_03_02_133303_create_favourite_products_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/backend/database/migrations/2026_03_05_063756_add-active-in-products.php b/backend/database/migrations/2026_03_05_063756_add-active-in-products.php new file mode 100644 index 0000000..d315a3b --- /dev/null +++ b/backend/database/migrations/2026_03_05_063756_add-active-in-products.php @@ -0,0 +1,29 @@ +boolean('is_active')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 5d8329d..311580f 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,7 @@