Commit c19f2b7d authored by DevPilot's avatar DevPilot

fix(creators): make "complete your account" onboarding actually save and finish

- Steps 4-5: the Skip <form> was nested inside the step form, which closed the
  outer form early and left Next/Complete outside it (button did nothing).
- Step detection no longer requires a profile photo that onboarding never
  asked for (users were always sent back to step 1). Step 1 now has an
  optional photo upload.
- x-ui.select and x-ui.textarea ignored :value, so saved answers (bio,
  experience, equipment, availability...) re-rendered empty and looked unsaved.
  Fixes every form using them (campaign edit, company profile, filters).
- Country and timezone are dropdowns instead of free text that had to match
  an exact code; website accepts "mysite.com".
- Language tags are committed on blur, not only on Enter (mobile).
- Validation errors are summarised at the top of each step.
- Dashboard CTA points to portfolio upload once onboarding is done.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent a2aecfda
......@@ -25,7 +25,18 @@ class CreatorOnboardingController extends Controller
{
$profile = $request->user()->creatorProfile;
$this->service->update($profile, $request->validated());
$this->service->update($profile, $request->safe()->except('avatar'));
if ($request->hasFile('avatar')) {
try {
$this->service->updateAvatar($profile, $request->file('avatar'));
} catch (\Throwable $e) {
// The text fields are already saved; don't lose them over an image failure.
report($e);
return back()->withErrors(['avatar' => __('errors.upload_failed')]);
}
}
$nextStep = $step + 1;
$totalSteps = 5;
......@@ -59,7 +70,8 @@ class CreatorOnboardingController extends Controller
private function determineCurrentStep($profile): int
{
if (!$profile->bio || !$profile->profile_picture_path) {
// The photo is optional here, so it must not pin the user to step 1.
if (!$profile->bio) {
return 1;
}
......
......@@ -12,6 +12,23 @@ class UpdateCreatorProfileRequest extends FormRequest
return $this->user()->isCreator();
}
protected function prepareForValidation(): void
{
$merge = [];
if ($this->filled('country')) {
$merge['country'] = strtoupper(trim($this->input('country')));
}
// People type "mysite.com"; the url rule needs a scheme.
$website = trim((string) $this->input('social_links.website'));
if ($website !== '' && !preg_match('#^https?://#i', $website)) {
$merge['social_links'] = array_merge((array) $this->input('social_links'), ['website' => 'https://' . $website]);
}
$this->merge($merge);
}
public function rules(): array
{
return [
......@@ -19,6 +36,7 @@ class UpdateCreatorProfileRequest extends FormRequest
'last_name' => ['sometimes', 'required', 'string', 'min:2', 'max:50', 'regex:/^[\pL\s]+$/u'],
'display_name' => ['sometimes', 'nullable', 'string', 'min:2', 'max:50'],
'bio' => ['sometimes', 'nullable', 'string', 'min:20', 'max:500'],
'avatar' => ['sometimes', 'nullable', 'file', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'],
'date_of_birth' => ['sometimes', 'nullable', 'date', 'before:-18 years'],
'gender' => ['sometimes', 'nullable', Rule::in(['male', 'female', 'prefer_not_to_say'])],
......
......@@ -14,4 +14,5 @@ return [
'report_already_submitted' => 'لقد قمت بالفعل بإرسال بلاغ لهذا المحتوى.',
'report_submitted' => 'تم إرسال بلاغك للمراجعة.',
'too_many_reports' => 'لقد أرسلت عددًا كبيرًا من البلاغات مؤخرًا. يرجى الانتظار قبل إرسال بلاغ آخر.',
'upload_failed' => 'تم حفظ بياناتك، لكن تعذّر رفع الصورة. من فضلك جرّب صورة أخرى.',
];
......@@ -14,4 +14,5 @@ return [
'report_already_submitted' => 'You have already submitted a report for this content.',
'report_submitted' => 'Your report has been submitted for review.',
'too_many_reports' => 'You have submitted too many reports recently. Please wait before submitting another.',
'upload_failed' => 'Your info was saved, but the picture could not be uploaded. Please try another image.',
];
......@@ -10,6 +10,9 @@
])
@php
// Callers pass either :selected or :value; honour both so saved values re-appear.
$selected = $selected ?? $attributes->get('value');
$attributes = $attributes->except('value');
$hasError = $error || ($name && $errors->has($name));
$errorMessage = $error ?? ($name ? $errors->first($name) : null);
$inputId = $attributes->get('id', $name ? "select-{$name}" : 'select-' . \Illuminate\Support\Str::random(6));
......
......@@ -10,6 +10,9 @@
])
@php
// Callers pass content either as the slot or as :value; honour both.
$content = $slot->isEmpty() ? (string) $attributes->get('value', '') : $slot;
$attributes = $attributes->except('value');
$hasError = $error || ($name && $errors->has($name));
$errorMessage = $error ?? ($name ? $errors->first($name) : null);
$inputId = $attributes->get('id', $name ? "textarea-{$name}" : 'textarea-' . \Illuminate\Support\Str::random(6));
......@@ -18,7 +21,7 @@ $baseClasses = 'input-field w-full rounded-input border bg-[var(--color-surface)
$errorClasses = $hasError ? 'border-[var(--color-error)] focus:ring-[var(--color-error)]/30 focus:border-[var(--color-error)]' : 'border-[var(--color-border)]';
@endphp
<div class="w-full" @if($showCount && $maxlength) x-data="{ count: 0 }" @endif>
<div class="w-full" @if($showCount && $maxlength) x-data="{ count: {{ mb_strlen(html_entity_decode(strip_tags((string) $content))) }} }" @endif>
@if($label)
<label for="{{ $inputId }}" class="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
{{ $label }}
......@@ -36,7 +39,7 @@ $errorClasses = $hasError ? 'border-[var(--color-error)] focus:ring-[var(--color
@if($required) required @endif
@if($showCount && $maxlength) x-on:input="count = $el.value.length" @endif
{{ $attributes->merge(['class' => "{$baseClasses} {$errorClasses}"]) }}
>{{ $slot }}</textarea>
>{{ $content }}</textarea>
<div class="flex items-center justify-between mt-1.5">
<div>
......
......@@ -33,12 +33,17 @@
</span>
</div>
</div>
@php
// Onboarding only covers profile details; once those are in,
// the remaining completion comes from portfolio work.
$onboardingDone = $profile->bio && $profile->country && !empty($profile->content_niches) && !empty($profile->skills);
@endphp
<a
href="{{ route('creator.onboarding') }}"
href="{{ $onboardingDone ? route('portfolio.create') : route('creator.onboarding') }}"
class="flex-shrink-0 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--color-primary)] text-white text-sm font-medium transition-all duration-200 hover:opacity-90 hover:shadow-md"
>
<x-ui.icon name="arrow-right" size="sm" />
<span>{{ __('ui.complete') }}</span>
<span>{{ $onboardingDone ? __('ui.upload_portfolio') : __('ui.complete') }}</span>
</a>
</div>
</div>
......
......@@ -14,7 +14,7 @@
</x-slot:header>
@php
$currentStep = request('step', $step);
$currentStep = max(1, min(5, (int) request('step', $step)));
$steps = [
1 => ['title' => __('creators.step_1_title'), 'desc' => __('creators.step_1_desc'), 'icon' => 'user'],
2 => ['title' => __('creators.step_2_title'), 'desc' => __('creators.step_2_desc'), 'icon' => 'map-pin'],
......@@ -67,6 +67,19 @@
<p class="text-sm text-[var(--color-text-muted)] mt-1">{{ $steps[$currentStep]['desc'] }}</p>
</div>
@if($errors->any())
<div class="p-4 rounded-xl border border-[var(--color-error)]/30 bg-[var(--color-error)]/5 animate-fade-in" role="alert">
<div class="flex items-start gap-3">
<x-ui.icon name="alert-circle" size="sm" class="text-[var(--color-error)] mt-0.5" />
<ul class="text-sm text-[var(--color-error)] space-y-1">
@foreach($errors->all() as $message)
<li>{{ $message }}</li>
@endforeach
</ul>
</div>
</div>
@endif
@include("creators.onboarding.steps.step-{$currentStep}", ['profile' => $profile])
</div>
</x-ui.card>
......@@ -87,15 +100,14 @@
<div class="flex items-center gap-3">
@if($currentStep >= 4)
<form method="POST" action="{{ route('creator.onboarding.skip') }}" class="inline">
@csrf
{{-- Lives in its own form below; a nested <form> here closed the main form early. --}}
<button
type="submit"
form="onboarding-skip-form"
class="px-4 py-2.5 rounded-xl text-sm font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-all duration-200"
>
{{ __('ui.skip') }}
</button>
</form>
@endif
<button
......@@ -113,5 +125,9 @@
</div>
</div>
</form>
<form id="onboarding-skip-form" method="POST" action="{{ route('creator.onboarding.skip') }}" class="hidden">
@csrf
</form>
</div>
</x-layouts.app>
......@@ -32,4 +32,31 @@
rows="4"
required
/>
<div x-data="{ preview: @js($profile->avatar_url) }">
<label for="onboarding-avatar" class="block text-sm font-medium text-[var(--color-text-primary)] mb-1.5">
{{ __('creators.profile_picture') }}
</label>
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full overflow-hidden bg-[var(--color-surface-hover)] flex items-center justify-center shrink-0 transition-all duration-200">
<template x-if="preview">
<img :src="preview" alt="{{ __('creators.profile_picture') }}" class="w-full h-full object-cover animate-fade-in">
</template>
<template x-if="!preview">
<span class="text-[var(--color-text-muted)]"><x-ui.icon name="camera" size="md" /></span>
</template>
</div>
<input
id="onboarding-avatar"
type="file"
name="avatar"
accept="image/jpeg,image/png,image/webp"
@change="const f = $event.target.files[0]; if (f) preview = URL.createObjectURL(f)"
class="block w-full text-sm text-[var(--color-text-muted)] file:me-3 file:px-4 file:py-2 file:rounded-lg file:border-0 file:bg-[var(--color-primary)]/10 file:text-[var(--color-primary)] file:font-medium hover:file:bg-[var(--color-primary)]/20 file:transition-colors file:duration-200 cursor-pointer"
>
</div>
@error('avatar')
<p class="mt-1 text-xs text-[var(--color-error)]">{{ $message }}</p>
@enderror
</div>
</div>
{{-- Step 2: Location & Languages --}}
<div class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<x-ui.input
<x-ui.select
name="country"
:label="__('creators.country')"
:placeholder="__('creators.country')"
:options="\App\Shared\Helpers\Countries::forSelect()"
:value="old('country', $profile->country)"
required
maxlength="2"
placeholder="EG"
/>
<x-ui.input
name="city"
......@@ -17,11 +17,15 @@
/>
</div>
<x-ui.input
@php
$timezones = collect(\DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => str_replace('_', ' ', $tz)])->all();
@endphp
<x-ui.select
name="timezone"
:label="__('creators.timezone')"
:value="old('timezone', $profile->timezone)"
placeholder="Africa/Cairo"
:placeholder="__('creators.timezone')"
:options="$timezones"
:value="old('timezone', $profile->timezone ?: 'Africa/Cairo')"
/>
{{-- Languages tag input --}}
......@@ -50,6 +54,13 @@
$event.target.value = '';
}
"
@blur="
const val = $event.target.value.trim();
if (val && languages.length < 10 && !languages.includes(val)) {
languages.push(val);
$event.target.value = '';
}
"
>
</div>
<p class="mt-1 text-xs text-[var(--color-text-muted)]">{{ __('ui.max_items', ['count' => 10]) }}</p>
......
......@@ -215,6 +215,13 @@
$event.target.value = '';
}
"
@blur="
const val = $event.target.value.trim();
if (val && languages.length < 10 && !languages.includes(val)) {
languages.push(val);
$event.target.value = '';
}
"
>
</div>
<p class="mt-1 text-xs text-[var(--color-text-muted)]">{{ __('ui.max_items', ['count' => 10]) }}</p>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment