diff --git a/app/Console/Commands/AssignDefaultPlan.php b/app/Console/Commands/AssignDefaultPlan.php new file mode 100644 index 0000000..18d792b --- /dev/null +++ b/app/Console/Commands/AssignDefaultPlan.php @@ -0,0 +1,30 @@ +error('Free plan not found. Run db:seed --class=PlanSeeder first.'); + + return self::FAILURE; + } + + $count = Workspace::whereNull('plan_id')->update(['plan_id' => $freePlan->id]); + $this->info("✓ {$count} workspace(s) assigned to Free plan"); + + return self::SUCCESS; + } +} diff --git a/app/Domains/Bio/Models/BioPage.php b/app/Domains/Bio/Models/BioPage.php index 3a51e9d..c515ea9 100644 --- a/app/Domains/Bio/Models/BioPage.php +++ b/app/Domains/Bio/Models/BioPage.php @@ -60,4 +60,34 @@ class BioPage extends Model { return $this->belongsTo(Domain::class); } + + /** Canonical public URL — path-based for Free, subdomain/custom for Pro+ */ + public function publicUrl(): string + { + $apex = config('services.hetzner.zone', 'nimu.li'); + $ws = $this->workspace; + + if (! $ws) { + return ''; + } + + // Custom domain (Pro+ only, not apex subdomain). Require dot prefix so + // "evilnimu.li" is not treated as an apex subdomain of nimu.li. + if ($this->subdomain_host && ! str_ends_with($this->subdomain_host, '.'.$apex)) { + return "https://{$this->subdomain_host}/b/{$this->slug}"; + } + + // Pro+ workspace subdomain + if (! $ws->isFreePlan() && $ws->subdomain_full) { + return "https://{$ws->subdomain_full}/b/{$this->slug}"; + } + + // Free plan (or fallback): apex path-based + return "https://{$apex}/b/{$ws->slug}/{$this->slug}"; + } + + public function shortPublicUrl(): string + { + return (string) preg_replace('|^https?://|', '', $this->publicUrl()); + } } diff --git a/app/Domains/Subscription/Services/PlanLimitsService.php b/app/Domains/Subscription/Services/PlanLimitsService.php index 28be54b..cd98db3 100644 --- a/app/Domains/Subscription/Services/PlanLimitsService.php +++ b/app/Domains/Subscription/Services/PlanLimitsService.php @@ -8,12 +8,24 @@ use Illuminate\Support\Facades\Redis; class PlanLimitsService { + public function canCreateBioPage(Workspace $workspace): bool + { + /** @var Plan|null $plan */ + $plan = $workspace->plan; + if (! $plan || is_null($plan->bio_pages_limit)) { + return true; + } + $count = \App\Domains\Bio\Models\BioPage::where('workspace_id', $workspace->id)->count(); + + return $count < $plan->bio_pages_limit; + } + public function canCreateLink(Workspace $workspace): bool { /** @var Plan|null $plan */ $plan = $workspace->plan; - if (! $plan) { - return true; // No plan = allow (trial or free) + if (! $plan || is_null($plan->link_limit)) { + return true; } $count = $workspace->links()->count(); @@ -31,7 +43,7 @@ class PlanLimitsService { /** @var Plan|null $plan */ $plan = $workspace->plan; - if (! $plan) { + if (! $plan || is_null($plan->click_limit_monthly)) { return false; } $used = $this->monthlyClicksUsed($workspace); @@ -43,7 +55,7 @@ class PlanLimitsService { /** @var Plan|null $plan */ $plan = $workspace->plan; - if (! $plan) { + if (! $plan || is_null($plan->click_limit_monthly)) { return false; } diff --git a/app/Http/Controllers/BioPageController.php b/app/Http/Controllers/BioPageController.php index d52f257..8e904cf 100644 --- a/app/Http/Controllers/BioPageController.php +++ b/app/Http/Controllers/BioPageController.php @@ -4,22 +4,83 @@ namespace App\Http\Controllers; use App\Domains\Bio\Models\BioPage; use App\Domains\Domain\Models\Domain; -use Illuminate\Http\Response; +use App\Domains\Workspace\Models\Workspace; +use Illuminate\Http\Request; use Illuminate\View\View; +use Symfony\Component\HttpFoundation\Response; class BioPageController extends Controller { - public function show(string $slug): View|Response + /** + * Free-plan: nimu.li/b/{workspaceSlug}/{slug} + * Pro+ workspaces: 301 redirect to their subdomain URL. + */ + public function showFree(string $workspaceSlug, string $slug): View|Response { - $host = request()->getHost(); - $domain = Domain::where('hostname', $host)->first(); + $ws = Workspace::where('slug', $workspaceSlug)->firstOrFail(); - $page = BioPage::where('slug', $slug) - ->where('domain_id', $domain?->id) + if ($ws->isPaidPlan() && $ws->subdomain_full) { + return redirect("https://{$ws->subdomain_full}/b/{$slug}", 301); + } + + $bio = BioPage::where('workspace_id', $ws->id) + ->where('slug', $slug) + ->whereNull('subdomain_host') ->where('is_published', true) ->with('blocks') ->firstOrFail(); - return view('bio.show', compact('page')); + return view('bio.show', ['page' => $bio]); + } + + /** + * Pro+ subdomain ({ws-slug}.nimu.li/b/{slug}) or verified custom domain. + * Workspace resolved by ResolveWorkspaceBySubdomain middleware or host lookup here. + */ + public function showScoped(string $slug, Request $request): View|Response + { + $host = $request->getHost(); + $apex = config('services.hetzner.zone', 'nimu.li'); + + if (str_ends_with($host, '.'.$apex)) { + $wsSlug = substr($host, 0, -(strlen($apex) + 1)); + $ws = Workspace::where('slug', $wsSlug) + ->whereNotNull('subdomain_full') + ->firstOrFail(); + + abort_unless($ws->isPaidPlan(), 404); + + $bio = BioPage::where('workspace_id', $ws->id) + ->where('subdomain_host', $host) + ->where('slug', $slug) + ->where('is_published', true) + ->with('blocks') + ->firstOrFail(); + } else { + Domain::where('hostname', $host) + ->where('scope', 'custom') + ->whereNotNull('verified_at') + ->firstOrFail(); + + $bio = BioPage::where('subdomain_host', $host) + ->where('slug', $slug) + ->where('is_published', true) + ->with('blocks') + ->firstOrFail(); + } + + return view('bio.show', ['page' => $bio]); + } + + public function preview(Workspace $workspace, string $bioUlid): View|Response + { + $bio = BioPage::where('ulid', $bioUlid) + ->where('workspace_id', $workspace->id) + ->with('blocks') + ->firstOrFail(); + + $editUrl = route('w.bio.edit', [$workspace->ulid, $bioUlid]); + + return view('bio.show', ['page' => $bio, 'isPreview' => true, 'editUrl' => $editUrl]); } } diff --git a/app/Livewire/Modals/CreateBioPage.php b/app/Livewire/Modals/CreateBioPage.php index be76c74..c37e85b 100644 --- a/app/Livewire/Modals/CreateBioPage.php +++ b/app/Livewire/Modals/CreateBioPage.php @@ -14,6 +14,10 @@ class CreateBioPage extends ModalComponent { public string $workspaceUlid = ''; + public string $workspaceSlug = ''; + + public bool $isFreePlan = true; + public int $workspaceId = 0; public string $title = ''; @@ -50,9 +54,16 @@ class CreateBioPage extends ModalComponent $workspace->members()->where('user_id', auth()->id())->exists(), 404 ); - $this->workspaceUlid = $workspaceUlid; - $this->workspaceId = $workspace->id; + } elseif ($workspace = current_workspace()) { + // no-op: falls through to shared setup below + } else { + return; } + + $this->workspaceUlid = $workspace->ulid; + $this->workspaceSlug = $workspace->slug; + $this->isFreePlan = $workspace->isFreePlan(); + $this->workspaceId = $workspace->id; } public function save(): void diff --git a/app/Livewire/Pages/Bio/Edit.php b/app/Livewire/Pages/Bio/Edit.php new file mode 100644 index 0000000..037fb83 --- /dev/null +++ b/app/Livewire/Pages/Bio/Edit.php @@ -0,0 +1,137 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->pageId = $page->id; + $this->workspaceUlid = $workspace->ulid; + + $apex = config('services.hetzner.zone', 'nimu.li'); + + if (! $workspace->isFreePlan() && $workspace->subdomain_full) { + $this->publicUrlBase = "https://{$workspace->subdomain_full}/b"; + $this->displayUrlBase = "{$workspace->subdomain_full}/b"; + } else { + $this->publicUrlBase = "https://{$apex}/b/{$workspace->slug}"; + $this->displayUrlBase = "{$apex}/b/{$workspace->slug}"; + } + } + + public function save(array $state): void + { + $page = BioPage::findOrFail($this->pageId); + + $displayName = $state['profile']['displayName'] ?? ''; + + $page->update([ + 'slug' => $state['slug'] ?? $page->slug, + 'title' => ['en' => $displayName, 'de' => $displayName], + 'is_published' => false, + 'theme' => [ + 'profile' => $state['profile'] ?? [], + 'theme' => $state['theme'] ?? [], + 'socials' => $state['socials'] ?? [], + ], + ]); + + BioBlock::where('bio_page_id', $this->pageId)->delete(); + + foreach ($state['blocks'] ?? [] as $i => $block) { + BioBlock::create([ + 'bio_page_id' => $this->pageId, + 'position' => $i, + 'type' => $block['type'] ?? 'link', + 'config' => array_diff_key($block, array_flip(['id', 'type', '_collapsed'])), + ]); + } + + $this->dispatch('toast', message: 'Gespeichert', type: 'success'); + } + + public function publish(array $state): void + { + $this->save($state); + + BioPage::where('id', $this->pageId)->update(['is_published' => true]); + + $this->dispatch('toast', message: 'Veröffentlicht', type: 'success'); + } + + public function unpublish(): void + { + BioPage::where('id', $this->pageId)->update(['is_published' => false]); + + $this->dispatch('toast', message: 'Als Entwurf gespeichert', type: 'success'); + } + + public function render(): View + { + $page = BioPage::with('blocks')->findOrFail($this->pageId); + + $theme = is_array($page->theme) ? $page->theme : []; + + $initialState = [ + 'uid' => $page->blocks->count() + 100, + 'slug' => $page->slug, + 'isPublished' => $page->is_published, + 'profile' => $theme['profile'] ?? [ + 'avatar' => null, + 'displayName' => $page->getTranslation('title', 'en'), + 'handle' => $page->slug, + 'bio' => '', + 'verified' => false, + ], + 'theme' => array_merge([ + 'bgType' => 'gradient', + 'bg1' => '#0f0a2e', + 'bg2' => '#6b1b9a', + 'bgImage' => null, + 'dark' => true, + 'button' => 'rounded', + 'font' => 'geist', + 'borderColor' => '#7c3aed', + 'shadowColor' => '#18181b', + ], $theme['theme'] ?? []), + 'socials' => $theme['socials'] ?? [], + 'blocks' => $page->blocks + ->sortBy('position') + ->map(fn ($b) => array_merge( + ['id' => $b->id, 'type' => $b->type], + $b->config ?? [] + )) + ->values() + ->toArray(), + ]; + + return view('livewire.pages.bio.edit', [ + 'page' => $page, + 'workspaceUlid' => $this->workspaceUlid, + 'bioUlid' => $page->ulid, + 'initialState' => $initialState, + 'publicUrlBase' => $this->publicUrlBase, + 'displayUrlBase' => $this->displayUrlBase, + ])->layout('layouts.nimuli-app', ['title' => 'Bio Editor']); + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index e513b25..7dc06bf 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -12,13 +12,32 @@ class PlanFactory extends Factory public function definition(): array { return [ - 'name' => fake()->unique()->word(), + 'name' => 'Free', + 'tier' => 'free', 'monthly_price_cents' => 0, - 'workspace_limit' => 1, - 'member_limit' => 1, + 'workspace_limit' => 1, + 'member_limit' => 1, 'custom_domain_limit' => 0, - 'link_limit' => 50, + 'link_limit' => 50, 'click_limit_monthly' => 1000, + 'bio_pages_limit' => 1, + 'features' => [], ]; } + + public function free(): static + { + return $this->state(['tier' => 'free', 'name' => 'Free', 'custom_domain_limit' => 0, 'bio_pages_limit' => 1]); + } + + public function pro(): static + { + return $this->state([ + 'tier' => 'pro', + 'name' => 'Pro', + 'monthly_price_cents' => 1900, + 'custom_domain_limit' => 1, + 'bio_pages_limit' => 5, + ]); + } } diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php index 1a47f90..b57e137 100644 --- a/database/factories/WorkspaceFactory.php +++ b/database/factories/WorkspaceFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Domains\Subscription\Models\Plan; use App\Domains\Workspace\Models\Workspace; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -14,9 +15,24 @@ class WorkspaceFactory extends Factory public function definition(): array { return [ - 'name' => fake()->company(), - 'slug' => Str::random(12), + 'name' => fake()->company(), + 'slug' => Str::slug(fake()->unique()->words(3, true)), 'owner_id' => User::factory(), ]; } + + public function free(): static + { + return $this->state(fn () => [ + 'plan_id' => Plan::factory()->free()->create()->id, + ]); + } + + public function pro(): static + { + return $this->state(fn () => [ + 'plan_id' => Plan::factory()->pro()->create()->id, + 'subdomain_full' => null, + ]); + } } diff --git a/database/migrations/2026_05_17_500000_add_bio_pages_limit_and_agency_plan.php b/database/migrations/2026_05_17_500000_add_bio_pages_limit_and_agency_plan.php new file mode 100644 index 0000000..575aa6b --- /dev/null +++ b/database/migrations/2026_05_17_500000_add_bio_pages_limit_and_agency_plan.php @@ -0,0 +1,48 @@ +unsignedSmallInteger('bio_pages_limit')->nullable()->after('click_limit_monthly'); + + // make numeric limits nullable so Agency can set null = unlimited + $table->unsignedSmallInteger('workspace_limit')->nullable()->change(); + $table->unsignedSmallInteger('member_limit')->nullable()->change(); + $table->unsignedSmallInteger('custom_domain_limit')->nullable()->change(); + $table->unsignedInteger('link_limit')->nullable()->change(); + }); + + // tier is already a string(32) column (added in 400000); no DDL change needed. + // agency value is valid — string columns accept any value. + } + + public function down(): void + { + // Revert agency → free so numeric limits can be restored safely + DB::table('plans')->where('tier', 'agency')->update(['tier' => 'free']); + + // Fill NULLs with defaults before restoring NOT NULL constraints + DB::table('plans')->whereNull('workspace_limit')->update(['workspace_limit' => 1]); + DB::table('plans')->whereNull('member_limit')->update(['member_limit' => 1]); + DB::table('plans')->whereNull('custom_domain_limit')->update(['custom_domain_limit' => 0]); + DB::table('plans')->whereNull('link_limit')->update(['link_limit' => 50]); + + Schema::table('plans', function (Blueprint $table) { + if (Schema::hasColumn('plans', 'bio_pages_limit')) { + $table->dropColumn('bio_pages_limit'); + } + $table->unsignedSmallInteger('workspace_limit')->default(1)->nullable(false)->change(); + $table->unsignedSmallInteger('member_limit')->default(1)->nullable(false)->change(); + $table->unsignedSmallInteger('custom_domain_limit')->default(0)->nullable(false)->change(); + $table->unsignedInteger('link_limit')->default(50)->nullable(false)->change(); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 1a8f46f..35f7a86 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -10,14 +10,93 @@ class PlanSeeder extends Seeder public function run(): void { $plans = [ - ['name' => 'free', 'monthly_price_cents' => 0, 'workspace_limit' => 1, 'member_limit' => 1, 'custom_domain_limit' => 0, 'link_limit' => 50, 'click_limit_monthly' => 1000], - ['name' => 'pro', 'monthly_price_cents' => 1900, 'workspace_limit' => 1, 'member_limit' => 3, 'custom_domain_limit' => 1, 'link_limit' => 1000, 'click_limit_monthly' => 25000], - ['name' => 'business', 'monthly_price_cents' => 7900, 'workspace_limit' => 3, 'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000], - ['name' => 'agency', 'monthly_price_cents' => 19900, 'workspace_limit' => 999, 'member_limit' => 999, 'custom_domain_limit' => 999, 'link_limit' => 999999, 'click_limit_monthly' => 5000000], + [ + 'name' => 'Free', + 'tier' => 'free', + 'stripe_price_id' => null, + 'monthly_price_cents' => 0, + 'workspace_limit' => 1, + 'member_limit' => 1, + 'custom_domain_limit' => 0, + 'link_limit' => 50, + 'click_limit_monthly' => 1000, + 'bio_pages_limit' => 1, + 'features' => [ + 'workspace_subdomain' => false, + 'pixel_injection' => false, + 'ai_suite' => false, + 'webhooks' => false, + 'api_tokens' => false, + 'agency_features' => false, + ], + ], + [ + 'name' => 'Pro', + 'tier' => 'pro', + 'stripe_price_id' => env('STRIPE_PRICE_PRO'), + 'monthly_price_cents' => 1900, + 'workspace_limit' => 1, + 'member_limit' => 3, + 'custom_domain_limit' => 1, + 'link_limit' => 1000, + 'click_limit_monthly' => 25000, + 'bio_pages_limit' => 5, + 'features' => [ + 'workspace_subdomain' => true, + 'pixel_injection' => true, + 'ai_suite' => true, + 'webhooks' => true, + 'api_tokens' => true, + 'agency_features' => false, + ], + ], + [ + 'name' => 'Business', + 'tier' => 'business', + 'stripe_price_id' => env('STRIPE_PRICE_BUSINESS'), + 'monthly_price_cents' => 7900, + 'workspace_limit' => 3, + 'member_limit' => 10, + 'custom_domain_limit' => 5, + 'link_limit' => 10000, + 'click_limit_monthly' => 250000, + 'bio_pages_limit' => 25, + 'features' => [ + 'workspace_subdomain' => true, + 'pixel_injection' => true, + 'ai_suite' => true, + 'webhooks' => true, + 'api_tokens' => true, + 'agency_features' => true, + ], + ], + [ + 'name' => 'Agency', + 'tier' => 'agency', + 'stripe_price_id' => env('STRIPE_PRICE_AGENCY'), + 'monthly_price_cents' => 19900, + 'workspace_limit' => null, + 'member_limit' => null, + 'custom_domain_limit' => null, + 'link_limit' => null, + 'click_limit_monthly' => 5000000, + 'bio_pages_limit' => null, + 'features' => [ + 'workspace_subdomain' => true, + 'pixel_injection' => true, + 'ai_suite' => true, + 'webhooks' => true, + 'api_tokens' => true, + 'agency_features' => true, + 'whitelabel_reseller' => true, + ], + ], ]; - foreach ($plans as $plan) { - Plan::updateOrCreate(['name' => $plan['name']], $plan); + foreach ($plans as $p) { + Plan::updateOrCreate(['tier' => $p['tier']], $p); } + + $this->command->info('✓ 4 Plans seeded (free/pro/business/agency)'); } } diff --git a/resources/views/livewire/modals/create-bio-page.blade.php b/resources/views/livewire/modals/create-bio-page.blade.php index 43899ab..72f777f 100644 --- a/resources/views/livewire/modals/create-bio-page.blade.php +++ b/resources/views/livewire/modals/create-bio-page.blade.php @@ -13,7 +13,13 @@
Build a fully personalized landing page. Everything updates live in the preview on the right.
+ + {{-- Section 1: Profile --}} +{{ $bioPages->total() }} pages
No bio pages yet
Create a link-in-bio page for Instagram, TikTok and more.
- @if($ws) + @if($workspaceUlid) @endif diff --git a/tests/Feature/Bio/PublicUrlTest.php b/tests/Feature/Bio/PublicUrlTest.php new file mode 100644 index 0000000..f110cfd --- /dev/null +++ b/tests/Feature/Bio/PublicUrlTest.php @@ -0,0 +1,48 @@ +free()->create(['slug' => 'nexxo']); + $bio = BioPage::factory()->for($ws)->create(['slug' => 'my-bio']); + + expect($bio->publicUrl())->toBe('https://nimu.li/b/nexxo/my-bio'); +}); + +it('returns shortPublicUrl without scheme', function () { + $ws = Workspace::factory()->free()->create(['slug' => 'nexxo']); + $bio = BioPage::factory()->for($ws)->create(['slug' => 'my-bio']); + + expect($bio->shortPublicUrl())->toBe('nimu.li/b/nexxo/my-bio'); +}); + +it('returns pro subdomain-url when subdomain_full set', function () { + $ws = Workspace::factory()->pro()->create([ + 'slug' => 'acme', + 'subdomain_full' => 'acme.nimu.li', + ]); + $bio = BioPage::factory()->for($ws)->create(['slug' => 'john']); + + expect($bio->publicUrl())->toBe('https://acme.nimu.li/b/john'); +}); + +it('returns custom-domain-url when subdomain_host is non-apex domain', function () { + $ws = Workspace::factory()->pro()->create(['slug' => 'acme']); + $bio = BioPage::factory()->for($ws)->create([ + 'slug' => 'page', + 'subdomain_host' => 'links.acme.de', + ]); + + expect($bio->publicUrl())->toBe('https://links.acme.de/b/page'); +}); + +it('falls back to free path when no subdomain_full on paid plan', function () { + $ws = Workspace::factory()->pro()->create([ + 'slug' => 'acme', + 'subdomain_full' => null, + ]); + $bio = BioPage::factory()->for($ws)->create(['slug' => 'test']); + + expect($bio->publicUrl())->toBe('https://nimu.li/b/acme/test'); +}); diff --git a/tests/Feature/Routing/Free404FixTest.php b/tests/Feature/Routing/Free404FixTest.php new file mode 100644 index 0000000..835c0ba --- /dev/null +++ b/tests/Feature/Routing/Free404FixTest.php @@ -0,0 +1,35 @@ +free()->create(['slug' => 'nexxo']); + BioPage::factory()->for($ws)->create([ + 'slug' => 'my-bio', + 'is_published' => true, + ]); + + $this->get('http://nimu.li/b/nexxo/my-bio')->assertOk(); +}); + +it('returns 404 for ulid-path attempt on apex domain', function () { + $ws = Workspace::factory()->free()->create(['slug' => 'nexxo']); + BioPage::factory()->for($ws)->create([ + 'slug' => 'my-bio', + 'is_published' => true, + ]); + + // ULID starts with digits/uppercase — fails workspaceSlug regex [a-z][a-z0-9-]{1,62} + $this->get('http://nimu.li/b/'.$ws->ulid.'/my-bio')->assertNotFound(); +}); + +it('unpublished bio returns 404 on apex domain', function () { + $ws = Workspace::factory()->free()->create(['slug' => 'nexxo']); + BioPage::factory()->for($ws)->create([ + 'slug' => 'draft', + 'is_published' => false, + ]); + + $this->get('http://nimu.li/b/nexxo/draft')->assertNotFound(); +});