Skip to content

Commit be5e366

Browse files
authored
Merge pull request #339 from Userhorlie/feat/issues-267-268-269-270
feat: real auth/learning API calls, forgot/reset password pages, onboarding wizard
2 parents 40a9166 + 263b9de commit be5e366

10 files changed

Lines changed: 523 additions & 51 deletions

File tree

‎backend/src/users/dto/update-profile.dto.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator';
1+
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class UpdateProfileDto {
@@ -14,5 +14,16 @@ export class UpdateProfileDto {
1414
@IsString()
1515
@MaxLength(500)
1616
bio?: string;
17+
18+
@ApiProperty({ example: true, description: 'onboardingCompleted field', required: false })
19+
@IsOptional()
20+
@IsBoolean()
21+
onboardingCompleted?: boolean;
22+
23+
@ApiProperty({ example: 'Learn DeFi', description: 'learningGoal field', required: false })
24+
@IsOptional()
25+
@IsString()
26+
@MaxLength(200)
27+
learningGoal?: string;
1728
}
1829

‎backend/src/users/entities/user.entity.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export class User {
4848
@Column({ default: false })
4949
suspended: boolean;
5050

51+
@Column({ default: false })
52+
onboardingCompleted: boolean;
53+
54+
@Column({ nullable: true })
55+
learningGoal: string | null;
56+
5157
@Column({ type: 'int', default: 0 })
5258
lessonsCompleted: number;
5359

‎backend/src/users/users.service.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ export class UserService {
156156
user.name = updateProfileDto.username;
157157
}
158158
if (updateProfileDto.bio !== undefined) user.bio = updateProfileDto.bio;
159+
if (updateProfileDto.onboardingCompleted !== undefined) user.onboardingCompleted = updateProfileDto.onboardingCompleted;
160+
if (updateProfileDto.learningGoal !== undefined) user.learningGoal = updateProfileDto.learningGoal;
159161

160162
return this.userRepository.save(user);
161163
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { Mail } from "lucide-react"
5+
import { Button } from "@/components/ui/button"
6+
import { Input } from "@/components/ui/input"
7+
import { useAuth } from "@/contexts/auth-context"
8+
import { toast } from "sonner"
9+
import Link from "next/link"
10+
11+
export default function ForgotPasswordPage() {
12+
const [email, setEmail] = useState("")
13+
const [isLoading, setIsLoading] = useState(false)
14+
const [sent, setSent] = useState(false)
15+
const { forgotPassword } = useAuth()
16+
17+
const handleSubmit = async (e: React.FormEvent) => {
18+
e.preventDefault()
19+
setIsLoading(true)
20+
try {
21+
await forgotPassword(email)
22+
setSent(true)
23+
} catch {
24+
toast.error("Something went wrong. Please try again.")
25+
} finally {
26+
setIsLoading(false)
27+
}
28+
}
29+
30+
return (
31+
<div className="min-h-screen bg-[#0a0a0a] text-white flex items-center justify-center px-4">
32+
<div className="w-full max-w-md space-y-6">
33+
<div className="text-center">
34+
<div className="flex justify-center mb-4">
35+
<div className="w-16 h-16 rounded-xl bg-[#00ff88]/20 flex items-center justify-center border border-[#00ff88]/30">
36+
<Mail className="w-8 h-8 text-[#00ff88]" />
37+
</div>
38+
</div>
39+
<h1 className="text-2xl font-bold">Forgot Password</h1>
40+
<p className="text-gray-400 mt-2">
41+
Enter your email and we&apos;ll send you a reset link.
42+
</p>
43+
</div>
44+
45+
{sent ? (
46+
<div className="p-4 rounded-lg bg-[#00ff88]/10 border border-[#00ff88]/30 text-center text-[#00ff88]">
47+
Check your email for a password reset link.
48+
</div>
49+
) : (
50+
<form onSubmit={handleSubmit} className="space-y-4">
51+
<div className="space-y-2">
52+
<label htmlFor="email" className="text-sm font-medium text-[#c7d5e3]">
53+
Email
54+
</label>
55+
<div className="relative">
56+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
57+
<Input
58+
id="email"
59+
type="email"
60+
placeholder="you@example.com"
61+
value={email}
62+
onChange={(e) => setEmail(e.target.value)}
63+
className="pl-10"
64+
required
65+
/>
66+
</div>
67+
</div>
68+
<Button type="submit" className="w-full bg-[#02c177]" disabled={isLoading}>
69+
{isLoading ? "Sending..." : "Send Reset Link"}
70+
</Button>
71+
</form>
72+
)}
73+
74+
<div className="text-center text-sm text-gray-400">
75+
<Link href="/" className="text-[#02c177] hover:underline">
76+
Back to home
77+
</Link>
78+
</div>
79+
</div>
80+
</div>
81+
)
82+
}

‎frontend/app/onboarding/page.tsx‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { Button } from "@/components/ui/button"
6+
import { api } from "@/lib/api"
7+
import { useQuery } from "@tanstack/react-query"
8+
import { toast } from "sonner"
9+
import { BookOpen, Zap, TrendingUp, Shield } from "lucide-react"
10+
11+
const EXPERIENCE_LEVELS = [
12+
{ value: "beginner", label: "Complete beginner", description: "I'm new to crypto and blockchain" },
13+
{ value: "some", label: "Some knowledge", description: "I know the basics but want to go deeper" },
14+
{ value: "experienced", label: "Already investing", description: "I trade/invest and want to learn more" },
15+
]
16+
17+
const LEARNING_GOALS = [
18+
{ value: "understand_crypto", label: "Understand crypto basics", icon: BookOpen },
19+
{ value: "learn_defi", label: "Learn DeFi & yield", icon: TrendingUp },
20+
{ value: "build_web3", label: "Build Web3 apps", icon: Zap },
21+
{ value: "secure_assets", label: "Secure my assets", icon: Shield },
22+
]
23+
24+
interface Course {
25+
id: string
26+
title: string
27+
description: string
28+
difficulty?: string
29+
}
30+
31+
export default function OnboardingPage() {
32+
const router = useRouter()
33+
const [step, setStep] = useState(1)
34+
const [experience, setExperience] = useState("")
35+
const [goal, setGoal] = useState("")
36+
const [enrolling, setEnrolling] = useState<string | null>(null)
37+
38+
const difficulty =
39+
experience === "beginner" ? "Beginner" : experience === "some" ? "Intermediate" : "Advanced"
40+
41+
const { data: courses = [], isLoading: coursesLoading } = useQuery<Course[]>({
42+
queryKey: ["courses", difficulty],
43+
queryFn: async () => {
44+
const res = await api.get<Course[] | { data?: Course[] }>(`/courses?difficulty=${difficulty}`)
45+
return Array.isArray(res) ? res : (res?.data ?? [])
46+
},
47+
enabled: step === 4,
48+
})
49+
50+
const saveAndFinish = async (courseId?: string) => {
51+
try {
52+
await api.patch("/users/me", { onboardingCompleted: true, learningGoal: goal })
53+
if (courseId) {
54+
setEnrolling(courseId)
55+
await api.post(`/courses/${courseId}/enroll`, {})
56+
}
57+
router.push("/dashboard")
58+
} catch {
59+
toast.error("Something went wrong. Please try again.")
60+
} finally {
61+
setEnrolling(null)
62+
}
63+
}
64+
65+
return (
66+
<div className="min-h-screen bg-[#0a0a0a] text-white flex items-center justify-center px-4">
67+
<div className="w-full max-w-lg space-y-8">
68+
{/* Progress bar */}
69+
<div className="flex gap-2">
70+
{[1, 2, 3, 4].map((s) => (
71+
<div
72+
key={s}
73+
className={`h-1 flex-1 rounded-full transition-colors ${s <= step ? "bg-[#00ff88]" : "bg-white/10"}`}
74+
/>
75+
))}
76+
</div>
77+
78+
{/* Step 1: Welcome */}
79+
{step === 1 && (
80+
<div className="space-y-6 text-center">
81+
<div className="flex justify-center">
82+
<div className="w-20 h-20 rounded-2xl bg-[#00ff88]/20 flex items-center justify-center border border-[#00ff88]/30">
83+
<BookOpen className="w-10 h-10 text-[#00ff88]" />
84+
</div>
85+
</div>
86+
<div>
87+
<h1 className="text-3xl font-bold mb-3">Welcome to ByteChain Academy</h1>
88+
<p className="text-gray-400">
89+
Short, practical lessons on crypto, DeFi, and Web3. Earn XP, collect badges, and get
90+
verifiable certificates — all at your own pace.
91+
</p>
92+
</div>
93+
<Button className="w-full bg-[#02c177]" onClick={() => setStep(2)}>
94+
Get Started
95+
</Button>
96+
</div>
97+
)}
98+
99+
{/* Step 2: Experience level */}
100+
{step === 2 && (
101+
<div className="space-y-6">
102+
<div>
103+
<h2 className="text-2xl font-bold mb-2">What&apos;s your experience level?</h2>
104+
<p className="text-gray-400">We&apos;ll tailor course recommendations for you.</p>
105+
</div>
106+
<div className="space-y-3">
107+
{EXPERIENCE_LEVELS.map((lvl) => (
108+
<button
109+
key={lvl.value}
110+
onClick={() => setExperience(lvl.value)}
111+
className={`w-full text-left p-4 rounded-xl border transition-colors ${
112+
experience === lvl.value
113+
? "border-[#00ff88] bg-[#00ff88]/10"
114+
: "border-white/10 bg-white/5 hover:border-white/30"
115+
}`}
116+
>
117+
<div className="font-medium">{lvl.label}</div>
118+
<div className="text-sm text-gray-400">{lvl.description}</div>
119+
</button>
120+
))}
121+
</div>
122+
<Button
123+
className="w-full bg-[#02c177]"
124+
disabled={!experience}
125+
onClick={() => setStep(3)}
126+
>
127+
Continue
128+
</Button>
129+
</div>
130+
)}
131+
132+
{/* Step 3: Learning goal */}
133+
{step === 3 && (
134+
<div className="space-y-6">
135+
<div>
136+
<h2 className="text-2xl font-bold mb-2">What&apos;s your main goal?</h2>
137+
<p className="text-gray-400">Pick the topic you&apos;re most excited about.</p>
138+
</div>
139+
<div className="grid grid-cols-2 gap-3">
140+
{LEARNING_GOALS.map(({ value, label, icon: Icon }) => (
141+
<button
142+
key={value}
143+
onClick={() => setGoal(value)}
144+
className={`p-4 rounded-xl border text-left transition-colors ${
145+
goal === value
146+
? "border-[#00ff88] bg-[#00ff88]/10"
147+
: "border-white/10 bg-white/5 hover:border-white/30"
148+
}`}
149+
>
150+
<Icon className="w-6 h-6 text-[#00ff88] mb-2" />
151+
<div className="text-sm font-medium">{label}</div>
152+
</button>
153+
))}
154+
</div>
155+
<Button
156+
className="w-full bg-[#02c177]"
157+
disabled={!goal}
158+
onClick={() => setStep(4)}
159+
>
160+
See Recommendations
161+
</Button>
162+
</div>
163+
)}
164+
165+
{/* Step 4: Course recommendations */}
166+
{step === 4 && (
167+
<div className="space-y-6">
168+
<div>
169+
<h2 className="text-2xl font-bold mb-2">Recommended for you</h2>
170+
<p className="text-gray-400">
171+
Based on your goals. Enrol in one to get started.
172+
</p>
173+
</div>
174+
175+
{coursesLoading && (
176+
<div className="space-y-3">
177+
{[1, 2, 3].map((i) => (
178+
<div key={i} className="h-20 rounded-xl bg-white/5 animate-pulse" />
179+
))}
180+
</div>
181+
)}
182+
183+
{!coursesLoading && courses.length === 0 && (
184+
<p className="text-gray-400 text-center py-6">No courses available yet.</p>
185+
)}
186+
187+
<div className="space-y-3">
188+
{courses.slice(0, 4).map((course) => (
189+
<div
190+
key={course.id}
191+
className="p-4 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-4"
192+
>
193+
<div className="min-w-0">
194+
<div className="font-medium truncate">{course.title}</div>
195+
<div className="text-sm text-gray-400 truncate">{course.description}</div>
196+
</div>
197+
<Button
198+
size="sm"
199+
className="shrink-0 bg-[#02c177]"
200+
disabled={enrolling === course.id}
201+
onClick={() => void saveAndFinish(course.id)}
202+
>
203+
{enrolling === course.id ? "Enrolling..." : "Enrol & Start"}
204+
</Button>
205+
</div>
206+
))}
207+
</div>
208+
209+
<Button
210+
variant="outline"
211+
className="w-full"
212+
onClick={() => void saveAndFinish()}
213+
>
214+
Skip — go to dashboard
215+
</Button>
216+
</div>
217+
)}
218+
</div>
219+
</div>
220+
)
221+
}

0 commit comments

Comments
 (0)