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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ product.name }}
+
+
+
+ {{ product.users }}
+
+
+
+
+
+ {{ product.description }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ product.name }}
+
+
+
+
+ {{ product.users }}
+
+
+
+
+
+ {{
+ product.open_type === '_blank'
+ ? 'New Tab (_blank)'
+ : 'Same Tab (_self)'
+ }}
+
+
+
+ Order: {{ product.order }}
+
+
+
+
+ {{ product.description }}
+
+
+
+
{{
+ product.link
+ }}
+
+
+
+
+
+
+
+
+
+
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 @@
@@ -38,110 +43,24 @@ import { ExternalLink, ArrowRight, Users } from 'lucide-vue-next';
-
-
-
-
-
-
-
-
-
-
- HSCStack
-
-
-
- 640+ Users
-
-
-
-
- HSC ও SSC শিক্ষার্থীদের পড়াশোনা গোছানোর জন্য সম্পূর্ণ
- ফ্রি ও পরিচ্ছন্ন স্টাডি রিপোজিটরি। বিষয় ও অধ্যায়ভিত্তিক
- হ্যান্ডনোট, লেকচার ক্লাস ও রিসোর্স।
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ResultStack
-
-
-
- 6600+ Users
-
-
-
-
- বাংলাদেশের সকল শিক্ষা বোর্ডের মেধা তালিকা, সেরা
- প্রতিষ্ঠান ও বিস্তারিত GPA পরিসংখ্যান বিশ্লেষণ করুন একটি
- সমন্বিত প্ল্যাটফর্মে।
-
-
+
-
-
+
+
+ No products are currently available. Check back soon!
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ Manage Products
+
+
+
+ {{ products.length }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.product ? 'Edit' : 'Create' }} Product
+
+
+ Configure showcase products displayed on the /projects
+ page.
+
+
+
+
+
+
+
+
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<{
- Our Products & Open Source Projects
+ The HSCStack Ecosystem & Connected Apps
+
+
+ HSCStack Suite
+
+
- Our
- Products
+ The
+ Ecosystem
- Explore platforms and tools built by HSCStack
+ Connected platforms and specialized tools designed for your complete
+ HSC & SSC study journey.
@@ -59,7 +69,7 @@ defineProps<{
class="rounded-2xl border border-slate-200 bg-white p-12 text-center shadow-xs dark:border-gray-800 dark:bg-gray-900"
>
- No products are currently available. Check back soon!
+ No ecosystem products are currently available. Check back soon!
From 6ceb21afa79389c1e21b3a0a2a8dcaa34a8f3b22 Mon Sep 17 00:00:00 2001
From: Tajim
Date: Mon, 21 Sep 2026 21:39:21 +0600
Subject: [PATCH 5/8] Revert "feat(navigation): add Ecosystem to navigation
rail and refresh showcase branding"
This reverts commit a89bdb0afe971103401f7957dd88285ffd886842.
---
resources/js/lib/navigation.ts | 8 --------
resources/js/pages/Projects.vue | 28 +++++++++-------------------
2 files changed, 9 insertions(+), 27 deletions(-)
diff --git a/resources/js/lib/navigation.ts b/resources/js/lib/navigation.ts
index 06e57819..760943ed 100644
--- a/resources/js/lib/navigation.ts
+++ b/resources/js/lib/navigation.ts
@@ -65,14 +65,6 @@ 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 f0abdf65..a94369c8 100644
--- a/resources/js/pages/Projects.vue
+++ b/resources/js/pages/Projects.vue
@@ -10,45 +10,35 @@ defineProps<{
- The HSCStack Ecosystem & Connected Apps
+ Our Products & Open Source Projects
-
-
- HSCStack Suite
-
-
- The
- Ecosystem
+ Our
+ Products
- Connected platforms and specialized tools designed for your complete
- HSC & SSC study journey.
+ Explore platforms and tools built by HSCStack
@@ -69,7 +59,7 @@ defineProps<{
class="rounded-2xl border border-slate-200 bg-white p-12 text-center shadow-xs dark:border-gray-800 dark:bg-gray-900"
>
- No ecosystem products are currently available. Check back soon!
+ No products are currently available. Check back soon!
From f97da1e9709a14edf62eb3ee9a6ddf5a9c78f363 Mon Sep 17 00:00:00 2001
From: Tajim
Date: Mon, 21 Sep 2026 21:42:35 +0600
Subject: [PATCH 6/8] refactor(products): update public route to /products and
add legacy redirect
---
app/Console/Commands/GenerateSitemap.php | 2 +-
app/Http/Controllers/ProductController.php | 4 +--
app/Observers/ProductObserver.php | 4 +--
resources/js/components/Footer.vue | 2 +-
.../js/components/navigation/Navigation.tsx | 16 +++++-----
.../js/pages/{Projects.vue => Products.vue} | 0
.../js/pages/admin/ProductCreateOrEdit.vue | 4 +--
routes/web.php | 3 +-
tests/Feature/ProductTest.php | 30 +++++++++----------
tests/Feature/PublicPagesTest.php | 10 +++++--
10 files changed, 41 insertions(+), 34 deletions(-)
rename resources/js/pages/{Projects.vue => Products.vue} (100%)
diff --git a/app/Console/Commands/GenerateSitemap.php b/app/Console/Commands/GenerateSitemap.php
index 7e7d42dc..2e502f29 100644
--- a/app/Console/Commands/GenerateSitemap.php
+++ b/app/Console/Commands/GenerateSitemap.php
@@ -28,7 +28,7 @@ public function handle(): int
'/blogs' => ['priority' => 0.8, 'freq' => 'daily'],
'/forum' => ['priority' => 0.8, 'freq' => 'daily'],
'/about-us' => ['priority' => 0.7, 'freq' => 'monthly'],
- '/projects' => ['priority' => 0.7, 'freq' => 'monthly'],
+ '/products' => ['priority' => 0.7, 'freq' => 'monthly'],
'/guide' => ['priority' => 0.7, 'freq' => 'monthly'],
'/ai' => ['priority' => 0.7, 'freq' => 'monthly'],
'/donate' => ['priority' => 0.6, 'freq' => 'monthly'],
diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php
index ea2328c3..9e67438d 100644
--- a/app/Http/Controllers/ProductController.php
+++ b/app/Http/Controllers/ProductController.php
@@ -14,14 +14,14 @@ class ProductController extends Controller
*/
public function index(): Response
{
- $products = Cache::rememberForever('projects_page_products', function () {
+ $products = Cache::rememberForever('products_page_products', function () {
return Product::where('is_active', true)
->orderBy('order')
->orderBy('id')
->get();
});
- return Inertia::render('Projects', [
+ return Inertia::render('Products', [
'products' => $products,
]);
}
diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php
index 0ff7e78b..4a77cade 100644
--- a/app/Observers/ProductObserver.php
+++ b/app/Observers/ProductObserver.php
@@ -9,11 +9,11 @@ class ProductObserver
{
public function saved(Product $product): void
{
- Cache::forget('projects_page_products');
+ Cache::forget('products_page_products');
}
public function deleted(Product $product): void
{
- Cache::forget('projects_page_products');
+ Cache::forget('products_page_products');
}
}
diff --git a/resources/js/components/Footer.vue b/resources/js/components/Footer.vue
index b1d3b6bd..ec4f6c6b 100644
--- a/resources/js/components/Footer.vue
+++ b/resources/js/components/Footer.vue
@@ -130,7 +130,7 @@ import AppLogo from './AppLogo.vue';
More From Us
- {isActive('/projects') && (
+ {isActive('/products') && (
)}
@@ -1232,11 +1232,11 @@ export const SiteDrawer = defineComponent({
)}
More From Us
- {isActive('/projects') && (
+ {isActive('/products') && (
)}
diff --git a/resources/js/pages/Projects.vue b/resources/js/pages/Products.vue
similarity index 100%
rename from resources/js/pages/Projects.vue
rename to resources/js/pages/Products.vue
diff --git a/resources/js/pages/admin/ProductCreateOrEdit.vue b/resources/js/pages/admin/ProductCreateOrEdit.vue
index d571b4ac..73e3b9b8 100644
--- a/resources/js/pages/admin/ProductCreateOrEdit.vue
+++ b/resources/js/pages/admin/ProductCreateOrEdit.vue
@@ -66,7 +66,7 @@ const submitForm = () => {
{{ props.product ? 'Edit' : 'Create' }} Product
- Configure showcase products displayed on the /projects
+ Configure showcase products displayed on the /products
page.
@@ -329,7 +329,7 @@ const submitForm = () => {
class="text-xs text-slate-500 dark:text-gray-400"
>
Make this product visible on the public
- /projects showcase.
+ /products showcase.
name('projects.index');
+ Route::get('/products', [ProductController::class, 'index'])->name('products.index');
+ Route::permanentRedirect('/projects', '/products');
Route::get('/about-us', [AboutUsController::class, 'index']);
diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php
index c078279a..18040f5b 100644
--- a/tests/Feature/ProductTest.php
+++ b/tests/Feature/ProductTest.php
@@ -7,7 +7,7 @@
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
-test('the projects page renders dynamic products from database', function () {
+test('the products page renders dynamic products from database', function () {
Product::create([
'name' => 'Demo Platform',
'description' => 'Demo description',
@@ -27,24 +27,24 @@
'order' => 2,
]);
- $response = $this->get('/projects');
+ $response = $this->get('/products');
$response->assertStatus(200);
$response->assertInertia(fn (AssertableInertia $page) => $page
- ->component('Projects')
+ ->component('Products')
->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');
+test('products page products are cached forever and cleared on create, update, and delete', function () {
+ Cache::forget('products_page_products');
- expect(Cache::has('projects_page_products'))->toBeFalse();
+ expect(Cache::has('products_page_products'))->toBeFalse();
- $this->get('/projects')->assertStatus(200);
+ $this->get('/products')->assertStatus(200);
- expect(Cache::has('projects_page_products'))->toBeTrue();
+ expect(Cache::has('products_page_products'))->toBeTrue();
// Invalidate on create
$product = Product::create([
@@ -55,23 +55,23 @@
'is_active' => true,
]);
- expect(Cache::has('projects_page_products'))->toBeFalse();
+ expect(Cache::has('products_page_products'))->toBeFalse();
// Re-cache
- $this->get('/projects')->assertStatus(200);
- expect(Cache::has('projects_page_products'))->toBeTrue();
+ $this->get('/products')->assertStatus(200);
+ expect(Cache::has('products_page_products'))->toBeTrue();
// Invalidate on update
$product->update(['name' => 'Renamed Product']);
- expect(Cache::has('projects_page_products'))->toBeFalse();
+ expect(Cache::has('products_page_products'))->toBeFalse();
// Re-cache
- $this->get('/projects')->assertStatus(200);
- expect(Cache::has('projects_page_products'))->toBeTrue();
+ $this->get('/products')->assertStatus(200);
+ expect(Cache::has('products_page_products'))->toBeTrue();
// Invalidate on delete
$product->delete();
- expect(Cache::has('projects_page_products'))->toBeFalse();
+ expect(Cache::has('products_page_products'))->toBeFalse();
});
test('unauthorized users cannot access admin products management', function () {
diff --git a/tests/Feature/PublicPagesTest.php b/tests/Feature/PublicPagesTest.php
index 2f987e89..0a769133 100644
--- a/tests/Feature/PublicPagesTest.php
+++ b/tests/Feature/PublicPagesTest.php
@@ -54,12 +54,18 @@
$response->assertStatus(200);
});
-test('the projects page loads successfully', function () {
- $response = $this->get('/projects');
+test('the products page loads successfully', function () {
+ $response = $this->get('/products');
$response->assertStatus(200);
});
+test('the legacy projects route permanently redirects to products', function () {
+ $response = $this->get('/projects');
+
+ $response->assertRedirect('/products');
+});
+
test('non-existent public resources render the 404 error page', function () {
$response = $this->get('/resources/999999');
From d63f7104239303cb550922ff83c54e230547b3cb Mon Sep 17 00:00:00 2001
From: Tajim
Date: Mon, 21 Sep 2026 21:46:07 +0600
Subject: [PATCH 7/8] refactor(products): rename order column to sort_order
across table, models, and UI
---
app/Http/Controllers/Admin/ProductController.php | 2 +-
app/Http/Controllers/ProductController.php | 2 +-
.../Requests/Product/StoreProductRequest.php | 2 +-
.../Requests/Product/UpdateProductRequest.php | 2 +-
app/Models/Product.php | 4 ++--
.../2026_09_21_210000_create_products_table.php | 2 +-
resources/js/components/ProductCard.vue | 2 +-
resources/js/components/admin/ProductRow.vue | 6 +++---
resources/js/pages/admin/ProductCreateOrEdit.vue | 16 ++++++++--------
tests/Feature/ProductTest.php | 11 ++++++-----
10 files changed, 25 insertions(+), 24 deletions(-)
diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php
index 9b6eaaff..e465888f 100644
--- a/app/Http/Controllers/Admin/ProductController.php
+++ b/app/Http/Controllers/Admin/ProductController.php
@@ -18,7 +18,7 @@ class ProductController extends Controller
*/
public function index(): Response
{
- $products = Product::orderBy('order')
+ $products = Product::orderBy('sort_order')
->orderByDesc('id')
->get();
diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php
index 9e67438d..de8dd6c1 100644
--- a/app/Http/Controllers/ProductController.php
+++ b/app/Http/Controllers/ProductController.php
@@ -16,7 +16,7 @@ public function index(): Response
{
$products = Cache::rememberForever('products_page_products', function () {
return Product::where('is_active', true)
- ->orderBy('order')
+ ->orderBy('sort_order')
->orderBy('id')
->get();
});
diff --git a/app/Http/Requests/Product/StoreProductRequest.php b/app/Http/Requests/Product/StoreProductRequest.php
index 463ae5ba..69ecd6ee 100644
--- a/app/Http/Requests/Product/StoreProductRequest.php
+++ b/app/Http/Requests/Product/StoreProductRequest.php
@@ -27,7 +27,7 @@ public function rules(): array
'link' => ['required', 'string', 'max:255'],
'open_type' => ['required', 'string', 'in:_blank,_self'],
'button_text' => ['nullable', 'string', 'max:100'],
- 'order' => ['nullable', 'integer', 'min:0'],
+ 'sort_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
index 7666596e..95db1b60 100644
--- a/app/Http/Requests/Product/UpdateProductRequest.php
+++ b/app/Http/Requests/Product/UpdateProductRequest.php
@@ -27,7 +27,7 @@ public function rules(): array
'link' => ['required', 'string', 'max:255'],
'open_type' => ['required', 'string', 'in:_blank,_self'],
'button_text' => ['nullable', 'string', 'max:100'],
- 'order' => ['nullable', 'integer', 'min:0'],
+ 'sort_order' => ['nullable', 'integer', 'min:0'],
'is_active' => ['required', 'boolean'],
];
}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index c4645d7e..84e331a6 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -18,7 +18,7 @@ class Product extends Model
'link',
'open_type',
'button_text',
- 'order',
+ 'sort_order',
'is_active',
];
@@ -30,7 +30,7 @@ protected function casts(): array
{
return [
'is_active' => 'boolean',
- 'order' => 'integer',
+ 'sort_order' => 'integer',
];
}
diff --git a/database/migrations/2026_09_21_210000_create_products_table.php b/database/migrations/2026_09_21_210000_create_products_table.php
index c01c9001..c4c01805 100644
--- a/database/migrations/2026_09_21_210000_create_products_table.php
+++ b/database/migrations/2026_09_21_210000_create_products_table.php
@@ -20,7 +20,7 @@ public function up(): void
$table->string('link');
$table->string('open_type')->default('_blank');
$table->string('button_text')->nullable();
- $table->integer('order')->default(0);
+ $table->integer('sort_order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
diff --git a/resources/js/components/ProductCard.vue b/resources/js/components/ProductCard.vue
index 30cbaf21..16510be8 100644
--- a/resources/js/components/ProductCard.vue
+++ b/resources/js/components/ProductCard.vue
@@ -13,7 +13,7 @@ export interface ProductItem {
link: string;
open_type?: string;
button_text?: string | null;
- order?: number;
+ sort_order?: number;
is_active?: boolean;
}
diff --git a/resources/js/components/admin/ProductRow.vue b/resources/js/components/admin/ProductRow.vue
index 5526b457..8540001c 100644
--- a/resources/js/components/admin/ProductRow.vue
+++ b/resources/js/components/admin/ProductRow.vue
@@ -84,12 +84,12 @@ const deleteProduct = () => {
- Order: {{ product.order }}
+ Sort Order: {{ product.sort_order }}
diff --git a/resources/js/pages/admin/ProductCreateOrEdit.vue b/resources/js/pages/admin/ProductCreateOrEdit.vue
index 73e3b9b8..18e33b91 100644
--- a/resources/js/pages/admin/ProductCreateOrEdit.vue
+++ b/resources/js/pages/admin/ProductCreateOrEdit.vue
@@ -24,7 +24,7 @@ const form = useForm({
link: props.product?.link || '',
open_type: props.product?.open_type || '_blank',
button_text: props.product?.button_text || '',
- order: props.product?.order ?? 0,
+ sort_order: props.product?.sort_order ?? 0,
is_active: Boolean(props.product?.is_active ?? true),
});
@@ -289,29 +289,29 @@ const submitForm = () => {
diff --git a/tests/Feature/ProductTest.php b/tests/Feature/ProductTest.php
index 18040f5b..0c2a28f8 100644
--- a/tests/Feature/ProductTest.php
+++ b/tests/Feature/ProductTest.php
@@ -15,7 +15,7 @@
'open_type' => '_blank',
'users' => '500+ Users',
'is_active' => true,
- 'order' => 1,
+ 'sort_order' => 1,
]);
Product::create([
@@ -24,7 +24,7 @@
'link' => 'https://example.com/inactive',
'open_type' => '_self',
'is_active' => false,
- 'order' => 2,
+ 'sort_order' => 2,
]);
$response = $this->get('/products');
@@ -114,7 +114,7 @@
'link' => 'https://testplatform.com',
'open_type' => '_blank',
'button_text' => 'Launch Test',
- 'order' => 1,
+ 'sort_order' => 1,
'is_active' => true,
]);
@@ -124,7 +124,8 @@
expect($product)->not->toBeNull()
->and($product->users)->toBe('1000+ Users')
->and($product->open_type)->toBe('_blank')
- ->and($product->button_text)->toBe('Launch Test');
+ ->and($product->button_text)->toBe('Launch Test')
+ ->and($product->sort_order)->toBe(1);
Storage::assertExists($product->image_path);
@@ -139,7 +140,7 @@
'link' => 'https://updated.com',
'open_type' => '_self',
'button_text' => 'Go to Updated',
- 'order' => 5,
+ 'sort_order' => 5,
'is_active' => true,
]);
From 081615183926f68d723e395703450948a7d4d4dd Mon Sep 17 00:00:00 2001
From: Tajim
Date: Mon, 21 Sep 2026 21:51:59 +0600
Subject: [PATCH 8/8] refactor(products): simplify admin image handling to file
upload only
---
.../Controllers/Admin/ProductController.php | 10 ++-----
.../Requests/Product/StoreProductRequest.php | 1 -
.../Requests/Product/UpdateProductRequest.php | 1 -
.../js/pages/admin/ProductCreateOrEdit.vue | 27 +------------------
4 files changed, 3 insertions(+), 36 deletions(-)
diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php
index e465888f..2fd66859 100644
--- a/app/Http/Controllers/Admin/ProductController.php
+++ b/app/Http/Controllers/Admin/ProductController.php
@@ -45,11 +45,9 @@ public function store(StoreProductRequest $request): RedirectResponse
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']);
+ unset($data['image']);
Product::create($data);
@@ -82,13 +80,9 @@ public function update(UpdateProductRequest $request, Product $product): Redirec
$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']);
+ unset($data['image']);
$product->update($data);
diff --git a/app/Http/Requests/Product/StoreProductRequest.php b/app/Http/Requests/Product/StoreProductRequest.php
index 69ecd6ee..690ceebd 100644
--- a/app/Http/Requests/Product/StoreProductRequest.php
+++ b/app/Http/Requests/Product/StoreProductRequest.php
@@ -22,7 +22,6 @@ public function rules(): array
'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'],
diff --git a/app/Http/Requests/Product/UpdateProductRequest.php b/app/Http/Requests/Product/UpdateProductRequest.php
index 95db1b60..465be83e 100644
--- a/app/Http/Requests/Product/UpdateProductRequest.php
+++ b/app/Http/Requests/Product/UpdateProductRequest.php
@@ -22,7 +22,6 @@ public function rules(): array
'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'],
diff --git a/resources/js/pages/admin/ProductCreateOrEdit.vue b/resources/js/pages/admin/ProductCreateOrEdit.vue
index 18e33b91..20f09a83 100644
--- a/resources/js/pages/admin/ProductCreateOrEdit.vue
+++ b/resources/js/pages/admin/ProductCreateOrEdit.vue
@@ -10,16 +10,10 @@ const props = defineProps({
},
});
-const isExternalPath =
- props.product?.image_path &&
- (props.product.image_path.startsWith('http://') ||
- props.product.image_path.startsWith('https://'));
-
const form = useForm({
name: props.product?.name || '',
description: props.product?.description || '',
image: null as File | null,
- image_url: isExternalPath ? props.product.image_path : '',
users: props.product?.users || '',
link: props.product?.link || '',
open_type: props.product?.open_type || '_blank',
@@ -252,9 +246,7 @@ const submitForm = () => {
{
>
{{ form.errors.image }}
-
-
-
-
- Or provide direct image URL (e.g. CDN URL):
-
-
-