fix(bio): plan-aware URL + plan-seeder + ulid→slug fix
- 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 <noreply@anthropic.com>main
parent
c479e46f55
commit
deb5f310a1
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Domains\Subscription\Models\Plan;
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class AssignDefaultPlan extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'workspaces:assign-default-plan';
|
||||||
|
|
||||||
|
protected $description = 'Assign free plan to workspaces without a plan';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$freePlan = Plan::firstWhere('tier', 'free');
|
||||||
|
|
||||||
|
if (! $freePlan) {
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,4 +60,34 @@ class BioPage extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Domain::class);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,24 @@ use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
class PlanLimitsService
|
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
|
public function canCreateLink(Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
/** @var Plan|null $plan */
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) {
|
if (! $plan || is_null($plan->link_limit)) {
|
||||||
return true; // No plan = allow (trial or free)
|
return true;
|
||||||
}
|
}
|
||||||
$count = $workspace->links()->count();
|
$count = $workspace->links()->count();
|
||||||
|
|
||||||
|
|
@ -31,7 +43,7 @@ class PlanLimitsService
|
||||||
{
|
{
|
||||||
/** @var Plan|null $plan */
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) {
|
if (! $plan || is_null($plan->click_limit_monthly)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$used = $this->monthlyClicksUsed($workspace);
|
$used = $this->monthlyClicksUsed($workspace);
|
||||||
|
|
@ -43,7 +55,7 @@ class PlanLimitsService
|
||||||
{
|
{
|
||||||
/** @var Plan|null $plan */
|
/** @var Plan|null $plan */
|
||||||
$plan = $workspace->plan;
|
$plan = $workspace->plan;
|
||||||
if (! $plan) {
|
if (! $plan || is_null($plan->click_limit_monthly)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,83 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Domains\Bio\Models\BioPage;
|
use App\Domains\Bio\Models\BioPage;
|
||||||
use App\Domains\Domain\Models\Domain;
|
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 Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class BioPageController extends Controller
|
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();
|
$ws = Workspace::where('slug', $workspaceSlug)->firstOrFail();
|
||||||
$domain = Domain::where('hostname', $host)->first();
|
|
||||||
|
|
||||||
$page = BioPage::where('slug', $slug)
|
if ($ws->isPaidPlan() && $ws->subdomain_full) {
|
||||||
->where('domain_id', $domain?->id)
|
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)
|
->where('is_published', true)
|
||||||
->with('blocks')
|
->with('blocks')
|
||||||
->firstOrFail();
|
->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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ class CreateBioPage extends ModalComponent
|
||||||
{
|
{
|
||||||
public string $workspaceUlid = '';
|
public string $workspaceUlid = '';
|
||||||
|
|
||||||
|
public string $workspaceSlug = '';
|
||||||
|
|
||||||
|
public bool $isFreePlan = true;
|
||||||
|
|
||||||
public int $workspaceId = 0;
|
public int $workspaceId = 0;
|
||||||
|
|
||||||
public string $title = '';
|
public string $title = '';
|
||||||
|
|
@ -50,9 +54,16 @@ class CreateBioPage extends ModalComponent
|
||||||
$workspace->members()->where('user_id', auth()->id())->exists(),
|
$workspace->members()->where('user_id', auth()->id())->exists(),
|
||||||
404
|
404
|
||||||
);
|
);
|
||||||
$this->workspaceUlid = $workspaceUlid;
|
} elseif ($workspace = current_workspace()) {
|
||||||
$this->workspaceId = $workspace->id;
|
// 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
|
public function save(): void
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Pages\Bio;
|
||||||
|
|
||||||
|
use App\Domains\Bio\Models\BioBlock;
|
||||||
|
use App\Domains\Bio\Models\BioPage;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Edit extends Component
|
||||||
|
{
|
||||||
|
public int $pageId = 0;
|
||||||
|
|
||||||
|
public string $workspaceUlid = '';
|
||||||
|
|
||||||
|
public string $publicUrlBase = '';
|
||||||
|
|
||||||
|
public string $displayUrlBase = '';
|
||||||
|
|
||||||
|
public function mount(string $bioUlid): void
|
||||||
|
{
|
||||||
|
$workspace = require_workspace();
|
||||||
|
|
||||||
|
$page = BioPage::where('ulid', $bioUlid)
|
||||||
|
->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,32 @@ class PlanFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->unique()->word(),
|
'name' => 'Free',
|
||||||
|
'tier' => 'free',
|
||||||
'monthly_price_cents' => 0,
|
'monthly_price_cents' => 0,
|
||||||
'workspace_limit' => 1,
|
'workspace_limit' => 1,
|
||||||
'member_limit' => 1,
|
'member_limit' => 1,
|
||||||
'custom_domain_limit' => 0,
|
'custom_domain_limit' => 0,
|
||||||
'link_limit' => 50,
|
'link_limit' => 50,
|
||||||
'click_limit_monthly' => 1000,
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Domains\Subscription\Models\Plan;
|
||||||
use App\Domains\Workspace\Models\Workspace;
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
@ -15,8 +16,23 @@ class WorkspaceFactory extends Factory
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => fake()->company(),
|
'name' => fake()->company(),
|
||||||
'slug' => Str::random(12),
|
'slug' => Str::slug(fake()->unique()->words(3, true)),
|
||||||
'owner_id' => User::factory(),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plans', function (Blueprint $table) {
|
||||||
|
// nullable bio_pages_limit (null = unlimited)
|
||||||
|
$table->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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -10,14 +10,93 @@ class PlanSeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$plans = [
|
$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' => 'Free',
|
||||||
['name' => 'business', 'monthly_price_cents' => 7900, 'workspace_limit' => 3, 'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000],
|
'tier' => 'free',
|
||||||
['name' => 'agency', 'monthly_price_cents' => 19900, 'workspace_limit' => 999, 'member_limit' => 999, 'custom_domain_limit' => 999, 'link_limit' => 999999, 'click_limit_monthly' => 5000000],
|
'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) {
|
foreach ($plans as $p) {
|
||||||
Plan::updateOrCreate(['name' => $plan['name']], $plan);
|
Plan::updateOrCreate(['tier' => $p['tier']], $p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->command->info('✓ 4 Plans seeded (free/pro/business/agency)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,13 @@
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-t2 mb-1.5">Slug (optional)</label>
|
<label class="block text-xs font-medium text-t2 mb-1.5">Slug (optional)</label>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-t3 text-xs shrink-0">nimu.li/bio/</span>
|
@php
|
||||||
|
$apex = config('services.hetzner.zone', 'nimu.li');
|
||||||
|
$urlPrefix = $isFreePlan
|
||||||
|
? $apex.'/b/'.$workspaceSlug.'/'
|
||||||
|
: $workspaceSlug.'.'.$apex.'/b/';
|
||||||
|
@endphp
|
||||||
|
<span class="text-t3 text-xs shrink-0">{{ $urlPrefix }}</span>
|
||||||
<input wire:model="slug" type="text" placeholder="my-bio"
|
<input wire:model="slug" type="text" placeholder="my-bio"
|
||||||
class="flex-1 px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
class="flex-1 px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,14 +1,14 @@
|
||||||
<div>
|
<div x-on:livewire:navigated.window="$wire.$refresh()">
|
||||||
@php $ws = current_workspace(); @endphp
|
|
||||||
<div class="flex items-center justify-between mb-6 nim-reveal nim-reveal-1">
|
<div class="flex items-center justify-between mb-6 nim-reveal nim-reveal-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-t1">Link-in-Bio</h1>
|
<h1 class="text-t1">Link-in-Bio</h1>
|
||||||
<p class="text-xs text-t3 mt-0.5">{{ $bioPages->total() }} pages</p>
|
<p class="text-xs text-t3 mt-0.5">{{ $bioPages->total() }} pages</p>
|
||||||
</div>
|
</div>
|
||||||
@if($ws)
|
@if($workspaceUlid)
|
||||||
<button
|
<button
|
||||||
@click="$dispatch('openModal', {component: 'modals.create-bio-page', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
|
wire:click="createNew"
|
||||||
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity flex items-center gap-1.5">
|
wire:loading.attr="disabled"
|
||||||
|
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity flex items-center gap-1.5 disabled:opacity-60">
|
||||||
<x-heroicon-o-plus class="w-4 h-4" />
|
<x-heroicon-o-plus class="w-4 h-4" />
|
||||||
New Bio Page
|
New Bio Page
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -25,7 +25,18 @@
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-sm font-semibold text-t1">{{ $title }}</div>
|
<div class="text-sm font-semibold text-t1">{{ $title }}</div>
|
||||||
<div class="text-xs text-t3 font-mono mt-0.5">nimu.li/<span class="text-blue">{{ $page->slug }}</span></div>
|
<div class="text-xs text-t3 font-mono mt-0.5">{{ $page->shortPublicUrl() }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-all duration-150">
|
||||||
|
<x-ui.copy-button :value="$page->publicUrl()" label="Copy" />
|
||||||
|
<a
|
||||||
|
href="{{ route('w.bio.edit', [$workspaceUlid, $page->ulid]) }}"
|
||||||
|
wire:navigate
|
||||||
|
class="inline-flex items-center px-2.5 py-1 bg-s2 border border-white/[.06] rounded-lg text-xs text-t2 hover:text-t1 hover:bg-s3 transition-colors">Edit</a>
|
||||||
|
<button
|
||||||
|
@click="$dispatch('openModal', {component: 'modals.delete-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
|
||||||
|
class="inline-flex items-center px-2.5 py-1 bg-s2 border border-white/[.06] rounded-lg text-xs text-t3 hover:text-red hover:border-red/30 transition-colors">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="badge {{ $page->is_published ? 'bg-green/10 text-green' : 'bg-white/[.06] text-t3' }}">
|
<span class="badge {{ $page->is_published ? 'bg-green/10 text-green' : 'bg-white/[.06] text-t3' }}">
|
||||||
|
|
@ -34,16 +45,6 @@
|
||||||
@endif
|
@endif
|
||||||
{{ $page->is_published ? 'Published' : 'Draft' }}
|
{{ $page->is_published ? 'Published' : 'Draft' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-all duration-150">
|
|
||||||
<x-ui.copy-button :value="'https://nimu.li/b/'.$page->slug" label="Copy" />
|
|
||||||
<button
|
|
||||||
@click="$dispatch('openModal', {component: 'modals.edit-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
|
|
||||||
class="text-t2 text-xs hover:text-t1 transition-colors">Edit</button>
|
|
||||||
<button
|
|
||||||
@click="$dispatch('openModal', {component: 'modals.delete-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
|
|
||||||
class="text-t3 text-xs hover:text-red transition-colors">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
<div class="p-16 text-center">
|
<div class="p-16 text-center">
|
||||||
|
|
@ -53,10 +54,11 @@
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-sm text-t2 mb-1.5">No bio pages yet</p>
|
<p class="text-sm text-t2 mb-1.5">No bio pages yet</p>
|
||||||
<p class="text-xs text-t3 mb-6">Create a link-in-bio page for Instagram, TikTok and more.</p>
|
<p class="text-xs text-t3 mb-6">Create a link-in-bio page for Instagram, TikTok and more.</p>
|
||||||
@if($ws)
|
@if($workspaceUlid)
|
||||||
<button
|
<button
|
||||||
@click="$dispatch('openModal', {component: 'modals.create-bio-page', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
|
wire:click="createNew"
|
||||||
class="px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity">
|
wire:loading.attr="disabled"
|
||||||
|
class="px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60">
|
||||||
Create first Bio Page
|
Create first Bio Page
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bio\Models\BioPage;
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
|
||||||
|
it('returns free path-url for free plan workspace', function () {
|
||||||
|
$ws = Workspace::factory()->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');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bio\Models\BioPage;
|
||||||
|
use App\Domains\Workspace\Models\Workspace;
|
||||||
|
|
||||||
|
it('serves bio at workspace-slug-path on apex domain', function () {
|
||||||
|
$ws = Workspace::factory()->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();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue