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}`; +}); + + + 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 @@ + + + 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 @@ + + + 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 @@ - - - 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..20f09a83 --- /dev/null +++ b/resources/js/pages/admin/ProductCreateOrEdit.vue @@ -0,0 +1,347 @@ + + + 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');