@vite(['resources/js/blade.js'])
any() && session('otp_verified_email') === old('email'); @endphp /* ---- Step state ---- */ step: {{ session('registered') ? 4 : ($otpStillVerified ? 3 : 1) }}, /* 1=email, 2=otp, 3=gym-details, 4=done */ /* ---- Owner info ---- */ ownerName: '{{ old('name','') }}', email: '{{ old('email','') }}', phone: '{{ old('phone','') }}', password: '', passwordConfirm: '', showPassword: false, showPasswordConfirm: false, /* ---- OTP ---- */ otpState: '{{ $otpStillVerified ? "verified" : "idle" }}', /* idle | sending | sent | verifying | verified */ otp: '', otpError: '', otpTimer: 0, _otpInterval: null, /* ---- Gym details ---- */ gymName: '{{ old('gym_name','') }}', subdomain: '{{ old('subdomain','') }}', subdomainManual: false, subdomainStatus: null, /* null | checking | available | taken */ _subdomainTimer: null, plan: '{{ old('plan','trial') }}', /* ---- Coupon ---- */ couponCode: '', couponResult: null, couponError: '', couponLoading: false, /* ---- Computed ---- */ get nameValid() { return /^[\p{L}\s'\-.]+$/u.test(this.ownerName.trim()) && this.ownerName.trim().length >= 2; }, get canSendOtp() { return this.nameValid && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email); }, get otpVerified() { return this.otpState === 'verified'; }, /* ---- Methods ---- */ slugify(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); }, watchGymName(val) { if (!this.subdomainManual) { this.subdomain = this.slugify(val); this.triggerSubdomainCheck(); } }, watchSubdomain(val) { this.subdomainManual = true; this.triggerSubdomainCheck(); }, triggerSubdomainCheck() { clearTimeout(this._subdomainTimer); if (!this.subdomain) { this.subdomainStatus = null; return; } this.subdomainStatus = 'checking'; this._subdomainTimer = setTimeout(() => this.checkSubdomain(), 600); }, checkSubdomain() { if (!this.subdomain) return; fetch('{{ route('onboard.check-subdomain') }}?subdomain=' + encodeURIComponent(this.subdomain), { headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' } }).then(r => r.json()).then(d => { this.subdomainStatus = d.available ? 'available' : 'taken'; }).catch(() => { this.subdomainStatus = null; }); }, sendOtp() { if (!this.canSendOtp || this.otpState === 'sending') return; // The resend cooldown only applies while sitting on the OTP step — // going back to change the email must always allow an immediate send. if (this.step === 2 && this.otpTimer > 0) return; this.otpState = 'sending'; this.otpError = ''; fetch('{{ route('onboard.send-otp') }}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' }, body: JSON.stringify({ name: this.ownerName, email: this.email }) }).then(r => r.json()).then(d => { if (d.sent) { this.otpState = 'sent'; this.step = 2; this.startOtpTimer(60); } else { this.otpState = 'idle'; this.otpError = d.message || 'Could not send OTP. Try again.'; } }).catch(() => { this.otpState = 'idle'; this.otpError = 'Network error. Please try again.'; }); }, startOtpTimer(secs) { this.otpTimer = secs; clearInterval(this._otpInterval); this._otpInterval = setInterval(() => { if (this.otpTimer > 0) { this.otpTimer--; } else { clearInterval(this._otpInterval); } }, 1000); }, // Going back to change the email should let the user request a fresh // OTP immediately — otherwise a leftover countdown from the previous // send silently blocks Continue with Email OTP until it expires. backToEmailStep() { this.step = 1; this.otpState = 'idle'; this.otpError = ''; this.otp = ''; this.otpTimer = 0; clearInterval(this._otpInterval); }, verifyOtp() { if (this.otp.length < 4 || this.otpState === 'verifying') return; this.otpState = 'verifying'; this.otpError = ''; fetch('{{ route('onboard.verify-otp') }}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' }, body: JSON.stringify({ email: this.email, otp: this.otp }) }).then(r => r.json()).then(d => { if (d.verified) { this.otpState = 'verified'; this.step = 3; } else { this.otpState = 'sent'; this.otpError = d.message || 'Invalid OTP. Please try again.'; } }).catch(() => { this.otpState = 'sent'; this.otpError = 'Network error. Please try again.'; }); }, validateCoupon() { if (!this.couponCode.trim() || this.couponLoading) return; this.couponLoading = true; this.couponResult = null; this.couponError = ''; fetch('{{ route('onboard.validate-coupon') }}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content, 'Accept': 'application/json' }, body: JSON.stringify({ code: this.couponCode, plan: this.plan }) }).then(r => r.json()).then(d => { this.couponLoading = false; if (d.valid) { this.couponResult = d; } else { this.couponError = d.message || 'Invalid or expired coupon code.'; } }).catch(() => { this.couponLoading = false; this.couponError = 'Network error. Please try again.'; }); }, get canSubmit() { return this.otpVerified && this.gymName.trim().length >= 2 && this.subdomain.length >= 2 && this.subdomainStatus === 'available' && this.password.length >= 8 && this.password === this.passwordConfirm; }, get submitBlockedReason() { if (!this.otpVerified) return 'Please verify your email with the OTP first.'; if (this.gymName.trim().length < 2) return 'Enter your gym name.'; if (this.subdomain.length < 2) return 'Choose a subdomain for your gym.'; if (this.subdomainStatus === 'checking') return 'Checking subdomain availability…'; if (this.subdomainStatus === 'taken') return 'That subdomain is already taken — try another.'; if (this.subdomainStatus !== 'available') return 'Choose an available subdomain.'; if (this.password.length < 8) return 'Password must be at least 8 characters.'; // Stay silent until the user has actually typed in Confirm Password — // the submit button remains disabled via canSubmit regardless. if (this.passwordConfirm.length === 0) return ''; if (this.password !== this.passwordConfirm) return 'Password and confirmation do not match.'; return ''; }, init() { if (this.step === 3 && this.subdomain) { this.subdomainManual = true; this.checkSubdomain(); } // Browser back/forward can restore this page from bfcache with inputs // still showing typed text, but no input events fire — so Alpine's // reactive state (and anything computed from it, like canSendOtp) // goes stale even though the fields look filled. Re-sync from the // actual DOM values whenever that happens. window.addEventListener('pageshow', (e) => { if (!e.persisted) return; this.ownerName = this.$refs.ownerNameInput?.value ?? this.ownerName; this.email = this.$refs.emailInput?.value ?? this.email; this.phone = this.$refs.phoneInput?.value ?? this.phone; this.gymName = this.$refs.gymNameInput?.value ?? this.gymName; const restoredSubdomain = this.$refs.subdomainInput?.value ?? this.subdomain; if (restoredSubdomain !== this.subdomain) { this.subdomain = restoredSubdomain; this.subdomainManual = true; this.checkSubdomain(); } }); } }"> {{-- Card --}}
{{-- Logo --}}
G
GymFlow
{{-- Progress dots --}}
@foreach([1,2,3] as $s)
@endforeach
{{-- Server-side validation errors (for non-JS fallback submit) --}} @if($errors->any())
    @foreach($errors->all() as $error)
  • • {{ $error }}
  • @endforeach
@endif {{-- == STEP 1: Owner info + send OTP == --}}

Create Your Gym

Let's get your gym set up on GymFlow.

Full name must be at least 2 characters and may only contain letters, spaces, hyphens, apostrophes and dots.
@error('name')

{{ $message }}

@enderror

Already have an account? Sign in

{{-- == STEP 2: OTP verification == --}}

Verify Your Email

We sent a 6-digit code to

Resend code in
{{-- == STEP 3: Gym details + password == --}}

Set Up Your Gym

Just a few more details and you're ready to go.

{{-- The actual form submits here --}}
@csrf {{-- Pass verified data as hidden fields --}}
{{-- Gym name --}}
@error('gym_name')

{{ $message }}

@enderror
{{-- Subdomain --}}
.gymflow.app
Checking availability… ✓ Available ✗ Already taken — try another
@error('subdomain')

{{ $message }}

@enderror
{{-- Plan --}}
{{-- Coupon --}}
{{-- Password --}}
@error('password')

{{ $message }}

@enderror
Passwords don't match.
{{-- Submit --}}

By creating an account you agree to our Terms of Service.
14-day free trial — no credit card required.

{{-- == STEP 4: Success == --}}

Welcome to GymFlow, {{ session('gym_name') }}! 🎉

Your gym account has been created and is ready to go.

Subdomain {{ session('subdomain') }}.{{ config('tenancy.central_domain') }}
Trial Start {{ \Illuminate\Support\Carbon::parse(session('plan_start'))->format('d M Y') }}
Trial Ends @if(session('plan_end')) {{ \Illuminate\Support\Carbon::parse(session('plan_end'))->format('d M Y') }} @else — @endif
Log In to Your Gym

A confirmation has been sent to your email. You can log in anytime at your gym's subdomain.