From a732424bd0671ca3d81cda51074b225c546f920c Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 21 Sep 2026 21:29:03 +0600 Subject: [PATCH 1/8] feat(products): add products table, permission migration, model, and controllers with forever caching --- .../Controllers/Admin/ProductController.php | 115 ++++++++++++++++++ app/Http/Controllers/ProductController.php | 28 +++++ .../Requests/Product/StoreProductRequest.php | 34 ++++++ .../Requests/Product/UpdateProductRequest.php | 34 ++++++ app/Models/Product.php | 47 +++++++ app/Observers/ProductObserver.php | 19 +++ app/Providers/AppServiceProvider.php | 3 + ...026_09_21_210000_create_products_table.php | 36 ++++++ ..._210100_add_manage_products_permission.php | 39 ++++++ database/seeders/RolePermissionSeeder.php | 5 + routes/admin.php | 11 ++ routes/web.php | 3 +- 12 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/ProductController.php create mode 100644 app/Http/Controllers/ProductController.php create mode 100644 app/Http/Requests/Product/StoreProductRequest.php create mode 100644 app/Http/Requests/Product/UpdateProductRequest.php create mode 100644 app/Models/Product.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 database/migrations/2026_09_21_210000_create_products_table.php create mode 100644 database/migrations/2026_09_21_210100_add_manage_products_permission.php diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php new file mode 100644 index 00000000..9b6eaaff --- /dev/null +++ b/app/Http/Controllers/Admin/ProductController.php @@ -0,0 +1,115 @@ +orderByDesc('id') + ->get(); + + return Inertia::render('admin/Product', [ + 'products' => $products, + ]); + } + + /** + * Show the form for creating a new product. + */ + public function create(): Response + { + return Inertia::render('admin/ProductCreateOrEdit'); + } + + /** + * Store a newly created product in storage. + */ + public function store(StoreProductRequest $request): RedirectResponse + { + $data = $request->validated(); + + if ($request->hasFile('image')) { + $path = $request->file('image')->store('products'); + $data['image_path'] = $path; + } elseif (! empty($data['image_url'])) { + $data['image_path'] = $data['image_url']; + } + + unset($data['image'], $data['image_url']); + + Product::create($data); + + return redirect() + ->route('admin.products.index') + ->with('success', 'Product created successfully.'); + } + + /** + * Show the form for editing the specified product. + */ + public function edit(Product $product): Response + { + return Inertia::render('admin/ProductCreateOrEdit', [ + 'product' => $product, + ]); + } + + /** + * Update the specified product in storage. + */ + public function update(UpdateProductRequest $request, Product $product): RedirectResponse + { + $data = $request->validated(); + + if ($request->hasFile('image')) { + if ($product->image_path && ! str($product->image_path)->startsWith(['http://', 'https://'])) { + Storage::delete($product->image_path); + } + + $path = $request->file('image')->store('products'); + $data['image_path'] = $path; + } elseif (isset($data['image_url'])) { + if (! empty($data['image_url'])) { + $data['image_path'] = $data['image_url']; + } + } + + unset($data['image'], $data['image_url']); + + $product->update($data); + + return redirect() + ->route('admin.products.index') + ->with('success', 'Product updated successfully.'); + } + + /** + * Remove the specified product from storage. + */ + public function destroy(Product $product): RedirectResponse + { + if ($product->image_path && ! str($product->image_path)->startsWith(['http://', 'https://'])) { + Storage::delete($product->image_path); + } + + $product->delete(); + + return redirect() + ->back() + ->with('success', 'Product deleted successfully.'); + } +} diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php new file mode 100644 index 00000000..ea2328c3 --- /dev/null +++ b/app/Http/Controllers/ProductController.php @@ -0,0 +1,28 @@ +orderBy('order') + ->orderBy('id') + ->get(); + }); + + return Inertia::render('Projects', [ + 'products' => $products, + ]); + } +} diff --git a/app/Http/Requests/Product/StoreProductRequest.php b/app/Http/Requests/Product/StoreProductRequest.php new file mode 100644 index 00000000..463ae5ba --- /dev/null +++ b/app/Http/Requests/Product/StoreProductRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'image' => [ + 'nullable', + 'image', + 'mimes:jpg,jpeg,png,webp', + 'max:5120', + ], + 'image_url' => ['nullable', 'string', 'max:1000'], + 'users' => ['nullable', 'string', 'max:100'], + 'link' => ['required', 'string', 'max:255'], + 'open_type' => ['required', 'string', 'in:_blank,_self'], + 'button_text' => ['nullable', 'string', 'max:100'], + 'order' => ['nullable', 'integer', 'min:0'], + 'is_active' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Product/UpdateProductRequest.php b/app/Http/Requests/Product/UpdateProductRequest.php new file mode 100644 index 00000000..7666596e --- /dev/null +++ b/app/Http/Requests/Product/UpdateProductRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'image' => [ + 'nullable', + 'image', + 'mimes:jpg,jpeg,png,webp', + 'max:5120', + ], + 'image_url' => ['nullable', 'string', 'max:1000'], + 'users' => ['nullable', 'string', 'max:100'], + 'link' => ['required', 'string', 'max:255'], + 'open_type' => ['required', 'string', 'in:_blank,_self'], + 'button_text' => ['nullable', 'string', 'max:100'], + 'order' => ['nullable', 'integer', 'min:0'], + 'is_active' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..c4645d7e --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,47 @@ + 'boolean', + 'order' => 'integer', + ]; + } + + public function getImageUrlAttribute(): ?string + { + if (! $this->image_path) { + return null; + } + + return str($this->image_path)->startsWith(['http://', 'https://']) + ? $this->image_path + : Storage::url($this->image_path); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..0ff7e78b --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,19 @@ +id(); + $table->string('name'); + $table->text('description'); + $table->string('image_path')->nullable(); + $table->string('users')->nullable(); + $table->string('link'); + $table->string('open_type')->default('_blank'); + $table->string('button_text')->nullable(); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_09_21_210100_add_manage_products_permission.php b/database/migrations/2026_09_21_210100_add_manage_products_permission.php new file mode 100644 index 00000000..3e0d3c7d --- /dev/null +++ b/database/migrations/2026_09_21_210100_add_manage_products_permission.php @@ -0,0 +1,39 @@ +forgetCachedPermissions(); + + $permission = Permission::findOrCreate('manage products', 'web'); + + $admin = Role::where('name', 'admin')->where('guard_name', 'web')->first(); + if ($admin) { + $admin->givePermissionTo($permission); + } + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + + $permission = Permission::where('name', 'manage products')->where('guard_name', 'web')->first(); + $permission?->delete(); + + app()[PermissionRegistrar::class]->forgetCachedPermissions(); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 0329cf44..9f0019bd 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -100,6 +100,11 @@ public function run(): void */ Permission::findOrCreate('manage peers'); + /* + * Product management + */ + Permission::findOrCreate('manage products'); + $admin->syncPermissions(Permission::all()); // Administrators have unrestricted access to all features. diff --git a/routes/admin.php b/routes/admin.php index 6b44d211..371fd10d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Admin\NodeController as AdminNodeController; use App\Http\Controllers\Admin\NoticeController as AdminNoticeController; use App\Http\Controllers\Admin\PeerSettingsController; +use App\Http\Controllers\Admin\ProductController as AdminProductController; use App\Http\Controllers\Admin\ResourceController as AdminResourceController; use App\Http\Controllers\Admin\SubjectController as AdminSubjectController; use App\Http\Controllers\Admin\SupportTicketController as AdminSupportTicketController; @@ -153,3 +154,13 @@ Route::get('/peers/settings', [PeerSettingsController::class, 'edit'])->name('peers.settings.edit'); Route::post('/peers/settings', [PeerSettingsController::class, 'update'])->name('peers.settings.update'); }); + +// Products +Route::middleware('permission:manage products')->group(function () { + Route::get('/products', [AdminProductController::class, 'index'])->name('products.index'); + Route::get('/products/create', [AdminProductController::class, 'create'])->name('products.create'); + Route::post('/products', [AdminProductController::class, 'store'])->name('products.store'); + Route::get('/products/edit/{product}', [AdminProductController::class, 'edit'])->name('products.edit'); + Route::match(['patch', 'post'], '/products/edit/{product}/patch', [AdminProductController::class, 'update'])->name('products.update'); + Route::delete('/products/{product}', [AdminProductController::class, 'destroy'])->name('products.destroy'); +}); diff --git a/routes/web.php b/routes/web.php index 4981bced..cd10e97e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,7 @@ use App\Http\Controllers\NodeController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\PeerController; +use App\Http\Controllers\ProductController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ResourceController; use App\Http\Controllers\ShortUrlController; @@ -98,7 +99,7 @@ Route::inertia('/join', 'platform/JoinTeam'); Route::inertia('/guide', 'ContributorGuide'); Route::inertia('/ai', 'ai/Index'); - Route::inertia('/projects', 'Projects'); + Route::get('/projects', [ProductController::class, 'index'])->name('projects.index'); Route::get('/about-us', [AboutUsController::class, 'index']); From de00c961dbc7d1d8280f88d16d3e8b806e63d4a6 Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 21 Sep 2026 21:29:06 +0600 Subject: [PATCH 2/8] feat(products): create dynamic product card and admin product management interface --- resources/js/components/ProductCard.vue | 110 ++++++ resources/js/components/admin/ProductRow.vue | 140 +++++++ .../js/components/navigation/Navigation.tsx | 1 + resources/js/layouts/AdminLayout.vue | 6 + resources/js/pages/Projects.vue | 129 ++---- resources/js/pages/admin/Product.vue | 68 ++++ .../js/pages/admin/ProductCreateOrEdit.vue | 372 ++++++++++++++++++ 7 files changed, 721 insertions(+), 105 deletions(-) create mode 100644 resources/js/components/ProductCard.vue create mode 100644 resources/js/components/admin/ProductRow.vue create mode 100644 resources/js/pages/admin/Product.vue create mode 100644 resources/js/pages/admin/ProductCreateOrEdit.vue diff --git a/resources/js/components/ProductCard.vue b/resources/js/components/ProductCard.vue new file mode 100644 index 00000000..30cbaf21 --- /dev/null +++ b/resources/js/components/ProductCard.vue @@ -0,0 +1,110 @@ + + + diff --git a/resources/js/components/admin/ProductRow.vue b/resources/js/components/admin/ProductRow.vue new file mode 100644 index 00000000..5526b457 --- /dev/null +++ b/resources/js/components/admin/ProductRow.vue @@ -0,0 +1,140 @@ + + + diff --git a/resources/js/components/navigation/Navigation.tsx b/resources/js/components/navigation/Navigation.tsx index 9ca53043..441f6f01 100644 --- a/resources/js/components/navigation/Navigation.tsx +++ b/resources/js/components/navigation/Navigation.tsx @@ -102,6 +102,7 @@ function getCollapsedAdminLabel(name: string): string { const map: Record = { 'Manage Contents': 'Contents', 'Manage Blogs': 'Blogs', + 'Manage Products': 'Products', 'Manage Forum': 'Forum', 'Support Tickets': 'Support', 'Site Notice': 'Notice', diff --git a/resources/js/layouts/AdminLayout.vue b/resources/js/layouts/AdminLayout.vue index 3eddbcde..996596a7 100644 --- a/resources/js/layouts/AdminLayout.vue +++ b/resources/js/layouts/AdminLayout.vue @@ -45,6 +45,12 @@ const allNavigation: AdminNavItem[] = [ { name: 'Dashboard', to: '/admin', icon: 'dashboard' }, { name: 'Manage Contents', to: '/admin/subjects', icon: 'menu_book' }, { name: 'Manage Blogs', to: '/admin/blogs', icon: 'book' }, + { + name: 'Manage Products', + to: '/admin/products', + icon: 'inventory_2', + permission: 'manage products', + }, { name: 'Manage Forum', to: '/admin/forums', diff --git a/resources/js/pages/Projects.vue b/resources/js/pages/Projects.vue index b6520eea..a94369c8 100644 --- a/resources/js/pages/Projects.vue +++ b/resources/js/pages/Projects.vue @@ -1,6 +1,11 @@ diff --git a/resources/js/pages/admin/Product.vue b/resources/js/pages/admin/Product.vue new file mode 100644 index 00000000..7dac5085 --- /dev/null +++ b/resources/js/pages/admin/Product.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/pages/admin/ProductCreateOrEdit.vue b/resources/js/pages/admin/ProductCreateOrEdit.vue new file mode 100644 index 00000000..d571b4ac --- /dev/null +++ b/resources/js/pages/admin/ProductCreateOrEdit.vue @@ -0,0 +1,372 @@ + + + From 951a343467d95cfc3461abc0d120f1dec3cee797 Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 21 Sep 2026 21:29:08 +0600 Subject: [PATCH 3/8] test(products): add feature tests for public showcase, caching, and admin management --- tests/Feature/ProductTest.php | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/Feature/ProductTest.php diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php new file mode 100644 index 00000000..c078279a --- /dev/null +++ b/tests/Feature/ProductTest.php @@ -0,0 +1,156 @@ + 'Demo Platform', + 'description' => 'Demo description', + 'link' => 'https://example.com', + 'open_type' => '_blank', + 'users' => '500+ Users', + 'is_active' => true, + 'order' => 1, + ]); + + Product::create([ + 'name' => 'Inactive Platform', + 'description' => 'Inactive description', + 'link' => 'https://example.com/inactive', + 'open_type' => '_self', + 'is_active' => false, + 'order' => 2, + ]); + + $response = $this->get('/projects'); + + $response->assertStatus(200); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('Projects') + ->has('products', 1) + ->where('products.0.name', 'Demo Platform') + ); +}); + +test('projects page products are cached forever and cleared on create, update, and delete', function () { + Cache::forget('projects_page_products'); + + expect(Cache::has('projects_page_products'))->toBeFalse(); + + $this->get('/projects')->assertStatus(200); + + expect(Cache::has('projects_page_products'))->toBeTrue(); + + // Invalidate on create + $product = Product::create([ + 'name' => 'New Product', + 'description' => 'New description', + 'link' => 'https://newproduct.com', + 'open_type' => '_blank', + 'is_active' => true, + ]); + + expect(Cache::has('projects_page_products'))->toBeFalse(); + + // Re-cache + $this->get('/projects')->assertStatus(200); + expect(Cache::has('projects_page_products'))->toBeTrue(); + + // Invalidate on update + $product->update(['name' => 'Renamed Product']); + expect(Cache::has('projects_page_products'))->toBeFalse(); + + // Re-cache + $this->get('/projects')->assertStatus(200); + expect(Cache::has('projects_page_products'))->toBeTrue(); + + // Invalidate on delete + $product->delete(); + expect(Cache::has('projects_page_products'))->toBeFalse(); +}); + +test('unauthorized users cannot access admin products management', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/admin/products'); + $response->assertStatus(302); + $response->assertSessionHas('error', 'You do not have permission to perform this action.'); + + $userWithAdmin = adminUserWithPermissions(['view admin']); + $response = $this->actingAs($userWithAdmin)->get('/admin/products'); + $response->assertStatus(302); + $response->assertSessionHas('error', 'You do not have permission to perform this action.'); +}); + +test('users with manage products permission can manage products in admin panel', function () { + Storage::fake(); + + $adminUser = adminUserWithPermissions(['view admin', 'manage products']); + + // List products + $response = $this->actingAs($adminUser)->get('/admin/products'); + $response->assertStatus(200); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('admin/Product') + ->has('products') + ); + + // Show create form + $this->actingAs($adminUser)->get('/admin/products/create')->assertStatus(200); + + // Store product + $file = UploadedFile::fake()->image('banner.png', 800, 450); + + $storeResponse = $this->actingAs($adminUser)->post('/admin/products', [ + 'name' => 'Test Product', + 'description' => 'A great new platform.', + 'image' => $file, + 'users' => '1000+ Users', + 'link' => 'https://testplatform.com', + 'open_type' => '_blank', + 'button_text' => 'Launch Test', + 'order' => 1, + 'is_active' => true, + ]); + + $storeResponse->assertRedirect(route('admin.products.index')); + + $product = Product::where('name', 'Test Product')->first(); + expect($product)->not->toBeNull() + ->and($product->users)->toBe('1000+ Users') + ->and($product->open_type)->toBe('_blank') + ->and($product->button_text)->toBe('Launch Test'); + + Storage::assertExists($product->image_path); + + // Show edit form + $this->actingAs($adminUser)->get("/admin/products/edit/{$product->id}")->assertStatus(200); + + // Update product + $updateResponse = $this->actingAs($adminUser)->post("/admin/products/edit/{$product->id}/patch", [ + 'name' => 'Updated Test Product', + 'description' => 'Updated description.', + 'users' => '2000+ Users', + 'link' => 'https://updated.com', + 'open_type' => '_self', + 'button_text' => 'Go to Updated', + 'order' => 5, + 'is_active' => true, + ]); + + $updateResponse->assertRedirect(route('admin.products.index')); + $product->refresh(); + expect($product->name)->toBe('Updated Test Product') + ->and($product->users)->toBe('2000+ Users') + ->and($product->open_type)->toBe('_self'); + + // Delete product + $deleteResponse = $this->actingAs($adminUser)->delete("/admin/products/{$product->id}"); + $deleteResponse->assertRedirect(); + expect(Product::find($product->id))->toBeNull(); +}); From a89bdb0afe971103401f7957dd88285ffd886842 Mon Sep 17 00:00:00 2001 From: Tajim Date: Mon, 21 Sep 2026 21:38:26 +0600 Subject: [PATCH 4/8] feat(navigation): add Ecosystem to navigation rail and refresh showcase branding --- resources/js/lib/navigation.ts | 8 ++++++++ resources/js/pages/Projects.vue | 28 +++++++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/resources/js/lib/navigation.ts b/resources/js/lib/navigation.ts index 760943ed..06e57819 100644 --- a/resources/js/lib/navigation.ts +++ b/resources/js/lib/navigation.ts @@ -65,6 +65,14 @@ export const primaryNavItems: NavItem[] = [ match: (url) => url.startsWith('/ai'), showInBottom: false, }, + { + label: 'Ecosystem', + labelBn: 'ইকোসিস্টেম', + href: '/projects', + icon: 'hub', + match: (url) => url.startsWith('/projects'), + showInBottom: false, + }, ]; export const overflowNavItems: NavItem[] = [ diff --git a/resources/js/pages/Projects.vue b/resources/js/pages/Projects.vue index a94369c8..f0abdf65 100644 --- a/resources/js/pages/Projects.vue +++ b/resources/js/pages/Projects.vue @@ -10,35 +10,45 @@ defineProps<{