From 67a5e6a61699d4ad72a410abc1990f4a46b48b36 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:16:25 +0600 Subject: [PATCH 01/15] refactor(onboarding): refine step headers, copy, and button actions --- app/Http/Controllers/AuthController.php | 29 +- resources/js/pages/auth/Onboarding.vue | 705 +++++++++++++++++------- tests/Feature/AuthenticationTest.php | 55 ++ 3 files changed, 581 insertions(+), 208 deletions(-) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index e2f7f379..31ab4e49 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(4) + ->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'], + 'appreciations.*' => ['integer', '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..5156d6f1 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -6,8 +6,11 @@ import { GraduationCap, AlertCircle, ArrowLeft, + ArrowRight, Camera, Loader2, + Heart, + BadgeCheck, } from 'lucide-vue-next'; import { computed, onUnmounted, ref } from 'vue'; import { compressImage } from '@/lib/imageCompression'; @@ -19,29 +22,47 @@ 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: [], }); 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,15 +142,98 @@ 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; } + if (hasContributors.value && form.appreciations.length === 0) { + return; + } + 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; +}; From c0231b686ade2ac73381333fccb36cabb5db0162 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:24:08 +0600 Subject: [PATCH 07/15] refactor(onboarding): show Name and School on contributor cards --- resources/js/pages/auth/Onboarding.vue | 34 ++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index 3eda83e5..468406a7 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -10,9 +10,9 @@ import { Camera, Loader2, Heart, - BadgeCheck, } from 'lucide-vue-next'; import { computed, onUnmounted, ref } from 'vue'; +import VerifiedBadge from '@/components/VerifiedBadge.vue'; import { compressImage } from '@/lib/imageCompression'; interface OnboardingUser { @@ -609,28 +609,26 @@ const getContributorAvatar = (contributor: Contributor) => { ?.toUpperCase() || 'U' }} -
+
+

+ {{ contributor.name }} +

+ +

- {{ contributor.name }} -

-

- @{{ contributor.username }} -

-

- {{ contributor.institution }} + {{ + contributor.institution || + `@${contributor.username}` + }}

From cf1eef44deb6394a12e0dedcd7998cc456866d87 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:24:54 +0600 Subject: [PATCH 08/15] refactor(onboarding): only display institution without username fallback --- resources/js/pages/auth/Onboarding.vue | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index 468406a7..a05cfb9d 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -623,12 +623,10 @@ const getContributorAvatar = (contributor: Contributor) => { />

- {{ - contributor.institution || - `@${contributor.username}` - }} + {{ contributor.institution }}

From c8071400a38001105fa03ab1651ad7ef61cd60ef Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:26:16 +0600 Subject: [PATCH 09/15] style(onboarding): match appreciate button styling with /u/profile --- resources/js/pages/auth/Onboarding.vue | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index a05cfb9d..0f88326c 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -631,25 +631,26 @@ const getContributorAvatar = (contributor: Contributor) => { - + + +
+ + + +
+ + +
+ +
+
+
From 90e6e3ab6ab2425b55436aa0ab99e9756beaaaa9 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:31:27 +0600 Subject: [PATCH 11/15] fix(onboarding): fix template closing tag and add visible skip button --- resources/js/pages/auth/Onboarding.vue | 138 +++++++------------------ 1 file changed, 35 insertions(+), 103 deletions(-) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index 07bee79b..abf8f3d6 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -10,10 +10,8 @@ import { Camera, Loader2, Heart, - MoreVertical, - SkipForward, } from 'lucide-vue-next'; -import { computed, onMounted, onUnmounted, ref } from 'vue'; +import { computed, onUnmounted, ref } from 'vue'; import VerifiedBadge from '@/components/VerifiedBadge.vue'; import { compressImage } from '@/lib/imageCompression'; @@ -43,7 +41,6 @@ const page = usePage(); const flashError = computed(() => (page.props as any).flash?.error); const currentStep = ref<1 | 2>(1); -const showMoreMenu = ref(false); const form = useForm<{ name: string; @@ -202,38 +199,15 @@ const goToStep2 = () => { currentStep.value = 2; }; -const submit = () => { +const submit = (skipAppreciations = false) => { if (isCompressing.value || form.errors.image) { return; } - if (hasContributors.value && form.appreciations.length === 0) { - return; - } - - form.post('/onboarding', { - forceFormData: true, - onError: (errors) => { - if ( - errors.name || - errors.username || - errors.school || - errors.image - ) { - currentStep.value = 1; - } - }, - }); -}; - -const skipAndSubmit = () => { - if (isCompressing.value || form.errors.image) { - return; + if (skipAppreciations) { + form.appreciations = []; } - form.appreciations = []; - showMoreMenu.value = false; - form.post('/onboarding', { forceFormData: true, onError: (errors) => { @@ -249,20 +223,6 @@ const skipAndSubmit = () => { }); }; -const handleWindowClick = () => { - if (showMoreMenu.value) { - showMoreMenu.value = false; - } -}; - -onMounted(() => { - window.addEventListener('click', handleWindowClick); -}); - -onUnmounted(() => { - window.removeEventListener('click', handleWindowClick); -}); - const getContributorAvatar = (contributor: Contributor) => { if (contributor.image_url) { return contributor.image_url; @@ -600,51 +560,18 @@ const getContributorAvatar = (contributor: Contributor) => { of {{ contributors.length }} appreciated -
- - - -
- - -
- -
-
-
+ @@ -757,26 +684,31 @@ const getContributorAvatar = (contributor: Contributor) => { + + + +
+
From d59b82f0c28c369d7a56e8dd68dc7e66e1132965 Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:33:28 +0600 Subject: [PATCH 12/15] feat(onboarding): pre-select all suggested contributors and allow submission without explicit skip button --- resources/js/pages/auth/Onboarding.vue | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index abf8f3d6..2cf6bcf1 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -53,7 +53,7 @@ const form = useForm<{ username: '', school: '', image: null, - appreciations: [], + appreciations: (props.suggestedContributors || []).map((c) => c.id), }); const previewUrl = ref(null); @@ -199,15 +199,11 @@ const goToStep2 = () => { currentStep.value = 2; }; -const submit = (skipAppreciations = false) => { +const submit = () => { if (isCompressing.value || form.errors.image) { return; } - if (skipAppreciations) { - form.appreciations = []; - } - form.post('/onboarding', { forceFormData: true, onError: (errors) => { @@ -684,7 +680,7 @@ const getContributorAvatar = (contributor: Contributor) => { - - -
- -
From 4e5b832aaa33d0407f7e907cc7b6864b237b7eae Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:39:39 +0600 Subject: [PATCH 13/15] feat(onboarding): suggest 4 contributors (2 top, 2 random) with top 2 pre-selected and polished card layout --- app/Http/Controllers/AuthController.php | 2 +- resources/js/pages/auth/Onboarding.vue | 45 ++++++++++++------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 31ab4e49..310809a2 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -114,7 +114,7 @@ public function showOnboarding(Request $request) $top = User::withCount('appreciationsReceived') ->orderByDesc('appreciations_received_count') - ->take(4) + ->take(2) ->get(['id', 'name', 'username', 'image_path', 'institution', 'is_verified']); $random = User::whereNotIn('id', $top->pluck('id')) diff --git a/resources/js/pages/auth/Onboarding.vue b/resources/js/pages/auth/Onboarding.vue index 2cf6bcf1..289b6d04 100644 --- a/resources/js/pages/auth/Onboarding.vue +++ b/resources/js/pages/auth/Onboarding.vue @@ -53,7 +53,9 @@ const form = useForm<{ username: '', school: '', image: null, - appreciations: (props.suggestedContributors || []).map((c) => c.id), + appreciations: (props.suggestedContributors || []) + .slice(0, 2) + .map((c) => c.id), }); const previewUrl = ref(null); @@ -571,34 +573,28 @@ const getContributorAvatar = (contributor: Contributor) => { -
+
-
+
{{ contributor.name @@ -609,19 +605,20 @@ const getContributorAvatar = (contributor: Contributor) => {
-
+

{{ contributor.name }}

{{ contributor.institution }}

@@ -631,22 +628,22 @@ const getContributorAvatar = (contributor: Contributor) => {
From 487cad5229f7f8735f02c57c7be2293b831686df Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:41:38 +0600 Subject: [PATCH 14/15] fix(security): bound onboarding appreciations array to max 4 with distinct ids --- app/Http/Controllers/AuthController.php | 4 +-- tests/Feature/AuthenticationTest.php | 42 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 310809a2..2f9ecc73 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -155,8 +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'], - 'appreciations.*' => ['integer', 'exists:users,id'], + '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.', diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index f7370966..34136fbe 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -390,3 +390,45 @@ $this->assertAuthenticatedAs($newUser); $response->assertRedirect(route('user.profile', 'fan_user')); }); + +test('completing onboarding with duplicate appreciation IDs fails validation', function () { + $contributor = User::factory()->create(['username' => 'mentor_dup']); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-dup', + 'email' => 'dup@example.com', + 'name' => 'Dup User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Dup User', + 'username' => 'dup_user', + 'school' => 'Notre Dame College', + 'appreciations' => [$contributor->id, $contributor->id], + ]); + + $response->assertSessionHasErrors(['appreciations.0']); + $this->assertGuest(); +}); + +test('completing onboarding with more than 4 appreciations fails validation', function () { + $contributors = User::factory()->count(5)->create(); + + $response = $this->withSession([ + 'onboarding_user' => [ + 'google_id' => 'google-id-overflow', + 'email' => 'overflow@example.com', + 'name' => 'Overflow User', + 'avatar' => null, + ], + ])->post(route('onboarding.complete'), [ + 'name' => 'Overflow User', + 'username' => 'overflow_user', + 'school' => 'Notre Dame College', + 'appreciations' => $contributors->pluck('id')->all(), + ]); + + $response->assertSessionHasErrors(['appreciations']); + $this->assertGuest(); +}); From ba87cd16aa621c203624468ed389e65d4b82b40a Mon Sep 17 00:00:00 2001 From: Tajim Date: Wed, 9 Sep 2026 23:42:12 +0600 Subject: [PATCH 15/15] test(auth): revert extra bounds feature tests --- tests/Feature/AuthenticationTest.php | 42 ---------------------------- 1 file changed, 42 deletions(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 34136fbe..f7370966 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -390,45 +390,3 @@ $this->assertAuthenticatedAs($newUser); $response->assertRedirect(route('user.profile', 'fan_user')); }); - -test('completing onboarding with duplicate appreciation IDs fails validation', function () { - $contributor = User::factory()->create(['username' => 'mentor_dup']); - - $response = $this->withSession([ - 'onboarding_user' => [ - 'google_id' => 'google-id-dup', - 'email' => 'dup@example.com', - 'name' => 'Dup User', - 'avatar' => null, - ], - ])->post(route('onboarding.complete'), [ - 'name' => 'Dup User', - 'username' => 'dup_user', - 'school' => 'Notre Dame College', - 'appreciations' => [$contributor->id, $contributor->id], - ]); - - $response->assertSessionHasErrors(['appreciations.0']); - $this->assertGuest(); -}); - -test('completing onboarding with more than 4 appreciations fails validation', function () { - $contributors = User::factory()->count(5)->create(); - - $response = $this->withSession([ - 'onboarding_user' => [ - 'google_id' => 'google-id-overflow', - 'email' => 'overflow@example.com', - 'name' => 'Overflow User', - 'avatar' => null, - ], - ])->post(route('onboarding.complete'), [ - 'name' => 'Overflow User', - 'username' => 'overflow_user', - 'school' => 'Notre Dame College', - 'appreciations' => $contributors->pluck('id')->all(), - ]); - - $response->assertSessionHasErrors(['appreciations']); - $this->assertGuest(); -});