From 768c814762d310149de2fb52b3a0dc74283ea0e2 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 17 Oct 2026 16:02:27 +0300 Subject: [PATCH] feat: product pricing plans Co-Authored-By: Claude Sonnet 5.5 --- app/Http/Controllers/ProductController.php | 19 +++++ app/Models/PricingPlan.php | 31 ++++++++ app/Models/Product.php | 8 ++ ...0_17_120000_create_pricing_plans_table.php | 27 +++++++ .../components/product/plans-form.blade.php | 40 ++++++++++ .../components/product/pricing.blade.php | 21 +++++ resources/views/products/create.blade.php | 2 + resources/views/products/edit.blade.php | 2 + resources/views/products/show.blade.php | 2 +- tests/Feature/ProductPricingTest.php | 78 +++++++++++++++++++ 10 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 app/Models/PricingPlan.php create mode 100644 database/migrations/2026_10_17_120000_create_pricing_plans_table.php create mode 100644 resources/views/components/product/plans-form.blade.php create mode 100644 resources/views/components/product/pricing.blade.php create mode 100644 tests/Feature/ProductPricingTest.php diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 43408ab..87f2a19 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers; use App\Models\Category; +use App\Models\PricingPlan; use App\Models\Product; use App\Models\Tag; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\Rule; use Laravel\Pennant\Feature; class ProductController extends Controller @@ -227,6 +229,11 @@ class ProductController extends Controller 'screenshots.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048', 'remove_screenshots' => 'nullable|array', 'remove_screenshots.*' => 'integer', + 'plans' => 'nullable|array', + 'plans.*.name' => 'required|string|max:100', + 'plans.*.price' => 'required|numeric|min:0|max:99999', + 'plans.*.period' => ['required', Rule::in(PricingPlan::PERIODS)], + 'plans.*.description' => 'nullable|string|max:255', ]; } @@ -235,6 +242,18 @@ class ProductController extends Controller */ protected function syncDetails(Request $request, Product $product): void { + // The submitted plan list replaces all existing plans + $product->pricingPlans()->delete(); + foreach (array_values((array) $request->input('plans', [])) as $i => $plan) { + $product->pricingPlans()->create([ + 'name' => $plan['name'], + 'price' => $plan['price'], + 'period' => $plan['period'], + 'description' => $plan['description'] ?? null, + 'position' => $i, + ]); + } + // Only rows belonging to this product may be removed $ids = array_filter((array) $request->input('remove_screenshots', [])); if ($ids) { diff --git a/app/Models/PricingPlan.php b/app/Models/PricingPlan.php new file mode 100644 index 0000000..c5a1e53 --- /dev/null +++ b/app/Models/PricingPlan.php @@ -0,0 +1,31 @@ + 'decimal:2']; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Price formatted for display, e.g. "€9.99" ("€0" for free plans). + */ + public function formattedPrice(): string + { + return (float) $this->price == 0 ? '€0' : '€'.number_format((float) $this->price, 2); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index d329425..30894fa 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -130,6 +130,14 @@ class Product extends Model return $this->hasMany(ProductScreenshot::class)->orderBy('position')->orderBy('id'); } + /** + * Get the product's pricing plans in display order. + */ + public function pricingPlans(): HasMany + { + return $this->hasMany(PricingPlan::class)->orderBy('position')->orderBy('id'); + } + /** * Get the user that owns the product. */ diff --git a/database/migrations/2026_10_17_120000_create_pricing_plans_table.php b/database/migrations/2026_10_17_120000_create_pricing_plans_table.php new file mode 100644 index 0000000..4c67fbe --- /dev/null +++ b/database/migrations/2026_10_17_120000_create_pricing_plans_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name', 100); + $table->decimal('price', 8, 2); + $table->string('period'); + $table->string('description')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pricing_plans'); + } +}; diff --git a/resources/views/components/product/plans-form.blade.php b/resources/views/components/product/plans-form.blade.php new file mode 100644 index 0000000..57536a7 --- /dev/null +++ b/resources/views/components/product/plans-form.blade.php @@ -0,0 +1,40 @@ +@props(['plans' => []]) + +@php + $initial = collect(old('plans', $plans))->map(fn ($p) => [ + 'name' => $p['name'] ?? '', + 'price' => $p['price'] ?? '', + 'period' => $p['period'] ?? 'monthly', + 'description' => $p['description'] ?? '', + ])->values()->all(); + $input = 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm'; +@endphp + + +
+ +
+ +
+ @foreach($errors->get('plans.*') as $messages) +

{{ $messages[0] }}

+ @endforeach + @error('plans') +

{{ $message }}

+ @enderror + +
+ diff --git a/resources/views/components/product/pricing.blade.php b/resources/views/components/product/pricing.blade.php new file mode 100644 index 0000000..706bc3b --- /dev/null +++ b/resources/views/components/product/pricing.blade.php @@ -0,0 +1,21 @@ +@props(['product']) + +@php($plans = $product->pricingPlans) + +@if($plans->isNotEmpty()) +
class('bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6') }}> +

Pricing

+
+ @foreach($plans as $plan) +
+

{{ $plan->name }}

+

{{ $plan->formattedPrice() }}

+

{{ $plan->period }}

+ @if($plan->description) +

{{ $plan->description }}

+ @endif +
+ @endforeach +
+
+@endif diff --git a/resources/views/products/create.blade.php b/resources/views/products/create.blade.php index d3640a4..fca263d 100644 --- a/resources/views/products/create.blade.php +++ b/resources/views/products/create.blade.php @@ -207,6 +207,8 @@ + +
diff --git a/resources/views/products/edit.blade.php b/resources/views/products/edit.blade.php index 398514a..4f57482 100644 --- a/resources/views/products/edit.blade.php +++ b/resources/views/products/edit.blade.php @@ -251,6 +251,8 @@
+ +
diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index 59efb01..eb54304 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -181,7 +181,7 @@ @endif
- + diff --git a/tests/Feature/ProductPricingTest.php b/tests/Feature/ProductPricingTest.php new file mode 100644 index 0000000..b7faa53 --- /dev/null +++ b/tests/Feature/ProductPricingTest.php @@ -0,0 +1,78 @@ + 'Priced Product', + 'description' => 'A product description', + 'website_url' => 'https://example.com', + 'category_id' => makeCategory()->id, + ], $overrides); +} + +test('submitter adds plans', function () { + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), pricingPayload([ + 'plans' => [ + ['name' => 'Free', 'price' => '0', 'period' => 'monthly', 'description' => 'Basics'], + ['name' => 'Team', 'price' => '9.99', 'period' => 'monthly'], + ], + ]))->assertRedirect()->assertSessionHasNoErrors(); + + $plans = Product::firstOrFail()->pricingPlans; + expect($plans->pluck('name')->all())->toBe(['Free', 'Team']); + expect($plans->pluck('position')->all())->toBe([0, 1]); + expect((float) $plans[1]->price)->toBe(9.99); +}); + +test('invalid plans fail validation and save nothing', function (array $plan, string $key) { + $user = userWithRole('user'); + + $this->actingAs($user)->post(route('products.store'), pricingPayload([ + 'plans' => [array_merge(['name' => 'Pro', 'price' => '5', 'period' => 'yearly'], $plan)], + ]))->assertSessionHasErrors($key); + + expect(Product::count())->toBe(0); + expect(PricingPlan::count())->toBe(0); +})->with([ + 'negative price' => [['price' => '-1'], 'plans.0.price'], + 'non numeric price' => [['price' => 'abc'], 'plans.0.price'], + 'bad period' => [['period' => 'weekly'], 'plans.0.period'], + 'missing name' => [['name' => ''], 'plans.0.name'], +]); + +test('update replaces the plan list', function () { + $user = userWithRole('user'); + $product = Product::factory()->pending()->create(['user_id' => $user->id]); + $product->pricingPlans()->create(['name' => 'Old', 'price' => 1, 'period' => 'monthly', 'position' => 0]); + $product->pricingPlans()->create(['name' => 'Older', 'price' => 2, 'period' => 'yearly', 'position' => 1]); + + $this->actingAs($user)->put(route('products.update', $product), pricingPayload([ + 'category_id' => $product->category_id, + 'plans' => [['name' => 'New', 'price' => '3', 'period' => 'one-time']], + ]))->assertRedirect(); + + expect($product->pricingPlans()->pluck('name')->all())->toBe(['New']); +}); + +test('show page prints price and period', function () { + $product = Product::factory()->approved()->create(); + $product->pricingPlans()->create(['name' => 'Free', 'price' => 0, 'period' => 'monthly', 'position' => 0]); + $product->pricingPlans()->create(['name' => 'Team', 'price' => 9.99, 'period' => 'monthly', 'position' => 1]); + + $this->get(route('products.show', $product))->assertOk() + ->assertSee('€9.99')->assertSee('monthly')->assertSee('€0'); +}); + +test('no plans, no pricing section', function () { + $product = Product::factory()->approved()->create(); + + $this->get(route('products.show', $product))->assertOk()->assertDontSee('data-pricing', false); +});