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/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php
new file mode 100644
index 00000000..2fd66859
--- /dev/null
+++ b/app/Http/Controllers/Admin/ProductController.php
@@ -0,0 +1,109 @@
+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;
+ }
+
+ unset($data['image']);
+
+ 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;
+ }
+
+ unset($data['image']);
+
+ $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..de8dd6c1
--- /dev/null
+++ b/app/Http/Controllers/ProductController.php
@@ -0,0 +1,28 @@
+orderBy('sort_order')
+ ->orderBy('id')
+ ->get();
+ });
+
+ return Inertia::render('Products', [
+ 'products' => $products,
+ ]);
+ }
+}
diff --git a/app/Http/Requests/Product/StoreProductRequest.php b/app/Http/Requests/Product/StoreProductRequest.php
new file mode 100644
index 00000000..690ceebd
--- /dev/null
+++ b/app/Http/Requests/Product/StoreProductRequest.php
@@ -0,0 +1,33 @@
+ ['required', 'string', 'max:255'],
+ 'description' => ['required', 'string'],
+ 'image' => [
+ 'nullable',
+ 'image',
+ 'mimes:jpg,jpeg,png,webp',
+ 'max:5120',
+ ],
+ 'users' => ['nullable', 'string', 'max:100'],
+ 'link' => ['required', 'string', 'max:255'],
+ 'open_type' => ['required', 'string', 'in:_blank,_self'],
+ 'button_text' => ['nullable', 'string', 'max:100'],
+ '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
new file mode 100644
index 00000000..465be83e
--- /dev/null
+++ b/app/Http/Requests/Product/UpdateProductRequest.php
@@ -0,0 +1,33 @@
+ ['required', 'string', 'max:255'],
+ 'description' => ['required', 'string'],
+ 'image' => [
+ 'nullable',
+ 'image',
+ 'mimes:jpg,jpeg,png,webp',
+ 'max:5120',
+ ],
+ 'users' => ['nullable', 'string', 'max:100'],
+ 'link' => ['required', 'string', 'max:255'],
+ 'open_type' => ['required', 'string', 'in:_blank,_self'],
+ 'button_text' => ['nullable', 'string', 'max:100'],
+ 'sort_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..84e331a6
--- /dev/null
+++ b/app/Models/Product.php
@@ -0,0 +1,47 @@
+ 'boolean',
+ 'sort_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..4a77cade
--- /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('sort_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/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';
+import { Link } from '@inertiajs/vue3';
+import { ExternalLink, ArrowRight, Users, Layers } from 'lucide-vue-next';
+import { computed } from 'vue';
+
+export interface ProductItem {
+ id?: number;
+ name: string;
+ description: string;
+ image_url?: string | null;
+ image_path?: string | null;
+ users?: string | null;
+ link: string;
+ open_type?: string;
+ button_text?: string | null;
+ sort_order?: number;
+ is_active?: boolean;
+}
+
+const props = defineProps<{
+ product: ProductItem;
+}>();
+
+const displayImage = computed(() => {
+ return props.product.image_url || props.product.image_path || null;
+});
+
+const isBlank = computed(() => {
+ return (
+ props.product.open_type === '_blank' ||
+ props.product.link.startsWith('http://') ||
+ props.product.link.startsWith('https://')
+ );
+});
+
+const buttonLabel = computed(() => {
+ return props.product.button_text || `Visit ${props.product.name}`;
+});
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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..8540001c
--- /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)'
+ }}
+
+
+
+ Sort Order: {{ product.sort_order }}
+
+
+
+
+ {{ product.description }}
+
+
+
+
{{
+ product.link
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/navigation/Navigation.tsx b/resources/js/components/navigation/Navigation.tsx
index 9ca53043..b4968ba7 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',
@@ -523,10 +524,10 @@ export const SiteRail = defineComponent({
)}
More From Us
- {isActive('/projects') && (
+ {isActive('/products') && (
)}
@@ -1231,11 +1232,11 @@ export const SiteDrawer = defineComponent({
)}
More From Us
- {isActive('/projects') && (
+ {isActive('/products') && (
)}
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/Products.vue b/resources/js/pages/Products.vue
new file mode 100644
index 00000000..a94369c8
--- /dev/null
+++ b/resources/js/pages/Products.vue
@@ -0,0 +1,66 @@
+
+
+
+
+ Our Products & Open Source Projects
+
+
+
+
+
+
+
+ Our
+ Products
+
+
+
+ Explore platforms and tools built by HSCStack
+
+
+
+
+
+
+
+
+ No products are currently available. Check back soon!
+
+
+
+
diff --git a/resources/js/pages/Projects.vue b/resources/js/pages/Projects.vue
deleted file mode 100644
index b6520eea..00000000
--- a/resources/js/pages/Projects.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
- Our Products & Open Source Projects
-
-
-
-
-
-
-
- Our
- Products
-
-
-
- Explore platforms and tools built by HSCStack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- HSCStack
-
-
-
- 640+ Users
-
-
-
-
- HSC ও SSC শিক্ষার্থীদের পড়াশোনা গোছানোর জন্য সম্পূর্ণ
- ফ্রি ও পরিচ্ছন্ন স্টাডি রিপোজিটরি। বিষয় ও অধ্যায়ভিত্তিক
- হ্যান্ডনোট, লেকচার ক্লাস ও রিসোর্স।
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ResultStack
-
-
-
- 6600+ Users
-
-
-
-
- বাংলাদেশের সকল শিক্ষা বোর্ডের মেধা তালিকা, সেরা
- প্রতিষ্ঠান ও বিস্তারিত GPA পরিসংখ্যান বিশ্লেষণ করুন একটি
- সমন্বিত প্ল্যাটফর্মে।
-
-
-
-
-
-
-
-
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..20f09a83
--- /dev/null
+++ b/resources/js/pages/admin/ProductCreateOrEdit.vue
@@ -0,0 +1,347 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ props.product ? 'Edit' : 'Create' }} Product
+
+
+ Configure showcase products displayed on the /products
+ page.
+
+
+
+
+
+
+
+
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..aee68ccd 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,8 @@
Route::inertia('/join', 'platform/JoinTeam');
Route::inertia('/guide', 'ContributorGuide');
Route::inertia('/ai', 'ai/Index');
- Route::inertia('/projects', 'Projects');
+ 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
new file mode 100644
index 00000000..0c2a28f8
--- /dev/null
+++ b/tests/Feature/ProductTest.php
@@ -0,0 +1,157 @@
+ 'Demo Platform',
+ 'description' => 'Demo description',
+ 'link' => 'https://example.com',
+ 'open_type' => '_blank',
+ 'users' => '500+ Users',
+ 'is_active' => true,
+ 'sort_order' => 1,
+ ]);
+
+ Product::create([
+ 'name' => 'Inactive Platform',
+ 'description' => 'Inactive description',
+ 'link' => 'https://example.com/inactive',
+ 'open_type' => '_self',
+ 'is_active' => false,
+ 'sort_order' => 2,
+ ]);
+
+ $response = $this->get('/products');
+
+ $response->assertStatus(200);
+ $response->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('Products')
+ ->has('products', 1)
+ ->where('products.0.name', 'Demo Platform')
+ );
+});
+
+test('products page products are cached forever and cleared on create, update, and delete', function () {
+ Cache::forget('products_page_products');
+
+ expect(Cache::has('products_page_products'))->toBeFalse();
+
+ $this->get('/products')->assertStatus(200);
+
+ expect(Cache::has('products_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('products_page_products'))->toBeFalse();
+
+ // Re-cache
+ $this->get('/products')->assertStatus(200);
+ expect(Cache::has('products_page_products'))->toBeTrue();
+
+ // Invalidate on update
+ $product->update(['name' => 'Renamed Product']);
+ expect(Cache::has('products_page_products'))->toBeFalse();
+
+ // Re-cache
+ $this->get('/products')->assertStatus(200);
+ expect(Cache::has('products_page_products'))->toBeTrue();
+
+ // Invalidate on delete
+ $product->delete();
+ expect(Cache::has('products_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',
+ 'sort_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')
+ ->and($product->sort_order)->toBe(1);
+
+ 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',
+ 'sort_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();
+});
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');