started on the 2 step register process

This commit is contained in:
2025-10-04 18:52:44 +03:00
parent d1bc362fab
commit 76cfd9fee6
6 changed files with 366 additions and 39 deletions
@@ -33,12 +33,31 @@ class RegisteredUserController extends Controller
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
// Fitness profile (all optional to avoid breaking tests unless specified)
'age' => ['nullable', 'integer', 'min:5', 'max:120'],
'weight_kg' => ['nullable', 'numeric', 'min:20', 'max:400'],
'height_cm' => ['nullable', 'numeric', 'min:80', 'max:250'],
'fitness_level' => ['nullable', 'in:beginner,intermediate,advanced'],
'goal' => ['nullable', 'string', 'max:50'],
'health_notes' => ['nullable', 'string', 'max:65535'],
'available_equipment' => ['nullable', 'string', 'max:65535'],
'days_per_week' => ['nullable', 'integer', 'min:1', 'max:7'],
'minutes_per_session' => ['nullable', 'integer', 'min:5', 'max:600'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'age' => $request->age,
'weight_kg' => $request->weight_kg,
'height_cm' => $request->height_cm,
'fitness_level' => $request->fitness_level,
'goal' => $request->goal,
'health_notes' => $request->health_notes,
'available_equipment' => $request->available_equipment,
'days_per_week' => $request->days_per_week,
'minutes_per_session' => $request->minutes_per_session,
]);
event(new Registered($user));
+14
View File
@@ -21,6 +21,15 @@ class User extends Authenticatable
'name',
'email',
'password',
'age',
'weight_kg',
'height_cm',
'fitness_level',
'goal',
'health_notes',
'available_equipment',
'days_per_week',
'minutes_per_session',
];
/**
@@ -43,6 +52,11 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'age' => 'integer',
'weight_kg' => 'decimal:2',
'height_cm' => 'decimal:2',
'days_per_week' => 'integer',
'minutes_per_session' => 'integer',
];
}
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedTinyInteger('age')->nullable()->after('email_verified_at');
$table->decimal('weight_kg', 5, 2)->nullable()->after('age');
$table->decimal('height_cm', 5, 2)->nullable()->after('weight_kg');
$table->string('fitness_level', 20)->nullable()->after('height_cm'); // beginner, intermediate, advanced
$table->string('goal', 50)->nullable()->after('fitness_level'); // strength, endurance, weight_loss, hypertrophy, mobility, etc
$table->text('health_notes')->nullable()->after('goal'); // health conditions / injuries
$table->text('available_equipment')->nullable()->after('health_notes');
$table->unsignedTinyInteger('days_per_week')->nullable()->after('available_equipment');
$table->unsignedSmallInteger('minutes_per_session')->nullable()->after('days_per_week');
$table->index(['fitness_level', 'goal']);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex(['fitness_level', 'goal']);
$table->dropColumn([
'age',
'weight_kg',
'height_cm',
'fitness_level',
'goal',
'health_notes',
'available_equipment',
'days_per_week',
'minutes_per_session',
]);
});
}
};
+47
View File
@@ -161,4 +161,51 @@ return [
'similar' => 'Similar poses',
'similar_none' => 'No similar poses found.',
],
'auth' => [
'register' => [
'age' => 'Age',
'weight_kg' => 'Weight (kg)',
'height_cm' => 'Height (cm)',
'fitness_level' => 'Fitness level',
'goal' => 'Primary goal',
'health_notes' => 'Health conditions or injuries',
'available_equipment' => 'Available equipment',
'available_equipment_ph' => 'e.g. dumbbells, barbell, kettlebell, bands, pull-up bar',
'days_per_week' => 'Days per week',
'minutes_per_session' => 'Minutes per session',
'select_optional' => 'Select (optional)',
'levels' => [
'beginner' => 'Beginner',
'intermediate' => 'Intermediate',
'advanced' => 'Advanced',
],
'goals' => [
'strength' => 'Strength',
'endurance' => 'Endurance',
'weight_loss' => 'Weight loss',
'hypertrophy' => 'Hypertrophy',
'mobility' => 'Mobility',
],
'steps' => [
'step1_title' => 'Create your account',
'step2_title' => 'Tell us about your fitness profile (optional)',
'next' => 'Next',
'back' => 'Back',
'register' => 'Register',
],
'equipment_options' => [
'dumbbells' => 'Dumbbells',
'barbell' => 'Barbell',
'kettlebell' => 'Kettlebell',
'bands' => 'Resistance bands',
'pullup_bar' => 'Pull-up bar',
'bench' => 'Bench',
'machines' => 'Gym machines',
'yoga_mat' => 'Yoga mat',
'treadmill' => 'Treadmill',
'bodyweight' => 'Bodyweight only',
],
],
],
];
+47
View File
@@ -161,4 +161,51 @@ return [
'similar' => 'Poziții similare',
'similar_none' => 'Nu s-au găsit poziții similare.',
],
'auth' => [
'register' => [
'age' => 'Vârstă',
'weight_kg' => 'Greutate (kg)',
'height_cm' => 'Înălțime (cm)',
'fitness_level' => 'Nivel de fitness',
'goal' => 'Obiectiv principal',
'health_notes' => 'Afectiuni de sănătate sau accidentări',
'available_equipment' => 'Echipament disponibil',
'available_equipment_ph' => 'ex: gantere, bară, kettlebell, benzi, bară de tracțiuni',
'days_per_week' => 'Zile pe săptămână',
'minutes_per_session' => 'Minute pe sesiune',
'select_optional' => 'Selectează (opțional)',
'levels' => [
'beginner' => 'Începător',
'intermediate' => 'Intermediar',
'advanced' => 'Avansat',
],
'goals' => [
'strength' => 'Forță',
'endurance' => 'Anduranță',
'weight_loss' => 'Scădere în greutate',
'hypertrophy' => 'Hipertrofie',
'mobility' => 'Mobilitate',
],
'steps' => [
'step1_title' => 'Creează-ți contul',
'step2_title' => 'Spune-ne despre profilul tău de fitness (opțional)',
'next' => 'Înainte',
'back' => 'Înapoi',
'register' => 'Înregistrare',
],
'equipment_options' => [
'dumbbells' => 'Gantere',
'barbell' => 'Bară',
'kettlebell' => 'Kettlebell',
'bands' => 'Benzi elastice',
'pullup_bar' => 'Bară de tracțiuni',
'bench' => 'Bancă',
'machines' => 'Aparate',
'yoga_mat' => 'Saltea de yoga',
'treadmill' => 'Banda de alergare',
'bodyweight' => 'Doar greutatea corpului',
],
],
],
];
+173 -14
View File
@@ -1,7 +1,22 @@
<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
<form method="POST" action="{{ route('register') }}" x-data="registerSteps()" x-init="init()" @keydown.enter.prevent="onEnter($event)">
@csrf
<!-- Step indicator (visually simple) -->
<div class="mb-6 flex items-center gap-3">
<div class="flex items-center gap-2">
<div :class="step===1?'bg-indigo-600 text-white':'bg-gray-200 text-gray-700'" class="h-6 w-6 rounded-full flex items-center justify-center text-xs font-bold">1</div>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('app.auth.register.steps.step1_title') }}</div>
</div>
<div class="h-px w-8 bg-gray-300 dark:bg-gray-700"></div>
<div class="flex items-center gap-2">
<div :class="step===2?'bg-indigo-600 text-white':'bg-gray-200 text-gray-700'" class="h-6 w-6 rounded-full flex items-center justify-center text-xs font-bold">2</div>
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('app.auth.register.steps.step2_title') }}</div>
</div>
</div>
<!-- Step 1: account basics -->
<div x-show="step===1" x-cloak>
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
@@ -19,34 +34,178 @@
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-text-input id="password_confirmation" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<div class="flex items-center justify-end mt-6">
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}">
{{ __('Already registered?') }}
</a>
<button type="button" @click="goNext()" class="ms-4 inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-500">{{ __('app.auth.register.steps.next') }}</button>
</div>
</div>
<!-- Step 2: fitness profile (optional) -->
<div x-show="step===2" x-cloak>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<x-input-label for="age" :value="__('app.auth.register.age')" />
<x-text-input id="age" class="block mt-1 w-full" type="number" min="5" max="120" name="age" :value="old('age')" />
<x-input-error :messages="$errors->get('age')" class="mt-2" />
</div>
<div>
<x-input-label for="height_cm" :value="__('app.auth.register.height_cm')" />
<x-text-input id="height_cm" class="block mt-1 w-full" type="number" step="0.5" min="80" max="250" name="height_cm" :value="old('height_cm')" />
<x-input-error :messages="$errors->get('height_cm')" class="mt-2" />
</div>
<div>
<x-input-label for="weight_kg" :value="__('app.auth.register.weight_kg')" />
<x-text-input id="weight_kg" class="block mt-1 w-full" type="number" step="0.1" min="20" max="400" name="weight_kg" :value="old('weight_kg')" />
<x-input-error :messages="$errors->get('weight_kg')" class="mt-2" />
</div>
<div>
<x-input-label for="fitness_level" :value="__('app.auth.register.fitness_level')" />
<select id="fitness_level" name="fitness_level" class="block mt-1 w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<option value="">{{ __('app.auth.register.select_optional') }}</option>
<option value="beginner" @selected(old('fitness_level')==='beginner')>{{ __('app.auth.register.levels.beginner') }}</option>
<option value="intermediate" @selected(old('fitness_level')==='intermediate')>{{ __('app.auth.register.levels.intermediate') }}</option>
<option value="advanced" @selected(old('fitness_level')==='advanced')>{{ __('app.auth.register.levels.advanced') }}</option>
</select>
<x-input-error :messages="$errors->get('fitness_level')" class="mt-2" />
</div>
<div class="md:col-span-2">
<x-input-label for="goal" :value="__('app.auth.register.goal')" />
<select id="goal" name="goal" class="block mt-1 w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<option value="">{{ __('app.auth.register.select_optional') }}</option>
<option value="strength" @selected(old('goal')==='strength')>{{ __('app.auth.register.goals.strength') }}</option>
<option value="endurance" @selected(old('goal')==='endurance')>{{ __('app.auth.register.goals.endurance') }}</option>
<option value="weight_loss" @selected(old('goal')==='weight_loss')>{{ __('app.auth.register.goals.weight_loss') }}</option>
<option value="hypertrophy" @selected(old('goal')==='hypertrophy')>{{ __('app.auth.register.goals.hypertrophy') }}</option>
<option value="mobility" @selected(old('goal')==='mobility')>{{ __('app.auth.register.goals.mobility') }}</option>
</select>
<x-input-error :messages="$errors->get('goal')" class="mt-2" />
</div>
<div class="md:col-span-2">
<x-input-label for="health_notes" :value="__('app.auth.register.health_notes')" />
<textarea id="health_notes" name="health_notes" rows="3" class="block mt-1 w-full rounded-md border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">{{ old('health_notes') }}</textarea>
<x-input-error :messages="$errors->get('health_notes')" class="mt-2" />
</div>
<!-- Available equipment icon grid -->
<div class="md:col-span-2">
<x-input-label for="available_equipment" :value="__('app.auth.register.available_equipment')" />
<input type="hidden" id="available_equipment" name="available_equipment" value="{{ old('available_equipment') }}">
@php
$equipmentOptions = [
// Simple, high-contrast stroke-based icons to ensure visibility
['key' => 'dumbbells', 'icon' => '<path d="M2 10h3v4H2v-4Zm17 0h3v4h-3v-4ZM7 9h3v6H7V9Zm7 0h3v6h-3V9Zm-3 2h2v2h-2v-2Z"/>'],
['key' => 'barbell', 'icon' => '<path d="M3 10h2v4H3v-4Zm4-2h2v8H7V8Zm6 0h2v8h-2V8Zm6 2h2v4h-2v-4ZM5 11h14v2H5v-2Z"/>'],
['key' => 'kettlebell', 'icon' => '<path d="M9 9a3 3 0 1 1 6 0v1H9V9Zm-2 6a5 5 0 1 1 10 0v2a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3v-2Z"/>'],
['key' => 'bands', 'icon' => '<path d="M4 8c3-3 13-3 16 0M4 12c3-3 13-3 16 0M4 16c3-3 13-3 16 0"/>'],
['key' => 'pullup_bar', 'icon' => '<path d="M3 7h18v2H3V7Zm4 2h2v8H7V9Zm8 0h2v8h-2V9Z"/>'],
['key' => 'bench', 'icon' => '<path d="M5 13h14v2H5v-2Zm2 2h2v3H7v-3Zm8 0h2v3h-2v-3Z"/>'],
['key' => 'machines', 'icon' => '<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="8" width="6" height="8" rx="1"/>'],
['key' => 'yoga_mat', 'icon' => '<path d="M5 17l9-9a3 3 0 1 1 4 4l-9 9a3 3 0 1 1-4-4Z"/>'],
['key' => 'treadmill', 'icon' => '<path d="M4 17h10l4-8h2l-5 10H4v-2Zm2-8h6v2H6V9Z"/>'],
['key' => 'bodyweight', 'icon' => '<circle cx="12" cy="6" r="2"/><path d="M9 10h6l3 5h-2v5h-2v-4h-2v4H10v-5H8l1-2.5V10Z"/>'],
];
$oldEquip = collect(explode(',', (string) old('available_equipment')))->map(fn($s)=>trim($s))->filter()->values()->all();
@endphp
<div class="mt-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
@foreach($equipmentOptions as $opt)
@php $selected = in_array($opt['key'], $oldEquip, true); @endphp
<button type="button" class="equip-option group flex items-center gap-3 rounded-xl border-2 px-4 py-3 text-base select-none transition transform hover:-translate-y-0.5 hover:shadow focus:outline-none focus:ring-2 focus:ring-indigo-500/60 cursor-pointer {{ $selected ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 shadow' : 'border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200' }}" data-key="{{ $opt['key'] }}" aria-pressed="{{ $selected ? 'true' : 'false' }}" aria-label="{{ __('app.auth.register.equipment_options.' . $opt['key']) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-8 w-8 {{ $selected ? 'text-indigo-600' : 'text-gray-500' }}">{!! $opt['icon'] !!}</svg>
<span class="">{{ __('app.auth.register.equipment_options.' . $opt['key']) }}</span>
</button>
@endforeach
</div>
<p class="mt-2 text-xs text-gray-500">{{ __('app.auth.register.available_equipment_ph') }}</p>
<x-input-error :messages="$errors->get('available_equipment')" class="mt-2" />
</div>
<div>
<x-input-label for="days_per_week" :value="__('app.auth.register.days_per_week')" />
<x-text-input id="days_per_week" class="block mt-1 w-full" type="number" min="1" max="7" name="days_per_week" :value="old('days_per_week')" />
<x-input-error :messages="$errors->get('days_per_week')" class="mt-2" />
</div>
<div>
<x-input-label for="minutes_per_session" :value="__('app.auth.register.minutes_per_session')" />
<x-text-input id="minutes_per_session" class="block mt-1 w-full" type="number" min="5" max="600" name="minutes_per_session" :value="old('minutes_per_session')" />
<x-input-error :messages="$errors->get('minutes_per_session')" class="mt-2" />
</div>
</div>
<div class="flex items-center justify-between mt-6">
<button type="button" @click="goBack()" class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800">{{ __('app.auth.register.steps.back') }}</button>
<x-primary-button class="ms-4">
{{ __('Register') }}
{{ __('app.auth.register.steps.register') }}
</x-primary-button>
</div>
</div>
<script>
function registerSteps(){
return {
step: 1,
init(){
// Attach equipment toggles
const hidden = document.getElementById('available_equipment');
const selected = new Set((hidden.value||'').split(',').map(s=>s.trim()).filter(Boolean));
const sync = ()=>{ hidden.value = Array.from(selected).join(','); };
document.querySelectorAll('.equip-option').forEach(btn=>{
btn.addEventListener('click', ()=>{
const key = btn.getAttribute('data-key');
const isOn = btn.getAttribute('aria-pressed') === 'true';
if (isOn) {
btn.setAttribute('aria-pressed','false');
btn.classList.remove('border-indigo-500','bg-indigo-50','dark:bg-indigo-900/30','text-indigo-700','dark:text-indigo-300','shadow');
btn.classList.add('border-gray-300','dark:border-gray-700','bg-white','dark:bg-gray-900','text-gray-800','dark:text-gray-200');
selected.delete(key);
} else {
btn.setAttribute('aria-pressed','true');
btn.classList.add('border-indigo-500','bg-indigo-50','dark:bg-indigo-900/30','text-indigo-700','dark:text-indigo-300','shadow');
btn.classList.remove('border-gray-300','dark:border-gray-700','bg-white','dark:bg-gray-900','text-gray-800','dark:text-gray-200');
selected.add(key);
}
const icon = btn.querySelector('svg');
if (icon) {
icon.classList.toggle('text-indigo-600', !isOn);
icon.classList.toggle('text-gray-500', isOn);
}
sync();
});
});
},
goNext(){
// basic HTML5 validity check for step 1
const name = document.getElementById('name');
const email = document.getElementById('email');
const pass = document.getElementById('password');
const pass2 = document.getElementById('password_confirmation');
if (name.checkValidity() && email.checkValidity() && pass.checkValidity() && pass2.checkValidity()) {
this.step = 2;
// focus first optional field
setTimeout(()=>{ const age = document.getElementById('age'); if (age) age.focus(); }, 0);
} else {
// trigger browser messages
[name,email,pass,pass2].forEach(i=>!i.checkValidity() && i.reportValidity());
}
},
goBack(){ this.step = 1; setTimeout(()=>{ const name = document.getElementById('name'); if (name) name.focus(); }, 0); },
onEnter(e){
if (this.step===1) { this.goNext(); }
}
}
}
</script>
</form>
</x-guest-layout>