From deb5f310a116587cbf1e81ca84110326db3447f9 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 17 May 2026 22:44:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(bio):=20plan-aware=20URL=20+=20plan-seeder?= =?UTF-8?q?=20+=20ulid=E2=86=92slug=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: add bio_pages_limit to plans, make limit cols nullable (agency=unlimited) - PlanSeeder: 4 plans (free/pro/business/agency) with tier/features/bio_pages_limit - workspaces:assign-default-plan command: assigns free plan to unassigned workspaces - BioPage::publicUrl(): free=apex-path, pro+=subdomain, custom=custom-domain - Security: str_ends_with check uses '.'.$apex to prevent evilnimu.li bypass - BioPage::shortPublicUrl(): URL without scheme for display - PlanLimitsService: use bio_pages_limit column (was features['bio_pages']), null=unlimited - BioPageController: fix $bio → $page variable (view expects $page) - Bio index/edit views: use publicUrl()/shortPublicUrl(), not hardcoded ULID URLs - Bio edit view: publicUrlBase/displayUrlBase from component (plan-aware prefix) - Create-bio modal: plan-aware URL prefix display - PlanFactory/WorkspaceFactory: free()/pro() states for tests - Tests: PublicUrlTest (5 cases), Free404FixTest (3 cases) Known pre-existing failure (not caused by this change): - Tests\Feature\Auth\EmailVerificationTest::email can be verified Co-Authored-By: Claude Sonnet 4.6 --- app/Console/Commands/AssignDefaultPlan.php | 30 + app/Domains/Bio/Models/BioPage.php | 30 + .../Services/PlanLimitsService.php | 20 +- app/Http/Controllers/BioPageController.php | 75 +- app/Livewire/Modals/CreateBioPage.php | 15 +- app/Livewire/Pages/Bio/Edit.php | 137 +++ database/factories/PlanFactory.php | 27 +- database/factories/WorkspaceFactory.php | 20 +- ...00_add_bio_pages_limit_and_agency_plan.php | 48 + database/seeders/PlanSeeder.php | 91 +- .../livewire/modals/create-bio-page.blade.php | 8 +- .../views/livewire/pages/bio/edit.blade.php | 1052 +++++++++++++++++ .../views/livewire/pages/bio/index.blade.php | 40 +- tests/Feature/Bio/PublicUrlTest.php | 48 + tests/Feature/Routing/Free404FixTest.php | 35 + 15 files changed, 1631 insertions(+), 45 deletions(-) create mode 100644 app/Console/Commands/AssignDefaultPlan.php create mode 100644 app/Livewire/Pages/Bio/Edit.php create mode 100644 database/migrations/2026_05_17_500000_add_bio_pages_limit_and_agency_plan.php create mode 100644 resources/views/livewire/pages/bio/edit.blade.php create mode 100644 tests/Feature/Bio/PublicUrlTest.php create mode 100644 tests/Feature/Routing/Free404FixTest.php 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 @@
- nimu.li/bio/ + @php + $apex = config('services.hetzner.zone', 'nimu.li'); + $urlPrefix = $isFreePlan + ? $apex.'/b/'.$workspaceSlug.'/' + : $workspaceSlug.'.'.$apex.'/b/'; + @endphp + {{ $urlPrefix }} diff --git a/resources/views/livewire/pages/bio/edit.blade.php b/resources/views/livewire/pages/bio/edit.blade.php new file mode 100644 index 0000000..cb12678 --- /dev/null +++ b/resources/views/livewire/pages/bio/edit.blade.php @@ -0,0 +1,1052 @@ +{{-- + Bio page editor — all x-html targets are built from Alpine component state only + (no unescaped server data injected). renderBlocksHtml / renderSocialsHtml compose + strings from state.blocks and state.socials, which come from $initialState (PHP array + encoded via @json). User-entered text is output via textContent-equivalent bindings + (x-text) wherever XSS matters; x-html is used only for the live phone preview where + we build structured HTML from trusted state properties (urls, titles, etc.). +--}} +
+ + {{-- =================== Topbar =================== --}} +
+ +
+ + +
+
+ + + Preview + + + +
+ + {{-- =================== Editor + Preview Grid =================== --}} +
+ + {{-- ============ Editor (left) ============ --}} +
+

Link-in-Bio

+

Build a fully personalized landing page. Everything updates live in the preview on the right.

+ + {{-- Section 1: Profile --}} +
+
+ 1 +

Profile

+ How you appear on top +
+
+ + {{-- Avatar + Name/Handle --}} +
+
+ + +
+
+
+ + +
+
+ +
+ @ + +
+
+
+
+ + {{-- Bio textarea --}} +
+ + +
/140
+
+ + {{-- Verified toggle --}} +
+
+
Show verified badge
+
Adds a blue check next to your display name.
+
+
+
+ +
+
+ + {{-- Section 2: Theme --}} +
+
+ 2 +

Theme

+ Background, buttons, fonts +
+
+ + {{-- Preset themes --}} +
+ Preset themes +
+ + + + + + +
+
+ + {{-- Background type --}} +
+ Background +
+
Gradient
+
Solid
+
Image
+
+
+ + {{-- Color rows --}} +
+
+ Color 1 +
+ + +
+
+
+ Color 2 +
+ + +
+
+
+ + {{-- Background image dropzone --}} +
+ + +
+ + {{-- Dark mode toggle --}} +
+
+
Light text on dark background
+
Switches text color for readability on dark themes.
+
+
+
+ + {{-- Button style --}} +
+ Button style +
+ + + + + +
+ + {{-- Border color (always visible) --}} +
+
+ Border color +
+ + +
+
+
+ Shadow color +
+ + +
+
+
+
+ + {{-- Font --}} +
+ Font +
+ + + + +
+
+ +
+
+ + {{-- Section 3: Social Icons --}} +
+
+ 3 +

Social icons

+ Tap-row above your links +
+
+ + {{-- Current socials --}} +
+ +
+ + {{-- Add-social chips --}} +
+ +
+ +
+
+ + {{-- Section 4: Links & Blocks --}} +
+
+ 4 +

Links & Blocks

+ Reorder with ↑↓, click row to expand +
+
+ + + {{-- Add block buttons --}} +
+ + + +
+ +
+
+ + {{-- Section 5: Page settings --}} +
+
+ 5 +

Page settings

+ URL & visibility +
+
+
+ +
+ {{ $displayUrlBase }}/ + +
+
Lowercase, no spaces. Connect a custom domain in Domains.
+
+
+
+
Published
+
When enabled, your bio page is publicly visible.
+
+
+
+
+
+ +
+ + {{-- ============ Preview (right) ============ --}} + + +
+ + {{-- =================== Crop Modal =================== --}} + + + {{-- =================== SVG Social Icon Sprite =================== --}} + + + +
+{{-- script outside root div: libxml2 parseHTML() interprets
in JS strings as closing the outer element --}} + + +@script + +@endscript + diff --git a/resources/views/livewire/pages/bio/index.blade.php b/resources/views/livewire/pages/bio/index.blade.php index ebded00..53dc7f5 100644 --- a/resources/views/livewire/pages/bio/index.blade.php +++ b/resources/views/livewire/pages/bio/index.blade.php @@ -1,14 +1,14 @@ -
- @php $ws = current_workspace(); @endphp +

Link-in-Bio

{{ $bioPages->total() }} pages

- @if($ws) + @if($workspaceUlid) @@ -25,7 +25,18 @@
{{ $title }}
-
nimu.li/{{ $page->slug }}
+
{{ $page->shortPublicUrl() }}
+
+ +
+ + Edit +
@@ -34,16 +45,6 @@ @endif {{ $page->is_published ? 'Published' : 'Draft' }} - -
- - - -
@empty
@@ -53,10 +54,11 @@

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(); +});