diff --git a/backend/package.json b/backend/package.json index bedf73b..77072ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,7 +30,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.0.0", - "@nestjs/swagger": "^11.0.7", + "@nestjs/swagger": "^11.2.5", "@nestjs/throttler": "^6.4.0", "@nestjs/typeorm": "^11.0.0", "@types/passport-google-oauth20": "^2.0.16", diff --git a/backend/src/auth/providers/sign-in.provider.ts b/backend/src/auth/providers/sign-in.provider.ts index 703b5ac..e76ed4c 100644 --- a/backend/src/auth/providers/sign-in.provider.ts +++ b/backend/src/auth/providers/sign-in.provider.ts @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import { forwardRef, Inject, diff --git a/backend/src/redis/redis.constants.ts b/backend/src/redis/redis.constants.ts index 0905d76..6fddc65 100644 --- a/backend/src/redis/redis.constants.ts +++ b/backend/src/redis/redis.constants.ts @@ -1,2 +1 @@ -/* eslint-disable prettier/prettier */ export const REDIS_CLIENT = 'REDIS_CLIENT'; diff --git a/backend/src/redis/redis.module.ts b/backend/src/redis/redis.module.ts index bc3d021..d6e8f2e 100644 --- a/backend/src/redis/redis.module.ts +++ b/backend/src/redis/redis.module.ts @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import { Global, Module, OnModuleDestroy, Inject } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { redisProvider } from './redis.provider'; diff --git a/backend/src/redis/redis.provider.ts b/backend/src/redis/redis.provider.ts index 1857e13..91fb8ed 100644 --- a/backend/src/redis/redis.provider.ts +++ b/backend/src/redis/redis.provider.ts @@ -1,4 +1,3 @@ -/* eslint-disable prettier/prettier */ import { Provider } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; diff --git a/backend/src/users/dtos/editUserDto.dto.ts b/backend/src/users/dtos/editUserDto.dto.ts index 0707856..3c73961 100644 --- a/backend/src/users/dtos/editUserDto.dto.ts +++ b/backend/src/users/dtos/editUserDto.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, IsEmail, MinLength } from 'class-validator'; +import { + IsOptional, + IsString, + IsEmail, + MinLength, + IsArray, +} from 'class-validator'; export class EditUserDto { @ApiProperty({ @@ -27,4 +33,58 @@ export class EditUserDto { @IsString() @MinLength(6) password?: string; + + @ApiProperty({ + description: 'Country of the user', + required: false, + }) + @IsOptional() + @IsString() + country?: string; + + @ApiProperty({ + description: 'Interests of the user', + required: false, + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + interests?: string[]; + + @ApiProperty({ + description: 'Occupation of the user', + required: false, + }) + @IsOptional() + @IsString() + occupation?: string; + + @ApiProperty({ + description: 'Goals of the user', + required: false, + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + goals?: string[]; + + @ApiProperty({ + description: 'Available hours for the user', + required: false, + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + availableHours?: string[]; + + @ApiProperty({ + description: 'Bio of the user', + required: false, + }) + @IsOptional() + @IsString() + bio?: string; } diff --git a/backend/src/users/providers/update-user.service.ts b/backend/src/users/providers/update-user.service.ts index c964334..7347ab7 100644 --- a/backend/src/users/providers/update-user.service.ts +++ b/backend/src/users/providers/update-user.service.ts @@ -26,6 +26,12 @@ export class UpdateUserService { user.username = editUserDto.username ?? user.username; user.email = editUserDto.email ?? user.email; user.password = editUserDto.password ?? user.password; + user.country = editUserDto.country ?? user.country; + user.interests = editUserDto.interests ?? user.interests; + user.occupation = editUserDto.occupation ?? user.occupation; + user.goals = editUserDto.goals ?? user.goals; + user.availableHours = editUserDto.availableHours ?? user.availableHours; + user.bio = editUserDto.bio ?? user.bio; try { return await this.userRepository.save(user); diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index 38f3f1c..e18ff24 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -101,6 +101,48 @@ export class User { @Column('varchar', { length: 50, nullable: true }) ageGroup?: string; + /** + * Country of the user + */ + @ApiProperty({ example: 'United States', required: false }) + @Column('varchar', { length: 100, nullable: true }) + country?: string; + + /** + * User interests + */ + @ApiProperty({ example: ['Coding', 'Design'], required: false }) + @Column('simple-array', { nullable: true }) + interests?: string[]; + + /** + * User occupation + */ + @ApiProperty({ example: 'Software Engineer', required: false }) + @Column('varchar', { length: 150, nullable: true }) + occupation?: string; + + /** + * User goals + */ + @ApiProperty({ example: ['Learn NestJS', 'Build an app'], required: false }) + @Column('simple-array', { nullable: true }) + goals?: string[]; + + /** + * Available hours for learning + */ + @ApiProperty({ example: ['09:00', '10:00'], required: false }) + @Column('simple-array', { nullable: true }) + availableHours?: string[]; + + /** + * User bio + */ + @ApiProperty({ example: 'I am a passionate developer...', required: false }) + @Column('text', { nullable: true }) + bio?: string; + @Column({ nullable: true }) passwordResetToken?: string; @@ -116,4 +158,4 @@ export class User { @OneToOne(() => Streak, (streak) => streak.user) streak: Streak; -} +} \ No newline at end of file diff --git a/frontend/app/onboarding/OnboardingContext.tsx b/frontend/app/onboarding/OnboardingContext.tsx new file mode 100644 index 0000000..1a06089 --- /dev/null +++ b/frontend/app/onboarding/OnboardingContext.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface OnboardingData { + challengeLevel: string; + challengeTypes: string[]; + additionalInfo: { + country: string; + occupation: string; + interests: string[]; + goals: string[]; + }; + availability: { + availableHours: string[]; + bio: string; + }; +} + +interface OnboardingContextType { + data: OnboardingData; + updateData: (section: keyof OnboardingData, payload: any) => void; + // Specialized updaters for deep nesting + updateAdditionalInfo: (field: keyof OnboardingData['additionalInfo'], value: any) => void; + updateAvailability: (field: keyof OnboardingData['availability'], value: any) => void; +} + +const defaultData: OnboardingData = { + challengeLevel: '', + challengeTypes: [], + additionalInfo: { + country: '', + occupation: '', + interests: [], + goals: [] + }, + availability: { + availableHours: [], + bio: '' + } +}; + +const OnboardingContext = createContext(undefined); + +export const OnboardingProvider = ({ children }: { children: ReactNode }) => { + const [data, setData] = useState(defaultData); + + const updateData = (section: keyof OnboardingData, payload: any) => { + setData((prev) => ({ + ...prev, + [section]: payload, + })); + }; + + const updateAdditionalInfo = (field: keyof OnboardingData['additionalInfo'], value: any) => { + setData((prev) => ({ + ...prev, + additionalInfo: { + ...prev.additionalInfo, + [field]: value + } + })); + }; + + const updateAvailability = (field: keyof OnboardingData['availability'], value: any) => { + setData((prev) => ({ + ...prev, + availability: { + ...prev.availability, + [field]: value + } + })); + }; + + return ( + + {children} + + ); +}; + +export const useOnboarding = () => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; diff --git a/frontend/app/onboarding/additional-info/page.tsx b/frontend/app/onboarding/additional-info/page.tsx new file mode 100644 index 0000000..9e29b1d --- /dev/null +++ b/frontend/app/onboarding/additional-info/page.tsx @@ -0,0 +1,280 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@/components/Button'; +import { useOnboarding } from '../OnboardingContext'; +import Image from 'next/image'; + +// Step 3a: How did you hear about Block Mind? (Selection) +const referralSources = [ + 'Google Search', + 'X (formerly called Twitter)', + 'Facebook / Instagram', + 'Friends / family', + 'Play Store', + 'App Store', + 'News / article / blog', + 'Youtube', + 'Others' +]; + +// Step 3b: How old are you? (Age Range Selection) +const ageRanges = [ + 'From 10 to 17 years old', + '18 to 24 years old', + '25 to 34 years old', + '35 to 44 years old', + '45 to 54 years old', + '55 to 64 years old', + '65+' +]; + +export default function AdditionalInfoPage() { + const router = useRouter(); + const { updateAdditionalInfo } = useOnboarding(); + const [step, setStep] = useState<'referral' | 'age'>('referral'); + const [selectedSource, setSelectedSource] = useState(null); + const [selectedAge, setSelectedAge] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSourceSelect = (source: string) => { + setSelectedSource(source); + }; + + const handleAgeSelect = (age: string) => { + setSelectedAge(age); + }; + + const handleContinueFromReferral = () => { + if (selectedSource) { + updateAdditionalInfo('country', selectedSource); + setStep('age'); + } + }; + + const handleContinueFromAge = () => { + if (selectedAge) { + updateAdditionalInfo('occupation', selectedAge); // Storing age + setIsSubmitting(true); + } + }; + + const [loadingProgress, setLoadingProgress] = useState(0); + + // Handle loading animation when submitting + React.useEffect(() => { + if (!isSubmitting) return; + + const interval = setInterval(() => { + setLoadingProgress(prev => { + if (prev >= 100) { + clearInterval(interval); + return 100; + } + return prev + 2; + }); + }, 50); + + const timeout = setTimeout(() => { + router.push('/dashboard'); + }, 2700); + + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, [isSubmitting, router]); + + // Loading screen with animated progress + if (isSubmitting) { + return ( +
+ {/* Puzzle Icon */} +
+ Loading +
+ + {/* Message Card */} +
+ We're setting up your personalized challenges +
+ + {/* Animated Progress Bar */} +
+
+
+
+ {loadingProgress}% +
+
+ ); + } + + // Step 3a: Referral Source Selection Screen + if (step === 'referral') { + return ( +
+ {/* Top Navigation Bar */} +
+ + + {/* Progress Bar - White track */} +
+
+
+ +
+
+ + {/* Header with Puzzle Icon INLINE with Title */} +
+ MindBlock +
+ How do you hear about Block Mind? +
+
+ + {/* Selection Cards - Increased gap */} +
+ {referralSources.map((source) => ( + + ))} +
+ +
+ +
+ + {/* Background Ambience */} +
+
+
+
+
+ ); + } + + // Step 3b: Age Range Selection Screen + return ( +
+ {/* Top Navigation Bar */} +
+ + + {/* Progress Bar - White track */} +
+
+
+ +
+
+ + {/* Header with Puzzle Icon INLINE with Title */} +
+ MindBlock +
+ How old are you? +
+
+ + {/* Age Range Selection Cards - Increased gap */} +
+ {ageRanges.map((age) => ( + + ))} +
+ +
+ +
+ + {/* Background Ambience */} +
+
+
+
+
+ ); +} diff --git a/frontend/app/onboarding/challenge-level/page.tsx b/frontend/app/onboarding/challenge-level/page.tsx new file mode 100644 index 0000000..55a82a8 --- /dev/null +++ b/frontend/app/onboarding/challenge-level/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@/components/Button'; +import { useOnboarding } from '../OnboardingContext'; +import Image from 'next/image'; + +const levels = [ + { id: 'BEGINNER', label: 'I am a total beginner', icon: '/icon-level-beginner.svg' }, + { id: 'INTERMEDIATE', label: 'I am intermediate', icon: '/icon-level-intermediate.svg' }, + { id: 'ADVANCED', label: 'I am advanced', icon: '/icon-level-advanced.svg' }, + { id: 'EXPERT', label: 'I am an expert', icon: '/icon-level-expert.svg' }, +]; + +export default function ChallengeLevelPage() { + const router = useRouter(); + const { data, updateData } = useOnboarding(); + + const handleSelect = (levelId: string) => { + updateData('challengeLevel', levelId); + }; + + const handleContinue = () => { + if (data.challengeLevel) { + router.push('/onboarding/challenge-types'); + } + }; + + return ( +
+ {/* Top Navigation Bar */} +
+ + + {/* Progress Bar - White track */} +
+
+
+ +
+
+ + {/* Header with Puzzle Icon INLINE with Title */} +
+ MindBlock +
+ Choose Challenge level that matches your skills +
+
+ + {/* Selection Cards - Increased gap */} +
+ {levels.map((level) => ( + + ))} +
+ +
+ +
+ + {/* Background Ambience */} +
+
+
+
+
+ ); +} diff --git a/frontend/app/onboarding/challenge-types/page.tsx b/frontend/app/onboarding/challenge-types/page.tsx new file mode 100644 index 0000000..0b32133 --- /dev/null +++ b/frontend/app/onboarding/challenge-types/page.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import Button from '@/components/Button'; +import Image from 'next/image'; +import { useOnboarding } from '../OnboardingContext'; + +const types = [ + { id: 'CODING', label: 'Coding Challenges', icon: '/icon-code.svg' }, + { id: 'LOGIC', label: 'Logic Puzzle', icon: '/icon-puzzle.svg' }, + { id: 'BLOCKCHAIN', label: 'Blockchain', icon: '/icon-blockchain.svg' }, +]; + +export default function ChallengeTypesPage() { + const router = useRouter(); + const { data, updateData } = useOnboarding(); + + const handleToggle = (typeId: string) => { + const current = data.challengeTypes; + if (current.includes(typeId)) { + updateData('challengeTypes', current.filter(id => id !== typeId)); + } else { + updateData('challengeTypes', [...current, typeId]); + } + }; + + const handleContinue = () => { + if (data.challengeTypes.length > 0) { + router.push('/onboarding/additional-info'); + } + }; + + return ( +
+ {/* Top Navigation Bar */} +
+ + + {/* Progress Bar - White track */} +
+
+
+ +
+
+ + {/* Header with Puzzle Icon INLINE with Title */} +
+ MindBlock +
+ Choose the Challenge types (select at least one) +
+
+ + {/* Selection Cards - Increased gap */} +
+ {types.map((type) => { + const isSelected = data.challengeTypes.includes(type.id); + return ( + + ); + })} +
+ +
+ +
+ + {/* Background Ambience */} +
+
+
+
+
+ ); +} diff --git a/frontend/app/onboarding/layout.tsx b/frontend/app/onboarding/layout.tsx new file mode 100644 index 0000000..2f2bca1 --- /dev/null +++ b/frontend/app/onboarding/layout.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { OnboardingProvider } from './OnboardingContext'; + +export default function OnboardingRootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx new file mode 100644 index 0000000..7e09f61 --- /dev/null +++ b/frontend/app/onboarding/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export default function OnboardingPage() { + const router = useRouter(); + + useEffect(() => { + router.replace('/onboarding/challenge-level'); + }, [router]); + + return ( +
+ {/* Optional loader while redirecting */} +
+ ); +} diff --git a/frontend/components/onboarding/OnboardingLayout.tsx b/frontend/components/onboarding/OnboardingLayout.tsx new file mode 100644 index 0000000..2478006 --- /dev/null +++ b/frontend/components/onboarding/OnboardingLayout.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; +import Image from 'next/image'; + +interface OnboardingLayoutProps { + children: React.ReactNode; + currentStep: number; + totalSteps?: number; + title?: string; + onBack?: () => void; +} + +const OnboardingLayout: React.FC = ({ + children, + currentStep, + totalSteps = 4, + onBack, +}) => { + const router = useRouter(); + + const handleBack = () => { + if (onBack) { + onBack(); + } else { + router.back(); + } + }; + + const progressPercentage = (currentStep / totalSteps) * 100; + + return ( +
+ {/* Top Navigation Bar */} +
+ + + {/* Progress Bar */} +
+
+
+ + {/* Placeholder for symmetry or explicit step count if needed */} +
+
+ + {/* Main Content Area */} +
+ + {/* Puzzle Icon Header */} +
+ {/* Using tile.svg as the main logo/puzzle piece */} +
+ MindBlock +
+
+ + {children} +
+ + {/* Background Ambience (Optional) */} +
+
+
+
+
+ ); +}; + +export default OnboardingLayout; diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx index 676e0e8..00b1efb 100644 --- a/frontend/components/ui/Input.tsx +++ b/frontend/components/ui/Input.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { Eye, EyeOff } from 'lucide-react'; -interface InputProps { +interface InputProps extends React.InputHTMLAttributes { type: 'text' | 'email' | 'password'; placeholder: string; value: string; @@ -18,7 +18,8 @@ const Input = ({ value, onChange, label, - className = '' + className = '', + ...props }: InputProps) => { const [showPassword, setShowPassword] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -32,7 +33,7 @@ const Input = ({ return (
{label && ( -