Page: Signup Glass — Volt + Pest tests + CSS additions

- pages/auth/register: full re-port of CluPilot Signup Glass.html
  with 3-step stepper, SSO, name+email+workspace+password form,
  live slug preview, password meter (4 rules), terms checkbox,
  right-pane trial card + 4-benefit list + customer quote + logo strip.
- CSS additions: clu-stepper, clu-field-row, clu-input-suffix,
  clu-slug-preview, clu-pw-meter, clu-pw-rules, clu-trial-*,
  clu-benefit-*, clu-quote-*, clu-avatar, clu-logos.
- tests/Feature/Auth/SignupPageTest.php: 6 tests, all green
  (renders chrome, slug preview, empty-form rejects, weak password,
   creates user + logs in, duplicate email).
master
Boban Blaskovic 2026-05-31 22:22:32 +02:00
parent 5287b0024d
commit 78edcef3da
3 changed files with 598 additions and 268 deletions

View File

@ -403,4 +403,228 @@
.clu-pill.danger { color: var(--color-danger); background: var(--color-danger-soft); border-color: rgba(219,59,59,0.28); }
.clu-pill.accent { color: var(--color-accent); background: var(--color-accent-soft); border-color: rgba(59,111,242,0.28); }
.clu-pill.muted { color: var(--color-muted); background: rgba(0,0,0,0.04); border-color: var(--color-hairline); }
/* ───── Signup-specific ───── */
/* Stepper */
.clu-stepper {
display: flex; align-items: center; gap: 8px;
margin-top: 24px;
font-family: var(--font-mono);
font-size: 11px;
}
.clu-step { display: flex; align-items: center; gap: 8px; }
.clu-step-num {
width: 22px; height: 22px;
border-radius: 50%;
border: 1px solid var(--color-hairline);
background: rgba(255,255,255,0.6);
display: grid; place-items: center;
font-size: 11px; font-weight: 600;
color: var(--color-muted-2);
transition: all .15s;
}
.clu-step.active .clu-step-num { background: var(--color-accent); border-color: var(--color-accent); color: #fff; box-shadow: 0 4px 12px -4px rgba(59,111,242,0.6); }
.clu-step.done .clu-step-num { background: var(--color-success); border-color: var(--color-success); color: #fff; }
.clu-step-label {
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.clu-step.active .clu-step-label { color: var(--color-fg); }
.clu-step-line { flex: 1; height: 1px; background: var(--color-hairline); min-width: 14px; max-width: 28px; }
/* Input variants */
.clu-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.clu-hint {
font-size: 10.5px; font-weight: 500;
color: var(--color-muted-2);
font-family: var(--font-mono);
text-transform: none;
letter-spacing: 0;
}
.clu-input.with-suffix { padding-right: 0; }
.clu-input-suffix {
height: 100%;
display: flex; align-items: center;
padding: 0 13px;
border-left: 1px solid var(--color-hairline);
color: var(--color-muted-2);
font-family: var(--font-mono);
font-size: 11.5px;
background: rgba(255,255,255,0.35);
border-top-right-radius: var(--radius-xs);
border-bottom-right-radius: var(--radius-xs);
}
.clu-input-check { color: var(--color-success); display: none; flex: none; }
.clu-input.valid .clu-input-check { display: inline-flex; }
/* Slug preview */
.clu-slug-preview {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-muted);
margin-top: 4px;
}
.clu-slug-preview b { color: var(--color-accent); font-weight: 600; }
/* Password meter + rules */
.clu-pw-meter {
display: flex; gap: 4px;
margin-top: 6px;
}
.clu-pw-meter span {
flex: 1; height: 3px;
border-radius: 2px;
background: rgba(30,35,60,0.10);
transition: background .2s;
}
.clu-pw-meter.s1 span:nth-child(-n+1) { background: var(--color-danger); }
.clu-pw-meter.s2 span:nth-child(-n+2) { background: var(--color-warning); }
.clu-pw-meter.s3 span:nth-child(-n+3) { background: var(--color-accent); }
.clu-pw-meter.s4 span { background: var(--color-success); }
.clu-pw-rules {
display: flex; flex-wrap: wrap; gap: 6px 12px;
margin-top: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--color-muted);
}
.clu-pw-rule { display: inline-flex; align-items: center; gap: 5px; }
.clu-pw-rule .clu-pw-bullet {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--color-muted-2);
transition: background .15s;
}
.clu-pw-rule.ok { color: var(--color-success); }
.clu-pw-rule.ok .clu-pw-bullet { background: var(--color-success); }
/* Trial card */
.clu-trial-card {
background: var(--color-glass);
border: 1px solid var(--color-glass-border);
border-radius: var(--radius-sm);
padding: 18px 20px;
position: relative;
overflow: hidden;
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.clu-trial-card::before {
content: "";
position: absolute; top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2));
}
.clu-trial-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.clu-trial-tag {
font-family: var(--font-mono);
font-size: 10.5px; font-weight: 600;
color: var(--color-accent);
background: var(--color-accent-soft);
padding: 3px 9px;
border-radius: 5px;
border: 1px solid rgba(59,111,242,0.25);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.clu-trial-count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-muted);
display: inline-flex; align-items: center; gap: 6px;
}
.clu-trial-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.015em;
line-height: 1.25;
margin-top: 12px;
}
.clu-trial-title em {
font-style: normal;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
}
.clu-trial-sub {
color: var(--color-muted);
font-size: 13px;
margin-top: 6px;
}
/* Benefit list */
.clu-benefit-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
.clu-benefit { display: flex; gap: 12px; align-items: flex-start; }
.clu-benefit-ico {
width: 32px; height: 32px;
border-radius: 9px;
background: var(--color-accent-soft);
border: 1px solid rgba(59,111,242,0.22);
color: var(--color-accent);
display: grid; place-items: center;
flex: none;
}
.clu-benefit-ico.success { background: var(--color-success-soft); border-color: rgba(21,160,106,0.22); color: var(--color-success); }
.clu-benefit-ico.purple { background: rgba(124,92,255,0.12); border-color: rgba(124,92,255,0.25); color: var(--color-accent-2); }
.clu-benefit-ico.warn { background: var(--color-warning-soft); border-color: rgba(194,130,15,0.22); color: var(--color-warning); }
.clu-benefit-title { font-size: 13.5px; font-weight: 600; color: var(--color-fg); }
.clu-benefit-desc { font-size: 12.5px; color: var(--color-muted); margin-top: 2px; line-height: 1.5; }
/* Quote card */
.clu-quote-card {
background: var(--color-glass);
border: 1px solid var(--color-glass-border);
border-radius: var(--radius-sm);
padding: 18px 20px;
margin-top: auto;
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.clu-quote-text { font-size: 13.5px; color: var(--color-fg); line-height: 1.55; }
.clu-quote-text::before { content: "„"; color: var(--color-accent); font-size: 24px; line-height: 0; margin-right: 4px; vertical-align: -6px; font-weight: 600; }
.clu-quote-text::after { content: """; color: var(--color-accent); font-weight: 600; }
.clu-quote-author { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.clu-avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-success), #2a9d6f);
display: grid; place-items: center;
color: #fff; font-weight: 700; font-size: 12px;
font-family: var(--font-mono);
flex: none;
}
.clu-author-name { font-size: 12.5px; color: var(--color-fg); font-weight: 600; }
.clu-author-role { font-size: 11px; color: var(--color-muted); font-family: var(--font-mono); }
/* Logo strip */
.clu-logos {
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.clu-logo-pill {
padding: 5px 11px;
border: 1px solid var(--color-glass-border);
border-radius: 7px;
background: var(--color-glass);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
/* Top-link helper */
.clu-top-link {
display: flex; align-items: center; gap: 6px;
color: var(--color-muted);
font-size: 12.5px;
}
.clu-top-link a { color: var(--color-accent); font-weight: 600; }
}

View File

@ -4,6 +4,7 @@ use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
@ -17,6 +18,32 @@ new #[Layout('layouts.login')] class extends Component
public string $password = '';
public bool $terms = false;
public function getSlugProperty(): string
{
return Str::slug($this->workspace) ?: 'dein-team';
}
public function getEmailValidProperty(): bool
{
return (bool) preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $this->email);
}
public function getPwChecksProperty(): array
{
$v = $this->password;
return [
'len' => mb_strlen($v) >= 10,
'upper' => (bool) preg_match('/[A-Z]/', $v),
'num' => (bool) preg_match('/[0-9]/', $v),
'sym' => (bool) preg_match('/[^A-Za-z0-9]/', $v),
];
}
public function getPwScoreProperty(): int
{
return count(array_filter($this->pwChecks));
}
public function register(): void
{
$this->validate([
@ -41,54 +68,53 @@ new #[Layout('layouts.login')] class extends Component
}
}; ?>
<div class="clu-login-grid">
<div class="clu-auth-grid">
{{-- ===== LEFT: FORM ===== --}}
<section class="clu-form-side flex flex-col min-h-screen">
{{-- ============ LEFT: FORM ============ --}}
<section class="clu-glass flex flex-col" style="padding:30px 44px;min-height:calc(100vh - 28px);">
{{-- Top row --}}
<div class="flex items-center justify-between gap-4 flex-wrap gap-y-2">
<div class="flex items-center gap-[10px] shrink-0">
<div class="clu-brand-mark w-[30px] h-[30px] rounded-[8px]" aria-hidden="true"></div>
<div class="flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center gap-[10px]">
<div class="clu-brand-mark" aria-hidden="true"></div>
<div class="font-semibold text-[15px] tracking-[-0.01em]">
CluPilot<span class="text-t2 font-medium ml-[5px] text-[11.5px]">v2.4</span>
CluPilot<span class="text-(--color-muted-2) font-medium ml-[5px] text-[11.5px]">v2.4</span>
</div>
</div>
<div class="clu-login-status text-[12.5px] text-t2 shrink-0">
<div class="clu-top-link">
Schon Konto?
<a href="{{ route('login') }}" wire:navigate class="ml-[6px] font-medium text-akzent hover:underline">Anmelden </a>
<a href="{{ route('login') }}" wire:navigate>Anmelden </a>
</div>
</div>
{{-- Stepper --}}
<nav class="clu-stepper" aria-label="Registrierungs-Schritte">
<div class="clu-stepper" aria-label="Registrierungs-Schritte">
<div class="clu-step active">
<div class="clu-step-num" aria-current="step">1</div>
<div class="clu-step-num">1</div>
<div class="clu-step-label">Konto</div>
</div>
<div class="clu-step-line" role="separator"></div>
<div class="clu-step-line"></div>
<div class="clu-step">
<div class="clu-step-num">2</div>
<div class="clu-step-label">Workspace</div>
</div>
<div class="clu-step-line" role="separator"></div>
<div class="clu-step-line"></div>
<div class="clu-step">
<div class="clu-step-num">3</div>
<div class="clu-step-label">Erster Server</div>
</div>
</nav>
</div>
{{-- Form wrap --}}
<div class="my-auto flex flex-col gap-[22px] w-full py-6 sm:py-10 mx-auto" style="max-width:420px;">
<div class="my-auto flex flex-col gap-6 w-full" style="max-width:380px;padding:32px 0;">
<header>
<h1 class="m-0 text-[24px] font-semibold tracking-[-0.02em] leading-[1.2]">14 Tage kostenlos testen</h1>
<p class="mt-[6px] text-[13.5px] text-t2">Keine Kreditkarte. Volle Funktionen. Jederzeit kündbar.</p>
<h1 class="m-0 text-[25px] font-semibold tracking-[-0.02em] leading-[1.2]">14 Tage kostenlos testen</h1>
<p class="mt-[6px] text-[13.5px] text-(--color-muted)">Keine Kreditkarte. Volle Funktionen. Jederzeit kündbar.</p>
</header>
{{-- SSO --}}
<div class="clu-sso-row">
<button class="clu-sso-btn" type="button">
<div class="grid grid-cols-2 gap-[10px]">
<button type="button" class="clu-sso-btn">
<svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.5 12.27c0-.79-.07-1.54-.2-2.27H12v4.51h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.32z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.99.66-2.25 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z"/>
@ -97,7 +123,7 @@ new #[Layout('layouts.login')] class extends Component
</svg>
Mit Google fortfahren
</button>
<button class="clu-sso-btn" type="button">
<button type="button" class="clu-sso-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.87-1.36-3.87-1.36-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.69 1.24 3.35.95.1-.74.4-1.24.73-1.53-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.28 1.18-3.09-.12-.29-.51-1.46.11-3.05 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.20-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.12 3.05.74.81 1.18 1.83 1.18 3.09 0 4.42-2.69 5.40-5.25 5.68.41.36.78 1.06.78 2.14v3.18c0 .31.21.67.79.55C20.21 21.39 23.5 17.08 23.5 12c0-6.35-5.15-11.5-11.5-11.5z"/>
</svg>
@ -107,23 +133,22 @@ new #[Layout('layouts.login')] class extends Component
<div class="clu-divider">oder mit E-Mail</div>
{{-- Form --}}
<form wire:submit="register" class="flex flex-col gap-[14px]">
<form wire:submit="register" class="flex flex-col gap-[14px]" autocomplete="on" x-data="{ showPwd: false }">
{{-- First / Last name --}}
{{-- First + Last name --}}
<div class="clu-field-row">
<div class="flex flex-col gap-[6px]">
<label for="firstname" class="text-[11.5px] text-t2 font-medium">Vorname</label>
<label for="firstname" class="text-[11.5px] text-(--color-muted) font-semibold">Vorname</label>
<div class="clu-input">
<input wire:model="firstname" id="firstname" name="firstname" type="text"
placeholder="Marie" autocomplete="given-name" required autofocus />
<input wire:model.live="firstname" id="firstname" name="firstname" type="text"
placeholder="Marie" autocomplete="given-name" required />
</div>
<x-input-error :messages="$errors->get('firstname')" class="mt-1" />
</div>
<div class="flex flex-col gap-[6px]">
<label for="lastname" class="text-[11.5px] text-t2 font-medium">Nachname</label>
<label for="lastname" class="text-[11.5px] text-(--color-muted) font-semibold">Nachname</label>
<div class="clu-input">
<input wire:model="lastname" id="lastname" name="lastname" type="text"
<input wire:model.live="lastname" id="lastname" name="lastname" type="text"
placeholder="Weber" autocomplete="family-name" required />
</div>
<x-input-error :messages="$errors->get('lastname')" class="mt-1" />
@ -132,12 +157,12 @@ new #[Layout('layouts.login')] class extends Component
{{-- Email --}}
<div class="flex flex-col gap-[6px]">
<label for="reg-email" class="text-[11.5px] text-t2 font-medium">Arbeits-E-Mail</label>
<div class="clu-input" id="reg-email-wrap">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-dim flex-none" aria-hidden="true">
<label for="email" class="text-[11.5px] text-(--color-muted) font-semibold">Arbeits-E-Mail</label>
<div class="clu-input @if($this->emailValid) valid @endif">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>
</svg>
<input wire:model="email" id="reg-email" name="email" type="email"
<input wire:model.live="email" id="email" name="email" type="email"
placeholder="du@firma.de" autocomplete="email" required />
<svg class="clu-input-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M5 12l5 5L20 7"/>
@ -148,63 +173,67 @@ new #[Layout('layouts.login')] class extends Component
{{-- Workspace --}}
<div class="flex flex-col gap-[6px]">
<label for="reg-workspace" class="flex items-center justify-between text-[11.5px] text-t2 font-medium">
<label for="workspace" class="flex items-center justify-between text-[11.5px] text-(--color-muted) font-semibold">
Workspace-Name
<span class="font-normal font-mono text-[10.5px] text-dim">URL für dein Team</span>
<span class="clu-hint">URL für dein Team</span>
</label>
<div class="clu-input with-suffix">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-dim flex-none" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/>
<circle cx="7" cy="7.5" r="0.7" fill="currentColor"/><circle cx="7" cy="16.5" r="0.7" fill="currentColor"/>
</svg>
<input wire:model="workspace" id="reg-workspace" name="workspace" type="text"
<input wire:model.live="workspace" id="workspace" name="workspace" type="text"
placeholder="Acme Cluster" required />
<span class="clu-input-suffix">.clupilot.io</span>
</div>
<div class="clu-slug-preview">Erreichbar unter <b id="reg-slug-preview">dein-team</b>.clupilot.io</div>
<div class="clu-slug-preview">Erreichbar unter <b>{{ $this->slug }}</b>.clupilot.io</div>
<x-input-error :messages="$errors->get('workspace')" class="mt-1" />
</div>
{{-- Password --}}
<div class="flex flex-col gap-[6px]">
<label for="reg-password" class="flex items-center justify-between text-[11.5px] text-t2 font-medium">
<label for="password" class="flex items-center justify-between text-[11.5px] text-(--color-muted) font-semibold">
Passwort
<span class="font-normal font-mono text-[10.5px] text-dim">min. 10 Zeichen</span>
<span class="clu-hint">min. 10 Zeichen</span>
</label>
<div class="clu-input">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-dim flex-none" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>
</svg>
<input wire:model="password" id="reg-password" name="password" type="password"
<input wire:model.live="password" id="password" name="password"
:type="showPwd ? 'text' : 'password'"
placeholder="••••••••••••" autocomplete="new-password" required />
<button type="button" id="reg-toggle-eye" aria-label="Passwort anzeigen"
class="text-t2 p-[4px] rounded-[4px] grid place-items-center transition-colors hover:text-t1 hover:bg-surface2">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button type="button" class="clu-eye" @click="showPwd = !showPwd"
:aria-label="showPwd ? 'Passwort verbergen' : 'Passwort anzeigen'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<div class="clu-pw-meter" id="reg-pw-meter"><span></span><span></span><span></span><span></span></div>
<div class="clu-pw-meter @if(strlen($password)) s{{ $this->pwScore }} @endif"><span></span><span></span><span></span><span></span></div>
<div class="clu-pw-rules">
<span class="clu-pw-rule" data-rule="len"><span class="clu-pw-bullet"></span>10+ Zeichen</span>
<span class="clu-pw-rule" data-rule="upper"><span class="clu-pw-bullet"></span>Großbuchstabe</span>
<span class="clu-pw-rule" data-rule="num"><span class="clu-pw-bullet"></span>Zahl</span>
<span class="clu-pw-rule" data-rule="sym"><span class="clu-pw-bullet"></span>Sonderzeichen</span>
<span class="clu-pw-rule @if($this->pwChecks['len']) ok @endif"><span class="clu-pw-bullet"></span>10+ Zeichen</span>
<span class="clu-pw-rule @if($this->pwChecks['upper']) ok @endif"><span class="clu-pw-bullet"></span>Großbuchstabe</span>
<span class="clu-pw-rule @if($this->pwChecks['num']) ok @endif"><span class="clu-pw-bullet"></span>Zahl</span>
<span class="clu-pw-rule @if($this->pwChecks['sym']) ok @endif"><span class="clu-pw-bullet"></span>Sonderzeichen</span>
</div>
<x-input-error :messages="$errors->get('password')" class="mt-1" />
</div>
{{-- Terms --}}
<label class="clu-checkbox">
<input wire:model="terms" id="reg-terms" type="checkbox" class="sr-only" />
<span class="clu-check-box">
<label class="clu-checkbox" style="align-items:flex-start;">
<input wire:model="terms" type="checkbox" required />
<span class="box" style="margin-top:2px;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12l5 5L20 7"/>
</svg>
</span>
<span>Ich akzeptiere die <a href="#" class="text-akzent font-medium hover:underline">AGB</a> und stimme der <a href="#" class="text-akzent font-medium hover:underline">Datenschutzerklärung</a> zu. CluPilot speichert SSH-Keys verschlüsselt in der EU.</span>
<span class="leading-[1.45]">
Ich akzeptiere die <a href="#">AGB</a> und stimme der <a href="#">Datenschutzerklärung</a> zu.
CluPilot speichert SSH-Keys verschlüsselt in der EU.
</span>
</label>
<x-input-error :messages="$errors->get('terms')" class="mt-[-6px]" />
<x-input-error :messages="$errors->get('terms')" class="-mt-2" />
{{-- Submit --}}
<button class="clu-btn-primary" type="submit" wire:loading.attr="disabled">
@ -217,12 +246,11 @@ new #[Layout('layouts.login')] class extends Component
<path d="M5 12h14M13 5l7 7-7 7"/>
</svg>
</button>
</form>
</div>
{{-- Footer --}}
<footer class="flex items-center justify-between gap-3 text-t2 text-[11.5px] font-mono pt-3 border-t border-white/6 flex-wrap">
<footer class="flex items-center justify-between gap-3 text-(--color-muted) text-[11.5px] font-mono pt-[14px] border-t border-(--color-hairline) flex-wrap">
<div class="flex items-center gap-[6px]">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>
@ -230,37 +258,37 @@ new #[Layout('layouts.login')] class extends Component
TLS 1.3 · AES-256 · region eu-central-1
</div>
<div class="flex gap-[14px]">
<a href="#" class="text-t2 hover:text-t1 no-underline transition-colors">Datenschutz</a>
<a href="#" class="text-t2 hover:text-t1 no-underline transition-colors">AGB</a>
<a href="#" class="text-t2 hover:text-t1 no-underline transition-colors">Status</a>
<a href="#" class="text-t2 hover:text-t1 no-underline transition-colors">Hilfe</a>
<a href="#" class="text-(--color-muted) hover:text-(--color-fg) no-underline">Datenschutz</a>
<a href="#" class="text-(--color-muted) hover:text-(--color-fg) no-underline">AGB</a>
<a href="#" class="text-(--color-muted) hover:text-(--color-fg) no-underline">Status</a>
<a href="#" class="text-(--color-muted) hover:text-(--color-fg) no-underline">Hilfe</a>
</div>
</footer>
</section>
{{-- ===== RIGHT: BENEFITS PANEL ===== --}}
<aside class="clu-live-side relative border-l border-white/6 overflow-hidden flex flex-col gap-[22px] min-h-screen"
style="background:linear-gradient(180deg,#0c0c12 0%,#0a0a0f 100%);padding:36px 40px;"
aria-hidden="true">
{{-- ============ RIGHT: TRIAL / BENEFITS ============ --}}
<aside class="clu-glass clu-auth-side-right flex flex-col gap-[18px]"
style="padding:30px 34px;min-height:calc(100vh - 28px);" aria-hidden="true">
<div class="clu-trial-card">
<div class="clu-trial-row">
<span class="clu-trial-tag">14 Tage Pro · gratis</span>
<span class="clu-trial-count">
<span class="clu-pulse-dot w-[6px] h-[6px] rounded-full bg-success flex-none"></span>
<span class="clu-pulse-dot text-(--color-success)"></span>
47 Teams diese Woche
</span>
</div>
<div class="clu-trial-title">In <em>60 Sekunden</em> deinen ersten Server verbunden.</div>
<div class="clu-trial-sub">SSH-Schlüssel hochladen, Site importieren, fertig. Wir kümmern uns ums Monitoring, Backups und Updates du um dein Business.</div>
<div class="clu-trial-sub">
SSH-Schlüssel hochladen, Site importieren, fertig. Wir kümmern uns ums Monitoring,
Backups und Updates du um dein Business.
</div>
</div>
<ul class="clu-benefit-list">
<li class="clu-benefit">
<div class="clu-benefit-ico" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17l5-5-5-5M13 19h7"/>
</svg>
<div class="clu-benefit-ico">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 17l5-5-5-5M13 19h7"/></svg>
</div>
<div>
<div class="clu-benefit-title">SSH-First Setup</div>
@ -268,10 +296,8 @@ new #[Layout('layouts.login')] class extends Component
</div>
</li>
<li class="clu-benefit">
<div class="clu-benefit-ico success" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/><path d="M9 11l2 2 4-4"/>
</svg>
<div class="clu-benefit-ico success">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/><path d="M9 11l2 2 4-4"/></svg>
</div>
<div>
<div class="clu-benefit-title">Stündliche Backups inklusive</div>
@ -279,10 +305,8 @@ new #[Layout('layouts.login')] class extends Component
</div>
</li>
<li class="clu-benefit">
<div class="clu-benefit-ico purple" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5"/>
</svg>
<div class="clu-benefit-ico purple">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5"/></svg>
</div>
<div>
<div class="clu-benefit-title">Bulk-Updates ohne Drama</div>
@ -290,10 +314,8 @@ new #[Layout('layouts.login')] class extends Component
</div>
</li>
<li class="clu-benefit">
<div class="clu-benefit-ico warn" aria-hidden="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/><path d="M12 8v5M12 16h.01"/>
</svg>
<div class="clu-benefit-ico warn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/><path d="M12 8v5M12 16h.01"/></svg>
</div>
<div>
<div class="clu-benefit-title">Brute-Force-Defense aktiv</div>
@ -303,7 +325,10 @@ new #[Layout('layouts.login')] class extends Component
</ul>
<div class="clu-quote-card">
<div class="clu-quote-text">Statt 18 SSH-Sessions in Terminal-Tabs habe ich jetzt eine Konsole. Mein Agency-Team hostet 87 Sites und schläft endlich wieder durch.</div>
<div class="clu-quote-text">
Statt 18 SSH-Sessions in Terminal-Tabs habe ich jetzt eine Konsole.
Mein Agency-Team hostet 87 Sites und schläft endlich wieder durch.
</div>
<div class="clu-quote-author">
<div class="clu-avatar">JK</div>
<div>
@ -321,7 +346,5 @@ new #[Layout('layouts.login')] class extends Component
<span class="clu-logo-pill">bergmann</span>
<span class="clu-logo-pill">+ 2.1k</span>
</div>
</aside>
</div>

View File

@ -0,0 +1,83 @@
<?php
use App\Models\User;
use function Pest\Laravel\get;
use function Pest\Livewire\livewire;
it('renders the signup page with template chrome', function () {
$response = get(route('register'));
$response->assertStatus(200)
->assertSeeText('14 Tage kostenlos testen')
->assertSeeText('Keine Kreditkarte')
->assertSeeText('Mit Google fortfahren')
->assertSeeText('Mit GitHub fortfahren')
->assertSeeText('Workspace-Name')
->assertSeeText('SSH-First Setup')
->assertSeeText('Stündliche Backups inklusive')
->assertSeeText('Brute-Force-Defense aktiv')
->assertSee('clu-stepper', false)
->assertSee('clu-pw-meter', false)
->assertSee('clu-trial-card', false)
->assertSee('clu-benefit-list', false);
});
it('previews a workspace slug', function () {
livewire('pages.auth.register')
->set('workspace', 'Acme Cluster GmbH')
->assertSeeText('acme-cluster-gmbh');
});
it('rejects an empty form', function () {
livewire('pages.auth.register')
->set('firstname', '')
->set('lastname', '')
->set('email', '')
->set('workspace', '')
->set('password', '')
->set('terms', false)
->call('register')
->assertHasErrors(['firstname', 'lastname', 'email', 'workspace', 'password', 'terms']);
});
it('rejects passwords shorter than 10 chars', function () {
livewire('pages.auth.register')
->set('firstname', 'Marie')
->set('lastname', 'Weber')
->set('email', 'marie@example.test')
->set('workspace', 'Acme')
->set('password', 'short')
->set('terms', true)
->call('register')
->assertHasErrors('password');
});
it('creates a user and logs them in', function () {
livewire('pages.auth.register')
->set('firstname', 'Marie')
->set('lastname', 'Weber')
->set('email', 'marie@example.test')
->set('workspace', 'Acme Cluster')
->set('password', 'longenoughpw!1')
->set('terms', true)
->call('register')
->assertHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
expect(User::where('email', 'marie@example.test')->exists())->toBeTrue();
expect(auth()->check())->toBeTrue();
});
it('rejects a duplicate email', function () {
User::factory()->create(['email' => 'taken@example.test']);
livewire('pages.auth.register')
->set('firstname', 'Marie')
->set('lastname', 'Weber')
->set('email', 'taken@example.test')
->set('workspace', 'Acme')
->set('password', 'longenoughpw!1')
->set('terms', true)
->call('register')
->assertHasErrors('email');
});