diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index e2f7f379..2f9ecc73 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\User; +use App\Models\UserAppreciation; use App\Notifications\WelcomeNotification; use App\Rules\CleanText; use App\Services\ChatProfanityFilter; @@ -111,10 +112,21 @@ public function showOnboarding(Request $request) return redirect()->route('login')->with('error', 'Please continue with Google to create an account.'); } - $onboardingUser = $request->session()->get('onboarding_user'); + $top = User::withCount('appreciationsReceived') + ->orderByDesc('appreciations_received_count') + ->take(2) + ->get(['id', 'name', 'username', 'image_path', 'institution', 'is_verified']); + + $random = User::whereNotIn('id', $top->pluck('id')) + ->inRandomOrder() + ->take(2) + ->get(['id', 'name', 'username', 'image_path', 'institution', 'is_verified']); + + $suggestedContributors = $top->concat($random)->values(); return Inertia::render('auth/Onboarding', [ - 'user' => $onboardingUser, + 'user' => $request->session()->get('onboarding_user'), + 'suggestedContributors' => $suggestedContributors, ]); } @@ -143,6 +155,8 @@ public function completeOnboarding(Request $request) ], 'school' => ['required', 'string', 'max:255', new CleanText], 'image' => ['sometimes', 'nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'appreciations' => ['nullable', 'array', 'max:4'], + 'appreciations.*' => ['integer', 'distinct', 'exists:users,id'], ], [ 'school.required' => 'Please enter your school, college, or institution name.', 'username.regex' => 'Username can only contain letters, numbers, and underscores.', @@ -183,6 +197,17 @@ public function completeOnboarding(Request $request) } } + if (! empty($validated['appreciations'])) { + foreach ($validated['appreciations'] as $targetUserId) { + if ((int) $targetUserId !== (int) $user->id) { + UserAppreciation::firstOrCreate([ + 'user_id' => $targetUserId, + 'appreciator_id' => $user->id, + ]); + } + } + } + $request->session()->forget('onboarding_user'); Auth::login($user, remember: true); diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index 8333dc13..289b6d04 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -6,10 +6,13 @@ import { GraduationCap, AlertCircle, ArrowLeft, + ArrowRight, Camera, Loader2, + Heart, } from 'lucide-vue-next'; import { computed, onUnmounted, ref } from 'vue'; +import VerifiedBadge from '@/components/VerifiedBadge.vue'; import { compressImage } from '@/lib/imageCompression'; interface OnboardingUser { @@ -19,29 +22,49 @@ interface OnboardingUser { avatar?: string | null; } +interface Contributor { + id: number; + name: string; + username: string; + image_path?: string | null; + image_url?: string | null; + institution?: string | null; + is_verified?: boolean; +} + const props = defineProps<{ user?: OnboardingUser; + suggestedContributors?: Contributor[]; }>(); const page = usePage(); const flashError = computed(() => (page.props as any).flash?.error); +const currentStep = ref<1 | 2>(1); + const form = useForm<{ name: string; username: string; school: string; image: File | null; + appreciations: number[]; }>({ name: props.user?.name || '', username: '', school: '', image: null, + appreciations: (props.suggestedContributors || []) + .slice(0, 2) + .map((c) => c.id), }); const previewUrl = ref(null); const fileInputRef = ref(null); const isCompressing = ref(false); +const contributors = computed(() => props.suggestedContributors || []); +const hasContributors = computed(() => contributors.value.length > 0); + const handleImageChange = async (e: Event) => { const target = e.target as HTMLInputElement; const rawFile = target.files?.[0]; @@ -121,6 +144,63 @@ onUnmounted(() => { } }); +const toggleAppreciation = (userId: number) => { + const index = form.appreciations.indexOf(userId); + + if (index > -1) { + form.appreciations.splice(index, 1); + } else { + form.appreciations.push(userId); + } +}; + +const selectAllContributors = () => { + if (form.appreciations.length === contributors.value.length) { + form.appreciations = []; + } else { + form.appreciations = contributors.value.map((c) => c.id); + } +}; + +const goToStep2 = () => { + form.errors.name = ''; + form.errors.username = ''; + form.errors.school = ''; + + if (!form.name.trim()) { + form.errors.name = 'Please enter your full name.'; + + return; + } + + if (!form.username.trim()) { + form.errors.username = 'Please choose a username.'; + + return; + } + + if (!/^[a-zA-Z0-9_]{3,30}$/.test(form.username.trim())) { + form.errors.username = + 'Username must be 3-30 characters (letters, numbers, underscores).'; + + return; + } + + if (!form.school.trim()) { + form.errors.school = 'Please enter your institution name.'; + + return; + } + + if (!hasContributors.value) { + submit(); + + return; + } + + currentStep.value = 2; +}; + const submit = () => { if (isCompressing.value || form.errors.image) { return; @@ -128,8 +208,30 @@ const submit = () => { form.post('/onboarding', { forceFormData: true, + onError: (errors) => { + if ( + errors.name || + errors.username || + errors.school || + errors.image + ) { + currentStep.value = 1; + } + }, }); }; + +const getContributorAvatar = (contributor: Contributor) => { + if (contributor.image_url) { + return contributor.image_url; + } + + if (contributor.image_path) { + return `/storage/${contributor.image_path}`; + } + + return null; +}; diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 3b2e93db..f7370966 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -335,3 +335,58 @@ $response->assertRedirect(route('profile.edit')); }); + +test('onboarding passes suggested contributors to the view', function () { + $topUsers = User::factory()->count(4)->create(); + $randomUsers = User::factory()->count(2)->create(); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-suggested', + 'email' => 'suggested@example.com', + 'name' => 'Suggested User', + 'avatar' => null, + ], + ])->get(route('onboarding')); + + $response->assertStatus(200); + $response->assertInertia(fn ($page) => $page + ->component('auth/Onboarding') + ->has('suggestedContributors') + ); +}); + +test('completing onboarding with appreciations creates UserAppreciation records', function () { + $contributor1 = User::factory()->create(['username' => 'mentor_1']); + $contributor2 = User::factory()->create(['username' => 'mentor_2']); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-fan', + 'email' => 'fan@example.com', + 'name' => 'Fan User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Fan User', + 'username' => 'fan_user', + 'school' => 'Notre Dame College', + 'appreciations' => [$contributor1->id, $contributor2->id], + ]); + + $newUser = User::where('email', 'fan@example.com')->first(); + $this->assertNotNull($newUser); + + $this->assertDatabaseHas('user_appreciations', [ + 'user_id' => $contributor1->id, + 'appreciator_id' => $newUser->id, + ]); + + $this->assertDatabaseHas('user_appreciations', [ + 'user_id' => $contributor2->id, + 'appreciator_id' => $newUser->id, + ]); + + $this->assertAuthenticatedAs($newUser); + $response->assertRedirect(route('user.profile', 'fan_user')); +});