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 --}}
{{-- 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.
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 --}}
{{-- == 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.